In The first principle of software design, I introduced the core “principle” that underlies software design (and, really, design in general): All software is created to fit a context.

In this post, I’ll be talking about applying the First Principle to understand how to decouple your software, and how coupling is tightly related to the idea of the software’s design context.

This is an important topic for any engineer to keep in mind if they want to design high-quality systems. I would go so far as to say the best engineers always think about software design in terms of (de)coupling, modularization, and abstraction first. Everything else (best practices, design patterns, technology choices, etc.) just flows from there.

Table of Contents

(De)coupling

Coupling is when two things are linked together in a way that makes it difficult to manage and address them as separate units. Coupling isn’t inherently a bad thing, but an overly coupled solution tends to be difficult to understand and change.

Photo of a Rubik's Cube

Picture a Rubik’s cube — it’s not easy to change one face without also changing other faces. If you could freely move individual squares, the puzzle would be trivial. The difficulty of solving a Rubik’s cube is dealing with the coupling inherent in the puzzle.

In the very same way, a highly coupled software system often makes it hard to change what needs to change without incidentally affecting things that don’t need to be changed. That’s why a well-designed software system ensures that unrelated concepts are properly decoupled.

A decoupled software design is somehow “separated” into multiple parts that can be swapped out, maintained independently of the rest of the system, and reused in different contexts.

Of course, unlike the simple, mechanical coupling in a Rubik’s cube, the coupling in a software system can’t be tamed by following a series of memorizable algorithms over and over. There’s no silver-bullet strategy. Producing a decoupled software system requires a combination of careful thought, experience, iteration, proper tooling, and luck.

There’s no such thing as a useful system where everything is decoupled — a “true fully decoupled” system would end up as nothing more than disorganized, useless bits, like a pile of disconnected Legos.

When I talk about a “decoupled system,” I’m referring to a system where unnecessary coupling (i.e. coupling not beneficial for the system’s operation and maintenance) is eliminated.

Decoupling is behind all sorts of common patterns in software design:

Software patternDecouplesFrom
Config filesFrequently-changed valuesRarely-changed code
Queues and event busesConsuming dataProducing data
MicroserviceSmall chunk of the problem domainRest of the domain
Dependency injectionUsing dependenciesInstantiating dependencies
Throwing exceptionsHandling errorsCreating errors

Decoupling isn’t limited to software design, either. Consider a to-go coffee cup:

Illustration of a coffee cup, with the cup, sleeve, and lid labeled as modules.

The cup has three parts:

  1. The cup itself.
  2. The thermal sleeve.
  3. The lid.

The coffee cup is a decoupled system. If your drink isn’t hot, you can simply remove the thermal sleeve. If you’re not worried about spilling it, you can remove the lid. If you’re not drinking from it, you could even replace the lid with a different type that completely prevents spills.

In this way, the same system of objects — the to-go coffee cup — can be adapted to suit a variety of contexts.

It’s important to understand what should and shouldn’t be decoupled. It wouldn’t make sense to have, say, the bottom of the cup be a removable module like the lid, because that component (the bottom) is essential to the functioning of the cup.

Similarly, there is no point to trying to decouple things in our software design unless they’re already decoupled in the design context.

In other words, the coupling of our software design should mirror the coupling we perceive in the design context we’re solving for. I think of this as coupling should be isomorphic with context.

Coupling should be isomorphic with context

Isomorphic: (1) Sharing the same structure, relations, and/or form; (2) mathematically, having an isomorphism between them (see Isomorphism).

The idea of isomorphism is a very interesting one. Setting aside the literal mathematical definition, two things are isomorphic if they share the same abstract structure.

In this case, the idea is that the way your program is coupled should have a similarity — a correspondence — with the way the design context itself is coupled.

If you’re familiar with Domain-Driven Design, this idea might sound familiar. In DDD, the software design is modeled based on the “domain” (roughly speaking, the DDD “domain” is equivalent to our “context”).

