SOLID is a set of heuristics that mostly point in the same direction: make modules cohesive, decouple them through abstractions, and let changes happen in one place. Useful as a vocabulary; less useful when applied dogmatically.

The principles, with the parts that matter

SRP — one reason to change. The right unit of “one thing” is the team or stakeholder driving change. Code that gets edited by ML and by ops on the same sprint should probably be split. Code that’s “doing two things” but always changes together can stay.

OCP — extend without modifying. In practice this means: program against an interface, register new implementations, don’t reach into the original module. Frameworks (plugins, middleware, hooks) are how you operationalize this. Over-applying it leads to premature abstraction; only carve out the seam when you have two real implementations.

LSP — substitutability. A subclass that throws on a method the parent supports has broken LSP and broken its callers. The classic case: ImmutableList extends List and add() throws. The honest fix is composition, not inheritance.

ISP — narrow interfaces. A consumer should only depend on the methods it calls. If your User interface has 30 methods and the auth path uses 2, the auth path is dragging in 28 methods of coupling. Split by client.

DIP — depend on abstractions. High-level policy shouldn’t import low-level mechanism directly. The dependency is “write data” (interface), not “write Postgres”. This is what makes the high-level testable in isolation.

Coupling — the actual goal

SOLID is means, low coupling is end. Things that increase coupling without people noticing:

  • Shared mutable state (singletons, globals, ambient context).
  • Implicit ordering between modules (“call A before B”). Make it explicit or remove it.
  • Inappropriate intimacy — a caller pulling 10 fields off an object to do work the object should do. Move the method, not the data.
  • Leaky abstractions — the SDK that “wraps” Postgres but exposes connection objects. The wrap was for nothing.
  • Type-driven coupling — every module imports your “core types” package. Splits up over time; types should follow the domain seams.

Tactics for low coupling

  1. Boundary at process / network / persistence. Inside one process, low coupling is hygiene; across services, it’s the whole game (versioning, contracts, backwards compatibility).
  2. Push state to the edges. Pure functions in the middle, I/O at the rim. The “functional core, imperative shell” pattern.
  3. Introduce intermediate types. A DTO between two systems whose schemas drift independently is cheap insurance.
  4. Avoid deep inheritance. Three levels is too many. Composition stays flexible; inheritance ossifies.

Technical debt — when to pay

Debt is a forecast: “we will pay interest until we fix this.” Pay it down when:

  • The interest (slowdown, bug rate) is measurable.
  • You’re already in the file for a feature change.
  • The fix is bounded — you can land it without a six-week rewrite.

Don’t pay it for aesthetics. Don’t pay it during a fire. Track it where the work is tracked (tickets, code TODOs with issue links) — debt tracked nowhere is debt that compounds invisibly.