Oyam Notes

Essentials of Software Design

These principles are drawn from various sources and my own experience. They hold up across different kinds of architecture styles: from monoliths to distributed services.

I will be talking about units, which is any part of the code that you can reason about in isolation from other code. Units come in any scale: a function, a class, a module, a package or an entire service.

Abstraction

Create an interface for your unit, declare its contract and honor it.

Users of your unit should only have to know its contract — not the inner working behind it. If they have to look into the source code of your unit to use it, the abstraction is probably leaking.

Note that a "user" isn't necessarily another person. It might be future you calling your unit after you forget its implementation. Try looking at it through the eyes of a person who has never seen the source code: how do I make it easy to use?

An interface in this sense is not a language construct like interface in Go or C#. It's a set of public APIs exposed by your unit: its types, properties, methods, parameters and return values.

A contract may also be communicated not just with the language's features (for example, by applying strict types to accepted and returned values) but informally too: through the documentation (for example, by verbally requiring users to provide values of a certain kind or the unit will throw).

Cohesion and Coupling

Strive for high cohesion and low coupling.

High cohesion: A unit must have a well-defined scope. All the functionality and only the functionality directly belonging to that scope should be placed inside that unit.

Units with low cohesion are hard to reason about. They're also often a symptom of related code being scattered across the codebase which in turn drives up coupling.

utils-style modules are an exception because you need a place for all the tiny functions not belonging anywhere specifically.

Low coupling: Dependencies between separate units should be clearly documented and minimized. Tightly coupled units are fragile because a change in one often forces changes in the other.

Ideally each unit should be free to change internally without breaking anything else as long as its contract stays intact.

Separation of Concerns

Divide and conquer: Split responsibilities for a feature into units that are comprehensible on their own.

Reasoning about code is much easier when you only have to keep one unit in mind at a time. This is doable if each unit has limited concern, clear abstraction and contract for interaction with others.

You only have to remember the implementation of the unit you're currently working on — all the units you interact with can be treated as opaque interfaces whose internals are irrelevant to the task at hand.

Layers

Separate infrastructure of your service from its business logic.

The core purpose of your service should (ideally) not depend on how it's delivered to clients. That is why we, as software engineers, tend to push persistence, HTTP, and UI to the edges of the architecture, leaving the business logic at the core.

Code that handles refund policies shouldn't care whether the refund request came from a web UI, a mobile app, or a background job.

Don't Repeat Yourself (DRY)

Every business rule should have one point of truth.

Emphasis on business rule. DRY isn't about deduplicating any code that looks similar. See also Repeat Yourself (In Tests).

Don't make two units implement the same business requirement. Requirements change, you will have to update the code to follow them. If you have several places to update at once, you risk missing some and introducing subtle bugs and integrity violations.

If your units are cohesive and have a single concern, DRY will follow naturally.

See also

  • "Clean Architecture" by Robert Martin
  • "Domain Driven Design" by Eric Evans
  • "Applying UML and Patterns" by Craig Larman (read for GRASP patterns, if not for UML)