A Tale of Two Systems: A Primer on Software Architecture (Part 2)
In the last post, we covered some of the basics of architectural vocabulary and the importance of information hiding in component design. In this part, we will discuss some concrete patterns that frequently appear in software design and how to apply information hiding to them.
In this context, pattern means a positive design structure that recurs frequently in different design contexts, and an anti-pattern is a negative design structure that recurs frequently. In other words, a pattern is a good design tool to keep in your toolbox, and an anti-pattern is a common design pitfall to watch out for.
Big Ball of Mud vs Layers
First, the king of anti-patterns is the “big ball of mud.” According to the original definition, a big ball of mud is a “haphazardly structured, sprawling, sloppy, duct-tape and bailing wire, spaghetti code jungle.”[1] This definition is the antithesis of information hiding. The key insight of this anti-pattern is that systems naturally tend towards a confused state like that of a swamp, where every component resembles the others and have their responsibilities muddied together. This may be because time or resources were scarce, the problem was not well understood, or “temporary” code became permanent. Architecture requires an active investment of resources to maintain. Every architect must make assumptions and accept tradeoffs.
The corresponding pattern is layers, conceptual groupings of components that allow separation and quarantining. These layers may be created at the beginning of the system or may need to be identified after experience has taught something about the problem domain. Layers help to minimize dependencies, promote cohesion, and limit coupling. For example, you might architecturally enforce seperation between data storage, data processing, and data UI display, even if all those pieces of code deal with the same data. The key is constant vigilance for potential useful abstractions and separations, while also spending time efficiently, resisting the urge to overengineer for every potential future problem that probably won't come up.
God Object vs Single Responsibility
A more localized version of the same problem is the “god object.” While the big ball of mud anti-pattern means that there is unclear separation between parts of the system, the god object means that one component continues to take on more tasks, until eventually the entire system revolves around it. If a change to it breaks the entire system, or you struggle to describe everything it does, it might be a god object. This often occurs out of a sense of misguided convenience. Why do the work to build two objects when one can do both jobs? It can also occur by slow accumulation of functionality, when there's no time to separate into new components.
A better pattern to use instead is the Single Responsibility principle. A component should have a job that can be described in a straightforward way and understood by a single developer.[2] This promotes high cohesion. Before adding a new feature to existing code, consider whether your change would violate this principle.
Leaky Abstraction vs Contracts
A more subtle problem in how components interact with each other is the “leaky abstraction.” This means that a component attempts to use information hiding to protect other components from implementation decisions but fails to do so completely. When driving a car, the steering wheel and the pedals are an abstraction to everything happening inside. The check-engine light is the abstraction leaking: you are forced to care about the engine, which is an implementation detail of the contract “the car should move forward with the gas pedal.” This is inevitable for any non-trivial abstraction, because you are trying to present a complex system in a simplified manner. In software, a leak occurs when other components are forced to rely on something that the component didn’t mean to promise: the order of results, the speed of computation, etc., which are implementation details, not contractual.
The remedy goes back to a critical point about interface design: interfaces should be designed around what a component does, and not how it accomplishes it. The interface should speak the caller’s language, not the implementation’s internal language. In the car example, this would mean that the dashboard should tell you what you need to do (“visit a service station”) and not what the engine’s internal problem is (“fault code 1234”).
Implicit Coupling vs Dependency Injection
A sneakier failure mode of interface communication is “implicit coupling.” Implicit coupling means that dependencies are not immediately obvious while looking at the component, and that a dependency relationship has been hidden in some way. Implicit couplings create the potential for subtle, difficult-to-diagnose bugs. These couplings include shared state (especially shared global state), shared databases, assumptions about execution order, and circular dependencies. One version of global state that I especially want to mention is singletons. A singleton is a component that declares that only one copy of itself can ever be created. This is obviously convenient, as every other object always knows which one to reference. However, this can very easily become a brittle god-object that is very difficult to understand or change.
The remedy is dependency injection where all dependencies are explicitly declared, usually through a constructor parameter, or at least a factory pattern. This allows the injection of testing mocks or other implementations that improve the flexibility of components.
The practicality and the pitfalls of singletons are easy to demonstrate. (This applies similarly to other global state as well.)
The antipattern, singletons:
class Logger {
private static instance: Logger;
private constructor() { }
// Any component can grab the global instance at any time
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public log(message: string) { ... }
}
class CheckoutService {
process(cart: Cart) {
// Reaches out and grabs the global instance
Logger.getInstance().log("Processing cart...");
// ...
}
}
Any time I need to log a message, it's very easy to do so! However, the dependency is not immediately clear, and it's impossible to swap to a different version, even in tests.
Dependency injection, the better pattern:
class CheckoutService {
private logger: Logger;
// Dependency is declared explicitly and provided externally
constructor(logger: Logger) {
this.logger = logger;
}
process(cart: Cart) {
this.logger.log("Processing cart...");
// ...
}
}
It's easy to inject a different logger when we need to swap it out. It's also immediately obvious from the constructor that CheckoutService depends on Logger.
Concretes vs Interfaces
Dependency injection solves the problem of hidden dependencies by forcing them to be explicit. But what should those injected dependencies look like? To answer we must explore a related problem, dependence on concretes instead of interfaces. This high-level concept covers the intersection of leaky abstractions and implicit coupling from a different angle. Say that your system pulls data from a SQLite database. Later, you decide that SQL Server will meet your data storage requirements better. However, your abstraction to your database was leaky and therefore some of your app’s business logic relies on patterns specific to SQLite. This makes it a headache to swap your data storage layer. Instead of relying on a specific database, you should have relied on the general concept of a database.
A dependency should always be to an interface, not a concrete implementation. In some languages (C#, Java), this is a well-supported first-class concept. In others (Python, JavaScript), the responsibility is more on programmer discipline to not reach into the abstraction. Conceptually, you want to rely on the least specific version of the interface that will still fulfill your needs.
Here's what that looks like in code.
Anti-pattern — depending on a concrete implementation:
class SQLiteRepository {
public getProduct(id: string): Product { ... }
public saveOrder(order: Order): void { ... }
public runRawQuery(sql: string): any { ... }
}
class CheckoutService {
private repository: SQLiteRepository;
constructor() {
// Hardwired to a specific implementation
this.repository = new SQLiteRepository();
}
process(cart: Cart) {
const product = this.repository.getProduct(cart.productId);
// Tempted to use runRawQuery because it's right there...
this.repository.runRawQuery("SELECT...");
}
}
Pattern — depending on an interface:
interface ProductRepository {
getProduct(id: string): Product;
saveOrder(order: Order): void;
}
class SQLiteRepository implements ProductRepository {
public getProduct(id: string): Product { ... }
public saveOrder(order: Order): void { ... }
// runRawQuery is private — not part of the interface
private runRawQuery(sql: string): any {...}
}
class CheckoutService {
private repository: ProductRepository;
constructor(repository: ProductRepository) {
this.repository = repository;
}
process(cart: Cart) {
const product = this.repository.getProduct(cart.productId);
// runRawQuery is not visible here — the leak is sealed
}
}
In the first version, CheckoutService is hardwired to SQLite and has access to runRawQuery, an implementation detail it was never meant to use. Switching to a different database requires changes throughout CheckoutService. In the second version, CheckoutService depends only on the interface. Swapping SQLiteRepository for a SQL Server implementation requires no changes to CheckoutService at all.
Cathedral of Hubris vs Design for the Problem You Have
Finally, all the previous suggestions have been about adding increased structure and discipline; however, structure taken too far can create problems, too. The opposite of the big ball of mud is the cathedral of hubris where every piece has an exact purpose, and every possibility is accounted for. Every component is wrapped in multiple abstractions that try to anticipate complex requirements that may never materialize. A developer must crawl through those abstraction layers to find the functionality that they care about. Complex systems are built and maintained to cover just one small subset of the cases that you really care about. Overengineering is a serious architectural problem because all complexity is also future liability.
Being prepared for future possibilities is a plus, but unjustified paranoia about future requirements also creates failures. The goal is not minimal structure, nor maximum abstraction, but the appropriate structure for the problem at hand. This requires experience, prudence, and consultation with other project stakeholders.
In the next part, we will discuss applying these design principles when building a new project from scratch.
This definition hails from the 1999 paper “Big Ball of Mud” by Brian Foote and Joseph Yoder. Worth a read for its detailed analysis of the circumstances that cause programmers to create messy systems, and the steps that can be taken to fix the problem. ↩︎
The single responsibility principle is part of the SOLID framework proposed by Robert C. Martin in his 2002 book Agile Software Development: Principles, Patterns, and Practices. The other principles in the framework are also quite useful for component design. ↩︎