Abhinav Gupta

Notes on design

From objects to services

The SOLID principles stated plainly, then the harder question — which of them still mean anything once the method call becomes a network call.

Most introductions to system design begin with load balancers. I think that is the wrong end. Sharding and caching are answers, and they only make sense once you can say what question a component exists to answer — which is a design problem long before it is an infrastructure one.

So this starts with five principles that predate distributed systems entirely, collected by Robert C. Martin from earlier work and given the SOLID acronym by Michael Feathers. They were written about classes. The interesting part is what happens to each one when the boundary between two pieces of code stops being a function call and becomes a network hop.

The five, plainly

Single responsibility. A module should have one reason to change. The usual gloss — "do one thing" — is vaguer than the original and less useful. "One reason to change" points at people: if the finance team and the fulfilment team can each independently force you to edit the same class, that class has two responsibilities no matter how coherent it looks.

Open/closed. Open for extension, closed for modification. Adding a new payment method should mean adding a file, not editing a switch statement that six other features also depend on. In practice this is a claim about where you put your variation points, decided before you know what will vary — which is why it is the principle most often applied too early and at too much cost.

Liskov substitution. Barbara Liskov's formulation is about behaviour, not shape: if S is a subtype of T, anything true of a T must remain true when an S is substituted. A subclass that satisfies the interface but throws where the parent returned, or narrows what inputs it accepts, has broken it. Compilers check the shape. Nothing checks the behaviour but you.

Interface segregation. No client should be forced to depend on methods it does not use. A fat interface couples unrelated callers together: change it for one and you recompile, retest and redeploy all of them.

Dependency inversion. High-level policy should not depend on low-level detail; both should depend on an abstraction. Your order logic should not import a PostgreSQL driver. It should name what it needs — something that can persist an order — and let the concrete adapter be supplied from outside.

What changes at the boundary

Now make each of those boundaries a network call. Three things become true that were not true before.

The call can fail on its own, independently of both parties. It can take arbitrarily long. And the two sides can be running different versions of the code at the same time — which, inside a single process, essentially never happens.

Those three facts are what separate distributed design from object design, and they do not affect the five principles equally.

The ones that get stronger

Single responsibility survives, and its stakes go up. At class level, a muddled responsibility costs you some confusing code. At service level it costs you a deployment coupling: two teams that must now ship together, coordinate migrations, and share an on-call rotation. The question "what is the one reason this changes?" is the question that draws service boundaries, and getting it wrong is expensive in a way that refactoring a class is not.

Dependency inversion survives almost unchanged, and becomes the reason you can test any of this. If the order service depends on an interface rather than on a live payment provider, you can exercise its logic without the network. The pattern gets new names at this scale — ports and adapters, hexagonal architecture — but it is the same idea.

Interface segregation survives and sharpens into API design. A bloated endpoint is worse than a bloated interface, because you cannot see who depends on which field. Every attribute you return is one a client may come to rely on, and one you can then no longer remove. Narrow interfaces are how you keep the right to change your mind.

The one that changes meaning

Liskov substitution becomes versioning.

In a single process, substitutability is about class hierarchies. Across a network it is about time: version 2 of your service must be substitutable for version 1, because version 1's clients are still running and you cannot make them stop. That is the same contract obligation with a different axis.

And it is stricter, because you no longer control both sides. Adding a required field, narrowing an accepted range, tightening validation — each is a Liskov violation with a deployment attached. The practical rules people arrive at (add fields, never remove; widen inputs, never narrow; keep defaults for anything new) are Liskov applied to a contract you cannot recompile.

Substitutability across time is the version of Liskov that will page you at 3am.

The one to be careful with

Open/closed is the principle most likely to hurt you at this scale.

Applied to a class, guessing your extension points wrong costs a refactor. Applied to service boundaries, it produces a plugin architecture, a configuration language and a generic pipeline for a system that turned out to need exactly two behaviours. The abstraction is now load-bearing, deployed, and depended upon by other teams.

The cheaper order of operations is to write the second case concretely, and only then look at what the two cases actually share. Two examples tell you more about the shape of a variation than any amount of forecasting from one.

What the principles do not cover

SOLID is a set of principles about coupling. It has nothing to say about the three facts above, and there is a corresponding set of concerns that only appear once you cross a process boundary:

Where the load balancers come in

Only now. Caching, sharding, replication, queueing and read replicas are answers to measured problems — this query is slow, this table is hot, this consumer is behind. Each buys throughput or latency by spending consistency, operational complexity, or both.

Applied before the boundaries are right, they mostly add moving parts to a design whose real problem was that two things that change for different reasons were living in the same place. That is not an infrastructure problem, and no amount of infrastructure will fix it.

The short version

Single responsibility tells you where to cut. Dependency inversion makes the cut testable. Interface segregation keeps the cut narrow enough that you can change your mind later. Liskov, read as a rule about versions rather than subclasses, is what lets you deploy on Tuesday without breaking a client that shipped in March. Open/closed is worth having and worth delaying.

Everything after that is measurement.