There’s a lot more to DDD than just this idea, though (the importance of using the domain’s vocabulary comes to mind). If you’re not familiar, read Domain-Driven Design by Eric Evans!

You’re making a software system that allows users to make online purchases. Your company has (non-programmer) analysts that want to be able to construct reports about these purchases — which products are most popular, what times of day have the most purchases, whether discount codes encourage more purchases, etc. They want to be able to create and edit reports frequently without programmer assistance.

You could have the analysts run reports directly against the main database, but based on the analysts’ needs, you’ve decided to decouple the “analytics” part of the system from the “transaction processing” part of the system by emitting events to a specialized analytics database.

This way, (a) analysts can use a tool they’re familiar with to generate reports, and (b) analytics queries cannot cause degradations to the customer experience.

You’ve successfully “mirrored” the structure of the context — the distinction between the analysts’ needs and the core system’s needs — into your software design. This is an example of effective decoupling.

You’re writing a tool that converts between two well-defined, standardized, but complicated application protocols. (Say, for example, that one is from some legacy XML-based API and the other is a simpler, JSON-based API.)

You’ve also identified that the stakeholders who’ll be using the tool will require additional validation of the resulting JSON to ensure it can be loaded into their database.

Your first pass defines a module that performs the mapping to the new protocol and enforces the validation rules at the same time:

Performing translation and validation at the same time.

This design has coupled the translation between the two protocols with the database-related validation.

At first there seems to be no problem, but later, the users need to support a new database technology as well, which has different validation rules. Now you need to swim through all 1500 lines of translation and validation mixed together in order to support the new requirement.

What if instead you (re)wrote the tool to operate in two stages, decoupling translation and validation:

Performing translation and validation in separate stages.

The new solution has slightly more lines of code — 1550 total vs. 1500 total — reflecting the overhead of writing the logic in a decoupled way. But, now the requirement to support a new database technology is easy. You don’t need to touch the translation code at all — just update the 150 lines of validation code.

Of course, it may not have been obvious at the beginning of the project that it would eventually need to support multiple databases. But what you did know was that the translation code was unlikely to change frequently, being based on two standard formats, whereas the validation code was based on your users’ specific needs, which tend to vary over time.

An experienced engineer can recognize this distinction in the context of the problem, and architect their solution to match. Matching the coupling of your code to the coupling of the design context is the right way to “future-proof” your software.

Modularization

The way I use it, modularization means designing your software solution in a way that organizes the various “dimensions” of the context we’re designing for into separate chunks — “modules”.

Modularization is a much more specific concept than decoupling; modularization is not the only way to decouple code, but it’s an important one to master. Decoupling is an end, modularization is a means.

There is a tight relationship between modularization, decoupling, and abstraction: without abstractions, there’s no enforcement mechanism for modularization, and therefore decoupling is much harder. (As a side note, you can have code that has modules all over the place but still isn’t decoupled. Only well-designed modules that act as effective abstractions are good at decoupling.)

Just because we call it modularization doesn’t mean it has to be implemented with a programming language feature called “Modules.”

Modularization is the “what,” and modules are one example of a “how.” But modularization can also be achieved using classes, namespaces, plain functions, and so on. The name of the language feature is not very important.

Larger software systems depend on modularization and abstraction to organize and manage complexity.

A simple example is something like assembly code, where you don’t have functions or procedures to abstract logic, and rely on jumps (gotos) to move between different parts of the program. With this programming model, it’s difficult to ensure different pieces of code are truly modularized, because there’s always the possibility of jumping right to the middle of some logic.

In contrast, with a programming language that supports functions, it’s possible to hide the implementation of the function such that it’s impossible for other pieces of code to directly couple with the function’s implementation. (Of course, you can still couple with the function’s behavior, which sometimes comes down to the same thing. And if the function has side effects, there’s the possibility of coupling with those. These are examples of a function operating as a “leaky” abstraction.)

