A Tale of Two Systems: A Primer on Software Architecture (Part 1)

Share
A Tale of Two Systems: A Primer on Software Architecture (Part 1)

It was the best of times, it was the worst of times… it was the spring of hope, it was the winter of despair…” — Charles Dickens on writing code, probably. 


Anyone who has worked with a software system for a significant amount of time knows that software is a double-edged sword. When it works, software can accelerate workflows, power businesses, and improve lives. When it fails, software can be maddening, costly, or even deadly. But what separates a system that merely works from a system that is reliable and engineered well? Imagine two systems that appear the same from the outside. They both accomplish their task correctly, but one is chaotic, jerry-rigged, and hard for developers to understand. The other is logical, comprehensible, and easy for developers to reason about. How can you end up with the latter and not the former?

This becomes critical when you understand that the only constants in life are death, taxes, and software requirements changing. A software system’s goals are constantly changing because of customer needs, regulations, or any number of factors. There’s a good chance that a system that no longer needs to be changed is no longer being used. Good system design is critical because a system that is inflexible to being tweaked, let alone substantially altered, is not a system any developer wants to work with.

Many software systems start out with the best of intentions and end up in a state of chaos. Programmers start putting logic and data processing code wherever it's convenient to get the job done. This leads to confusing and inflexible systems that slow down changes, especially in projects that have a long lifespan. An experienced software engineer instead practices careful design of the parts of a system, before writing or modifying any code. This leads to flexible systems that adapt well to new requirements. This article aims to introduce readers to the vocabulary and habits that help to build sustainable software!

💡
A practical aside: This may sound great if you’re ready to build a system from scratch, but what about when you already have a confusing mess on your hands and the show must go on? In a future post, we’ll discuss patterns that are useful for stopping the bleeding of a chaotic project and orienting it towards a more stable future. 

Basic Elements of Software Architecture

There are many theories on the correct way to design software architecture. There are trends that have risen and fallen throughout the history of software design. The point of this article is not to insist that you must use microservice architecture over a monolith, or other decisions of that nature, but instead to advocate for the principles that are applicable to many architectural styles. This section aims to give you the vocabulary to discuss different aspects of software architecture.

Generically, the basic unit of software is a component. A component is responsible for a single, comprehensible unit of work, called a concern. Components can be composed to form larger components or a whole program. For a non-software analogy: Organelles are composed into cells, which are composed into body parts, which are composed into the whole body of a biological organism. Both organelles and software components individually function to accomplish a logical task.

The next important concepts are interfaces and contracts. These are how components communicate with each other. The interface is the set of operations that the component exposes to the outside world. It’s the published “menu” of actions. The contract is the expectations—sometimes implicit—that the component commits to upholding when it executes an operation.[1] Generally, the more explicit the contract, the better. Contracts define what the component does, but not necessarily how it does it.

Dependency describes the relationship between connected modules. A dependency exists when component A needs component B to do its job (“B is a dependency of A”). There are a few dangers to watch for with this concept. Cyclical dependencies are concerning; if A and B depend on each other, they become difficult to understand or test separately. Additionally, dependencies may be implicit. For example, A and B might have some shared global state. Even if A and B don’t call each other’s functions, they become implicit dependencies.

Different architectural styles prescribe different preferences for grouping components together. For example, a layered architecture might prefer to group components into data storage, logic, and presentation layers. In this case, the preference is to group components by domain function. The key point is that groupings should be made consciously, not by accident. You might describe these as the “zoning laws” of your software. Where do specific components live?

Finally, a warning not to be too tripped up by the small details. Architecture is about the set of decisions that are costly to change later. What components exist? How are they organized? What is not important at this point are implementation details. The algorithm or library you choose is not critical. A building architect isn’t too concerned about what color the walls will be painted.


Information Hiding and the Power of Slim Interfaces

Now that we have a vocabulary to discuss the concepts, how should we break a system down into components and define their interfaces? Many programmers intuitively create modules based on the steps needed to achieve the final result.

“Parnas’s Principle” of information hiding tells us that this intuitive approach is often incorrect, as it can expose unstable data representations to subsequent steps.[2]

Instead, components should be designed intentionally to “hide” implementation decisions from other components, and the most important implementation decisions to hide are the ones that are most likely to change.

