Design Principles
dry
don’t repeat yourself.
every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
when you find yourself copying code, extract it into a shared function, class, or module. duplication leads to:
- inconsistent bug fixes (fix in one place, forget another)
- increased maintenance burden
- divergent behaviour over time
kiss
keep it simple, stupid.
the simplest solution that works is usually the best. complexity should be added only when necessary, not speculatively.
avoid:
- premature optimisation
- over-engineering for hypothetical future requirements
- clever code that’s hard to read
encapsulate what varies
identify the aspects of your application that vary and separate them from what stays the same.
this is the foundation of many design patterns:
- strategy pattern: encapsulates interchangeable algorithms
- factory method: encapsulates object creation
- decorator: encapsulates additional behaviour
program to an interface, not an implementation
depend on abstractions rather than concrete classes.
// bad: coupled to ArrayList
ArrayList<String> items = new ArrayList<>();
// good: coupled to List interface
List<String> items = new ArrayList<>();
benefits:
- swap implementations without changing client code
- easier to mock in tests
- reduces ripple effects of changes
favour composition over inheritance
inheritance (“is-a”) creates tight coupling between parent and child classes. composition (“has-a”) is more flexible.
| Inheritance | Composition |
|---|---|
| static, compile-time | dynamic, runtime |
| tight coupling | loose coupling |
| fragile base class problem | no inheritance hierarchy to break |
| single inheritance (most langs) | compose multiple behaviours freely |
use inheritance when:
- there is a genuine “is-a” relationship
- you want to leverage polymorphism
- the Liskov substitution principle holds
use composition when:
- you need to reuse behaviour across unrelated classes
- you want runtime flexibility
- the relationship is “has-a” or “uses-a”
SOLID
five principles for object-oriented design, introduced by Robert C. Martin.
single responsibility principle
a class should have only one reason to change.
each class should do one thing and do it well. when a class has multiple responsibilities, changes to one responsibility risk breaking the others.
signs of violation
- class name includes “And” or “Manager” or “Handler” (vague)
- you modify the same class for unrelated features
- class has many unrelated methods
example
// violates SRP: handles both persistence and formatting
class Employee {
void saveToDatabase() { ... }
String formatAsJson() { ... }
void calculatePay() { ... }
}
// better: separate concerns
class Employee { void calculatePay() { ... } }
class EmployeeRepository { void save(Employee e) { ... } }
class EmployeeSerializer { String toJson(Employee e) { ... } }
open/closed principle
software entities should be open for extension but closed for modification.
you should be able to add new behaviour without changing existing code. this is typically achieved through:
- inheritance and polymorphism
- composition and delegation
- strategy pattern
example
// closed for modification: don't touch this
interface Shape { double area(); }
// open for extension: add new shapes freely
class Circle implements Shape {
double radius;
double area() { return Math.PI * radius * radius; }
}
class Rectangle implements Shape {
double width, height;
double area() { return width * height; }
}
Liskov substitution principle
subtypes must be substitutable for their base types.
if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program.
rules
- preconditions cannot be strengthened in a subtype
- postconditions cannot be weakened in a subtype
- invariants of the supertype must be preserved
- the “history constraint”: subtypes should not introduce methods that mutate state in ways the supertype doesn’t allow
classic violation
class Rectangle {
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
}
class Square extends Rectangle {
// violates LSP: can't independently set width/height
void setWidth(int w) { this.width = w; this.height = w; }
void setHeight(int h) { this.width = h; this.height = h; }
}
client code expecting Rectangle behaviour will break when given a Square.
interface segregation principle
clients should not be forced to depend on interfaces they do not use.
prefer many small, specific interfaces over one large, general-purpose interface.
example
// bad: fat interface
interface Worker {
void work();
void eat();
void sleep();
}
// good: segregated interfaces
interface Workable { void work(); }
interface Eatable { void eat(); }
interface Sleepable { void sleep(); }
class Robot implements Workable {
void work() { ... }
// doesn't need eat() or sleep()
}
dependency inversion principle
high-level modules should not depend on low-level modules. both should depend on abstractions.
abstractions should not depend on details. details should depend on abstractions.
example
// bad: high-level depends on low-level
class OrderService {
private MySQLDatabase db = new MySQLDatabase();
void save(Order o) { db.insert(o); }
}
// good: both depend on abstraction
interface Database { void insert(Object o); }
class OrderService {
private Database db;
OrderService(Database db) { this.db = db; }
void save(Order o) { db.insert(o); }
}
class MySQLDatabase implements Database { ... }
class PostgresDatabase implements Database { ... }
law of demeter
also known as the “principle of least knowledge”.
a method should only call methods of:
- itself (
this) - its own fields
- its parameters
- objects it creates locally
violation
// bad: message chain, knows too much about structure
customer.getWallet().getMoney().pay(amount);
// good: tell, don't ask
customer.pay(amount);
the first version couples the caller to the internal structure of Customer, Wallet, and Money. if any of those change, this code breaks.
coupling and cohesion
strive for low coupling and high cohesion.
- coupling
- the degree to which one module depends on another. low coupling means modules can change independently.
- cohesion
- the degree to which elements within a module belong together. high cohesion means a module does one thing well.
| Low Coupling | High Cohesion |
|---|---|
| modules are independent | related code is grouped together |
| changes don’t ripple | single, clear purpose |
| easier to test in isolation | easier to understand |
| can be reused in other contexts | less internal fragmentation |
design by contract
a methodology where software designers define formal, precise and verifiable interface specifications for software components.
- precondition
- what must be true before a method is called (caller’s responsibility)
- postcondition
- what will be true after a method returns (callee’s guarantee)
- invariant
- what must always be true for an object (class’s guarantee)
this is the other side of the coin to defensive programming, where the callee checks everything.
/**
* @pre amount > 0
* @pre balance >= amount
* @post balance == old(balance) - amount
*/
void withdraw(int amount) {
assert amount > 0 : "amount must be positive";
assert balance >= amount : "insufficient funds";
balance -= amount;
}
class invariants
a class invariant is a property or condition that must always hold true for all valid instances of a class, before and after any public method call.
examples
Stack:size >0= andsize <capacity=BankAccount:balance >0= (or minimum allowed)Rectangle:width > 0andheight > 0SortedList: elements are always in non-decreasing order
invariants are enforced by:
- constructors (establish the invariant)
- methods (preserve the invariant)
- encapsulation (prevent external code from breaking it)
yagni
you aren’t gonna need it.
don’t implement functionality until you actually need it. speculative generality is a code smell.
- build for today’s requirements, not tomorrow’s guesses
- simpler code is easier to change when requirements do evolve
- unused abstractions add cognitive load
Backlinks (5)
1. API Design /wiki/se/architecture-design/api/
an api is a promise you cannot take back: the moment a second team depends on it, every quirk you shipped is load-bearing. 𐃏 this page is the checklist for making promises deliberately — resources and verbs, status codes, pagination, versioning, errors, auth, and rate limiting, with the http machinery treated as the application-layer protocol it is (Kurose, James F. and Ross, Keith W., 2020).
2. Build Systems /wiki/se/implementation/build-systems/
every build system — make, ninja, bazel, cargo, even npm scripts pretending otherwise — is the same machine: a directed acyclic graph of files and commands, plus a policy for deciding which part of the graph is stale. everything else is syntax.
the model: a dag of targets
- a target is a file the build can produce; a rule says how (a command) and from what (its dependencies).
- dependencies point from outputs to inputs; because outputs of one rule are inputs to another, the whole thing is a dag — a cycle would mean “to build a you must first build a”.
- a build is then two steps:
- mark dirty: a target is dirty if it is missing, if any dependency is dirty, or if the staleness policy (timestamps or hashes, below) says an input changed. dirtiness propagates along edges — one flipped source poisons its whole downstream cone.
- evaluate in topological order: run each dirty target’s command after all its dependencies are up to date (Cormen, Thomas H. and Leiserson, Charles E. and Rivest, Ronald L. and Stein, Clifford, 2009). independent dirty targets can run in parallel (
-j8) — the dag is the parallelism plan, which is why builds are embarrassingly parallel until the link step serialises everything.
- the entire correctness contract: the graph must be complete. every undeclared dependency is a future “works after
make clean” bug — the build system faithfully skips rebuilding things it was never told could change.
make: the actual semantics
make’s whole rebuild rule fits in one sentence: a target is rebuilt if it does not exist, or if any prerequisite’s mtime is newer than the target’s mtime. everything else is macro expansion.
3. Microservices /wiki/se/architecture-design/microservices/
a microservice architecture decomposes one application into many independently deployable services, each owning its own data, talking over a network. the honest framing: you are trading a code organisation problem for a distributed systems problem. 𐃏 that trade is sometimes worth it. it is not worth it nearly as often as conference talks suggest.
the trade, not the hype
what you actually buy
- independent deployment: team a ships without waiting for team b’s release train. this is the real prize — everything else is secondary.
- independent scaling: scale the search service to 40 replicas while billing idles on 2.
- fault isolation: a memory leak in recommendations does not take down checkout — if you also build the resilience machinery below. isolation is earned, not free.
- technology heterogeneity: the ml service can be python while the ledger is jvm. (also a curse: n stacks to patch.)
- organisational scaling: conway’s law working for you — service boundaries that mirror team boundaries let teams own code end to end.
what you actually pay
- every in-process function call that crosses a new service boundary becomes a network call: it can now fail independently, time out, arrive twice, or arrive late.
- transactions that were a single
BEGIN...COMMITbecome sagas (below) — you give up atomicity across boundaries and must design compensation by hand. - refactoring across service boundaries is an order of magnitude harder than moving code between modules — the boundary is now an api contract with independent release cadences (Fowler, Martin, 2018).
- operational surface explodes: per-service ci, dashboards, alerts, on-call, versioned contracts, backwards-compatible migrations.
when a modular monolith wins
- a modular monolith — one deployable, strictly enforced internal module boundaries, one database with schema-per-module discipline — captures most of the design benefit at a fraction of the operational cost.
- you get cheap refactoring while the domain boundaries are still wrong (they always start wrong), real stack traces, local transactions, and one thing to deploy.
- the sane migration path is monolith-first: find the boundaries by living with them in-process, then extract the one or two services that genuinely need independent deployment or scaling — the strangler fig approach of routing traffic incrementally to extracted pieces. 𐃏
- rule of thumb: if one team can hold the whole system in its head and deploys are not blocked on other teams, microservices solve a problem you do not have.
decomposing a system
bounded contexts (ddd-lite)
the useful sliver of domain-driven design:
4. Wiki /wiki/
Knowledge is a paradox. The more one understand, the more one realises the vastness of his ignorance.