Principles aren't rules; they're heuristics that, applied with judgment, keep code changeable for years. Applied dogmatically, they generate the over-engineered abstractions they were meant to prevent. The trick is knowing when to reach for one and when to ignore it.
← Back to ArchitectureCoined by Robert C. Martin in the early 2000s. Originally aimed at OO design, but the underlying ideas — small responsibilities, stable abstractions, depend on interfaces — apply everywhere.
"A class should have one reason to change." Not "do one thing" — that's too easy to misread. The real test: who would ask for this code to change? If billing and reporting both ask for changes to the same class, it has two responsibilities and should split.
Common smell: a 2,000-line UserService that handles auth, profile edits, email, and analytics. Split by axis of change.
Don't: shatter every class into single-method classes. Cohesion matters too.
"Open to extension, closed to modification." You should be able to add new behavior without editing tested, working code. The classic mechanism: polymorphism. New payment provider? Add a new PaymentProvider implementation; don't add a new else if in a switch statement.
Caveat: applied prematurely, this produces speculative interfaces with one implementation. Wait until the second variant exists before extracting the abstraction.
If S is a subtype of T, you should be able to use an S anywhere a T is expected without breaking the program. Subclasses must honor the contract of the parent — same preconditions or weaker, same postconditions or stronger, no surprise exceptions.
Classic violation: Square extends Rectangle. Setting width on a Rectangle leaves height alone; on a Square it changes both. Code that worked on Rectangle now misbehaves on Square. The "is-a" of math doesn't match the "is-a" of behavior.
"Clients shouldn't be forced to depend on methods they don't use." Many small focused interfaces beat one fat interface. A Printer that needs only print() shouldn't have to depend on a MultiFunctionDevice interface with scan(), fax(), and staple().
In practice: if you find yourself stubbing methods with throw new NotSupportedException(), your interface is too wide.
High-level modules shouldn't depend on low-level modules; both should depend on abstractions. The order processor depends on an EmailSender interface, not the concrete SmtpClient. Wire the concrete one in at the edge (composition root, DI container).
This is what makes hexagonal architecture work: the domain depends on a port; an adapter implements it. Swap adapters without touching the domain.
Don't confuse with Dependency Injection — DI is one mechanism for achieving DIP, not the principle itself.
"Every piece of knowledge must have a single, unambiguous, authoritative representation." It's about knowledge, not code shape. Two functions that happen to have similar code today but represent different business rules will diverge — merging them now creates a fake abstraction that fights every future change.
Rule of three: wait until you see the same idea three times before extracting. Two might be coincidence.
Counter-principle: WET — "Write Everything Twice." Sometimes duplication is cheaper than the wrong abstraction. "Duplication is far cheaper than the wrong abstraction." — Sandi Metz.
The simplest design that solves today's problem usually wins. Cleverness costs the next reader minutes; clarity costs the writer minutes. Spend the cost on the side that happens once.
Don't build what isn't asked for. Speculative generality (extra config knobs, plugin hooks, generic types nobody uses) almost always costs more than a future refactor would have. Build the concrete thing; generalize when a second case actually shows up.
An object should only call methods on itself, its parameters, objects it creates, or its direct components. order.customer.address.zip reaches through three objects — change any of them and the caller breaks. Prefer order.shippingZip().
Sometimes called "no train wrecks" because of the dot-chains.
Inheritance binds you to a base class's whole shape, forever. Composition lets you assemble behavior from small pieces. Most "is-a" relationships are better modeled as "has-a" — a Robot has a WheelDrive, not is a WheeledVehicle.
Modern languages encourage this — Go has no inheritance at all; Rust uses traits; even Java/C# style guides now lean composition-first.
Don't pull data out of an object to make a decision; tell the object to make the decision. Instead of if (account.balance > amount) account.debit(amount), write account.tryDebit(amount). Behavior lives with the data.
Code should do what a reasonable reader expects. A function called getUser() shouldn't also send an email. A method that says it returns a list shouldn't return null. Surprise is a bug in the API design.
If something is wrong, crash at the boundary where it can be caught and diagnosed — not silently five layers later when the corrupted state finally explodes. Validate inputs, assert invariants, and let bugs be loud during development.
Almost every principle on this page is a restatement of one rule: maximize cohesion, minimize coupling. A module is cohesive when its parts belong together (single responsibility). It's loosely coupled when changes elsewhere don't ripple through it (interface segregation, dependency inversion, Law of Demeter).
A more precise vocabulary than "coupling." Two pieces of code are connascent if a change in one requires a change in the other. Connascence of name (rename a variable) is mild; connascence of position (argument order) is fragile; connascence of timing (B must run within 50ms of A) is brittle. Push connascence inward — keep tightly-connascent code close together; only weak forms cross module boundaries.
Different concerns — UI, business logic, persistence, transport — live in different places. Lets you change one without disturbing the others. The macro version of single-responsibility.
Identify the parts of your design likely to change and isolate them behind an interface. Stable parts depend on the interface; volatile parts hide behind it. Pricing rules, tax tables, third-party integrations are classic candidates.
The more a module is depended upon, the more stable (slow to change) it should be. A core domain module shared by ten services should be near-frozen; a leaf adapter can change every sprint. Volatile modules at the bottom of the dependency graph cause cascading breakage.
Short markdown files in the repo: title, context, decision, consequences. Capture why a choice was made — the constraints, the alternatives considered, the trade-offs accepted. Six months later, someone asks "why isn't this Postgres?" and the answer is committed history, not folklore.
Extracting a "general" abstraction from one example almost always overfits to that example. The next caller has slightly different needs; you bolt parameters onto the abstraction; eventually it becomes harder to use than three direct copies would have been.
You can write code that obeys every SOLID principle and still solves the wrong problem. Principles shape how you build something; they don't tell you what to build. Domain modeling and clear thinking come first.
A 50-line script doesn't need DIP. A throwaway prototype doesn't need ISP. A library used by thousands does. Apply principles in proportion to the change-cost they're meant to reduce.