Let’s take a concrete example of a component that checks a user’s credentials to connect to a server. The behavioral contract is straightforward and unlikely to change; given a username and password pair, it will decide whether access should be granted. The things that are likely to change are the implementation details: the password hashing algorithm, the user database schema, brute force protections, etc. Therefore, the information hiding principle says that other components should not be allowed to know about those details. If we just focused on the steps of deciding whether the password was correct, we could leak a lot more of the details to other components.

More abstractly, components should have high cohesion and low coupling. High cohesion means that the component’s job can be stated succinctly and clearly because it is coherent and focused. Low coupling means that the component relies on other components to the smallest possible degree and exposes as little as possible about itself to the outside world.

Variables and functions should be private until proven otherwise.

In other words, the interface of each component should be as slim as it can be while still fulfilling its purpose. This practice gives several concrete benefits:

  • Refactoring safety: If the internal structure of a component is hidden, we can more easily change the difficult implementation decisions, like the password hashing algorithm in the example above. The interface is the contract; the implementation is the component’s private business.
  • Testability: A component with a small interface is easy to test in isolation. The slim interface gives a limited number of variables to change. Additionally, it becomes easier to test the dependent components, because a slim interface is easier to fake, or mock, in the test environment. A mock or “test double” is a simple version of a component that has the same interface, but a different implementation. For example, a test database that pretends to store data and never has any complications. We can increase our confidence that each component achieves its purpose and show that the code behaves correctly when dependencies fulfill their contracts.
  • Ownership and comprehensibility: A component with a slim interface and high cohesion is much easier for others to understand. If the implementation is hiding information well, the developer should be able to modify the internals without breaking the contract. The developer can also have an abstract mental model of the dependency components, meaning the developer doesn’t care about the dependency’s internal structure, just the contract. The developer doesn’t have to hold every piece of the software in their mind simultaneously.

Another important dimension of component design is orthogonality, meaning component responsibilities do not overlap.[3] No two components can own the same concern. If they do, you get duplicated logic, conflicting behavior, and ambiguity about where a change should be made. Orthogonality might be called a corollary of high cohesion because it focuses on ensuring that each component has a unique function for the project.

In a well-architected system, each component has a clear job, a slim interface, and minimal dependencies. Each component can be tested in isolation, and the blast zones of changes are minimized. In a questionable system, components have multiple or muddled responsibilities, no principle of information hiding, and everything depends on everything else. Testing becomes complicated, and changes can result in many uncontrolled results cascading through the system.

In practice, any time you encounter a boundary in a software system, you should be thinking about information hiding. The public vs. private methods of a class in OOP are an interface. The definitions exported from a JavaScript module are an interface. The endpoints exposed in a REST API by a webserver are also an interface. These are opportunities to limit information exposure and help the ease-of-change for future developers.

Practical Example

Here's one practical example to illustrate information hiding. We'll explore more details in future posts.

The tightly coupled version:

class PaymentProcessor {
    public cardNumber: string;
    public cvv: string;
    public hashAlgorithm: string;
    public retryCount: number;

    public validate() { ... }
    public hash() { ... }
    public charge() { ... }
}

class CheckoutService {
    process(cart: Cart, processor: PaymentProcessor) {
        // Checkout knows everything about payment internals
        processor.cardNumber = cart.paymentDetails.number;
        processor.cvv = cart.paymentDetails.cvv;
        processor.hashAlgorithm = "sha256";
        processor.retryCount = 3;
        processor.validate();
        processor.hash();
        processor.charge();
    }
}

The improved version with information hiding applied:

class PaymentProcessor {
    private cardNumber: string;
    private cvv: string;
    private hashAlgorithm: string;
    private retryCount: number;

    public charge(details: PaymentDetails) { ... }
}

class CheckoutService {
    process(cart: Cart, processor: PaymentProcessor) {
        // CheckoutService knows the *steps* to check out, but not how the steps happen.
        processor.charge(cart.paymentDetails);
    }
}

  1. The “contract” was coined by Bertrand Meyer in his 1988 book Object-Oriented Software Construction. It’s an excellent guide for how to make implicit contracts explicit in software. ↩︎

  2. David Parnas introduced this concept in his seminal 1972 paper “On the Criteria To Be Used In Decomposing Systems into Modules”. Worth a read for detailed examples of the principle. ↩︎

  3. The concept of orthogonality was introduced in the 1999 book The Pragmatic Programmer by Andrew Hunt and David Thomas. It’s an excellent resource for practical advice in writing flexible software. ↩︎

Read more