Steven's Knowledge

Design Principles

SOLID, Law of Demeter, Tell-Don't-Ask, and the structural rules of well-designed code

Design Principles

A handful of structural principles, repeatedly rediscovered, guide most healthy designs. They are not laws — every one has counter-examples — but they describe forces that, when ignored, produce predictable kinds of pain.

SOLID

Five principles introduced by Robert C. Martin, each addressing a distinct failure mode of object-oriented designs. They generalize beyond OOP, but the names assume classes.

Single Responsibility Principle (SRP)

A class should have one reason to change.

Two methods that look related but answer to different stakeholders should not share a class. Otherwise the class accumulates Divergent Change: every stakeholder pulls it in a different direction, and the file grows into the merge conflict everyone dreads.

The practical test is to identify the axes of change: who would request a modification, and to which part. Each axis is a candidate boundary.

Open-Closed Principle (OCP)

Software entities should be open for extension but closed for modification.

A well-designed module accommodates new behavior by adding new code, not by editing existing code. Achieved through abstractions: a stable interface, with new implementations added as needed.

In practice, OCP applies along the axes of expected change. Trying to make every dimension extensible is over-engineering; making the dimensions where change is likely extensible is good design.

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types.

A function that works on Animal should work on every subclass of Animal — without exceptions, surprises, or instanceof checks. Subclasses may strengthen postconditions and weaken preconditions; they may not do the reverse.

The classic violation: a Square subclass of Rectangle that breaks setWidth and setHeight. The fix is usually that the inheritance was wrong — Square and Rectangle are siblings, not parent and child.

Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use.

A class implementing a fat interface inherits a dependency on every method, including those it has no use for. Split the interface into the minimal contracts each client actually needs. Most languages support implementing multiple interfaces; use that to compose what each client requires.

A consequence: prefer many small interfaces (Reader, Writer, Closer) to one large one (Stream).

Dependency Inversion Principle (DIP)

Depend on abstractions, not concretions. High-level modules should not depend on low-level modules.

The shape of the dependency graph matters as much as the code in each module. When a high-level policy module imports a low-level mechanism module, the policy is bound to that mechanism. Inverting the dependency — defining an interface in the policy module and having the mechanism module implement it — frees the policy from the mechanism.

The mechanical practice: pass dependencies in (constructor or function parameters); declare them as interfaces; let the wiring code (composition root, DI container) connect concrete types at the system edge.

Law of Demeter

Talk only to your immediate friends.

A method on A should call methods on:

  • itself (this.foo())
  • its parameters (p.foo())
  • objects it creates locally (new B().foo())
  • its direct fields (this.field.foo())

It should not reach through the result of a call to another object:

// Violates LoD — reaches through the order to the customer's address
order.getCustomer().getAddress().getZipCode();

// Honors LoD — order exposes what callers need
order.getShippingZipCode();

The principle is sometimes mocked as "Demeter is a paranoid law." The real value is structural: a method that walks a chain of objects encodes assumptions about every link in the chain. A change to any link forces a change to the caller. Hiding the chain behind a method on the immediate friend localizes the change.

The exception worth noting: data structures (records, value objects) are not "objects" in the LoD sense. Reading point.x from a value object is fine; calling account.balance on a domain object that protects invariants is not.

Tell, Don't Ask

A close cousin of LoD: instead of asking an object for its state, then making decisions on its behalf, tell the object what to do and let it decide.

// Asking
if (account.getBalance() < amount) {
  throw new InsufficientFundsError();
}
account.setBalance(account.getBalance() - amount);

// Telling
account.withdraw(amount);

The "telling" form moves the rule (you may not withdraw more than your balance) into the object that owns the data. Future changes to the rule happen in one place; callers cannot bypass them.

The principle is not absolute — queries are legitimate, and not every operation belongs on the data — but the default should lean toward methods on the owning object rather than logic at the call site.

Coupling and Cohesion

Underneath the named principles are two structural measurements that virtually all design heuristics serve.

Coupling

The degree to which modules depend on each other's internals. From loose to tight:

  • Data coupling. Modules communicate only through parameters of simple types. (Best.)
  • Stamp coupling. Modules pass whole records, much of which is unused.
  • Control coupling. One module passes a flag that changes the other's flow.
  • Common coupling. Modules share global state.
  • Content coupling. One module modifies another's internals directly. (Worst.)

Lower coupling lets a module be tested, replaced, or moved without disturbing its neighbors.

Cohesion

The degree to which the elements of a module belong together. The hierarchy is described in detail in Functions and Classes; the short version is that functional cohesion (one well-defined task) is the target, and coincidental cohesion (no real relationship) is the warning.

The two measurements interact: high cohesion within modules tends to allow low coupling between them. Low cohesion forces high coupling, because the unrelated parts of one module accumulate ties to the unrelated parts of others.

YAGNI — You Aren't Gonna Need It

Do not build for needs you cannot demonstrate today.

Every speculative abstraction has costs:

  • Code that has to be read but does nothing useful yet.
  • A shape that constrains future design — the speculation often turns out to be wrong, and the abstraction has to be undone.
  • A test surface that has to be maintained without delivering value.

The principle does not forbid extensibility; it forbids extensibility for unrealized needs. Build the interface you need today; extend when the second concrete need appears (the Rule of Three applies).

KISS — Keep It Simple

Simplicity is harder than it sounds because it requires resisting the urge to demonstrate cleverness. The Pragmatic Programmer phrases the test:

Will this code be easy to change?

A "clever" solution that is hard to change loses; a boring solution that is easy to change wins. Optimize for the reader who will inherit the code, not for the author writing it today.

Composition Over Inheritance

When two pieces of code share behavior, the choice is between:

  • Inheritance. A subclass inherits the behavior from a parent.
  • Composition. A class holds an instance of another and delegates to it.

Composition is the safer default because it can be changed at runtime, can be combined freely, and does not commit either party to the other's evolution. Inheritance creates the tightest coupling available and is justified only when the relationship is genuinely "is-a."

The idea generalizes beyond OOP: pure functions composed by passing one's output to another's input is an even simpler form of composition, and one of the strongest tools for keeping code orthogonal.

ETC — Easier to Change

A meta-principle from the second edition of The Pragmatic Programmer. When two designs are otherwise comparable, prefer the one that is easier to change. ETC subsumes most of the principles above:

  • DRY makes change easier because each decision lives in one place.
  • Orthogonality makes change easier because each change stays local.
  • LoD, Tell-Don't-Ask, DIP, and OCP all serve the same end at different scales.

When in doubt about which design to prefer, ask which one will be easier to change six months from now.

Pre-Commit Checklist

  • Does each module have one reason to change?
  • Does the dependency direction point from high-level policy to low-level mechanism, mediated by interfaces?
  • Are clients depending on the smallest interface they need?
  • Have you avoided long chains of .getX().getY().getZ()?
  • Do data-owning objects expose behavior, or are callers reaching for state and making decisions for them?
  • Is the design composed (held, injected, parameterized) rather than inherited where the relationship is not genuinely "is-a"?
  • Are you building only for needs that are concrete today?

On this page