The ability to hide implementation details is a type of abstraction that’s strongly recommended for modularizing code. You can implement modularization without implementation hiding, (e.g. in assembly code), but it requires more discipline and is less maintainable than modularization implemented using modules, closures, or some other implementation-hiding mechanism.

Instead of trying to explain how to modularize a program in abstract terms, I’ll provide a couple examples.

You’ve been working on a front-end SPA (Single-Page Application) and your UI code is getting increasingly difficult to handle. It’s not clear how different pieces of the UI are related to each other, changing one thing can unexpectedly impact other things, state management has become so complicated it’s risky to change, and you have a variety of hodgepodge functions to try and reduce code repetition.

To help tame this complexity, you pick up a component-based UI library like React. By splitting your UI into dozens of Components, you find it much easier to make changes and understand how data flows through your app.

Components are a type of modularization. Each component provides an abstraction: give me some props, children, etc., and I’ll “do a thing.” Exactly how I do that thing isn’t relevant. A component might have internal state, but that’s an implementation detail that’s hidden from callers.

Working on a Web application, you quickly recognize that there is some “boilerplate” code you run for every HTTP request. Also, your code includes a mixture of HTTP-related concerns (like returning certain status codes) with business logic. This is making it harder to reuse and compose your business logic in a non-HTTP context (e.g. running in a batch job).

So, you decide to decouple the HTTP-related stuff from the business logic. You take advantage of a series of modularization and decoupling capabilities in your programming language:

  1. Using functions (specifically, “higher-order functions” and “factories”) to implement HTTP concerns. Business logic goes into a separate set of functions that just accept and return normal values. The HTTP-implementing functions are responsible for parsing an HTTP request, calling the appropriate business logic, then parsing the return value into an HTTP response. (Congratulations, you just reinvented MVC Controllers!)
  2. Throwing exceptions in your business logic code and then catching them in your controller and converting them into an appropriate HTTP response. This way, if you want to handle the exceptions differently in the future, the business logic doesn’t need to change, just the “catching” logic.

You find that once the HTTP-layer concerns were constructed, they hardly need to change at all, even when adding new functionality. This makes it easier for you to add new business logic to your app without worrying about how it works with HTTP. It also lets you use the exact same business logic functions in your batch jobs.

Excessive modularization is also a problem. Remember the First Principle — the coupling of the system should reflect the coupling in the problem domain (context).

Also, modularization and abstraction have overhead (usually related to indirection). An excessively modularized program might be very decoupled, and each individual “module” might be easy to understand, but the system is also more complex as a whole than a less-modularized program would be.

There’s no hard rule, but a simple heuristic is that if you are looking at 100 LoC or fewer, modularization will often make the code even more complicated than leaving it as-is.

If you can’t explain why a chunk of logic should be decoupled from the rest of your code, don’t modularize it. (As a side note, this is a common problem I’ve noticed with early-stage career interview candidates’ code samples. They create too many modules because “it’s best practice,” despite the modules not offering any benefit. Then, when asked “why did you make this a module,” they struggle to justify the decision. Too many modules isn’t the worst thing in the world, and for an early-stage career candidate it’s not a fail-worthy issue, but it’s definitely a code smell.)

Conclusion

Understanding coupling and modularization will help you understand why high-quality software systems are the way they are. Building an effectively decoupled system is one of the biggest challenges engineers face and there’s no easy set of rules or practices to “solve” it. That’s why this post doesn’t have a lot of concrete answers. What’s important is how you think about the problem.

To sum it up:

  1. Coupling means it’s hard to change one part of a system without impacting another part. Decoupling is when that’s not the case.
  2. You should decouple parts of your system when there is an analogous decoupling in the problem context (“domain”).
  3. Modularization is one effective way to decouple code in a software system.
  4. The power of modularization comes from abstraction. Logic hidden within an abstraction is much easier to decouple.