Skip to main content

Design principles

Universal ideas that most of the patterns in this catalog are just careful applications of.

SOLID gets the acronym and the interview questions, but three older, blunter principles do most of the actual work. Every creational pattern is "encapsulate what varies" with a specific answer for what varies. Almost every structural and behavioral pattern is "program to an interface" plus a shape. Learn these and the pattern catalog stops looking like twenty-three unrelated tricks.

DRY, YAGNI, KISS

Three older rules, blunter still, that fit on an index card and prevent most of the damage the fancier principles exist to clean up.

DRY - Don't Repeat Yourself

Every piece of knowledge in a system should have one authoritative place to live. Not one copy of the code - one copy of the fact. A tax rate hardcoded as 0.07 in twelve files is not twelve pieces of logic; it is one fact that forgot to pick a home, and the day it changes to 0.075 you get to find all twelve by hand, or you get to find eleven.

The trap is applying DRY to code that merely looks alike instead of code that means the same thing. Two validation functions with identical five lines today, checking a zip code and checking a product SKU, are coincidentally identical - not duplicated. Merge them into one validateFormat() and the day the SKU rule needs a checksum digit, you are untangling a shared function that never should have been shared. Repetition is cheap. The wrong abstraction is not.

YAGNI - You Aren't Gonna Need It

Build the thing the current requirement asks for, not the thing you can imagine a future requirement asking for. A PaymentProcessor interface makes sense the day you add a second payment provider - not the day you write the first one, on the theory that surely there will be a second one eventually. Until that day, the interface is a file you maintain for a caller that does not exist.

The cost is not just the extra code. It is the extra code getting in the way of the design you would have picked if you had waited for the second real requirement to tell you its actual shape, which is almost never the shape you guessed.

KISS - Keep It Simple, Stupid

Given two designs that solve the same problem, the simpler one wins, where "simpler" means fewer moving parts a reader has to hold in their head at once - not fewer characters. A fifteen-line function with an early return reads simpler than a clever six-line one-liner that needs a comment to explain it. Complexity that the problem does not require is a cost with no offsetting benefit, paid every time someone has to change the code.

None of these three is a design pattern, and none of them shows up as an interview buzzword the way SOLID does. They are the reason most of the patterns below are worth reaching for only sometimes: a pattern is a deliberate, named complexity, and KISS is the voice asking whether you actually need it yet.

What good design buys you

Two things, and they are both about the future rather than today.

Code reuse

Cost and time are the two metrics that decide whether a product ships. Reuse is the obvious lever: stop writing the same thing over and over. The obviousness is a trap, though - making existing code work in a new context usually costs more than expected, and the reason is always the same. Tight coupling, dependencies on concrete classes rather than interfaces, hardcoded operations. The logic was reusable; its wiring was not.

Erich Gamma, one of the four authors of the original pattern catalog, describes reuse as three levels, and patterns sit deliberately in the middle:

FrameworksPatternsClassesMost reuse, most risk. Theycall you, not the other wayround, and you commit early.Reuse of design ideas ratherthan code. Cheap to adopt,cheap to abandon.Least reuse, least risk:libraries and containers.

Reuse and risk climb together. Patterns are the affordable middle.

A framework distills your design decisions for you - it identifies the key abstractions, names them, and calls your code when it is ready ("don't call us, we'll call you"). That is enormous leverage and an enormous bet. Patterns give you the design idea without the commitment to somebody else's concrete code.

Extensibility

Change is the only constant in this job. You shipped for Windows and now they want macOS. You built a GUI framework with square buttons and round buttons became fashionable. You designed a clean e-commerce architecture and a month later somebody wants to take orders over the phone.

Three reasons this always happens:

  • You understand the problem better once you have solved it. By the time version one is done you are ready to rewrite it, because now you know what it should have been.
  • The ground moves. Something outside your control changes - a browser drops Flash, a vendor deprecates an API - and your plans go with it.
  • The goalposts move. The client loved version one so much that they now see eleven more things it could do. These are not frivolous requests; your good first version caused them.

The bright side: if someone asks you to change your app, someone still cares about your app. That is why experienced developers design for change they cannot yet name.

Encapsulate what varies

Identify the aspects of your application that vary and separate them from what stays the same.

The goal is damage control. Picture the program as a ship and changes as mines floating under the surface. A single-compartment hull sinks on the first hit. Divide the hull into sealed compartments and a hit costs you one compartment, not the voyage.

At the method level

Somewhere in an e-commerce codebase, getOrderTotal sums the line items and applies tax. The summing part is arithmetic and will outlive us all. The tax part depends on country, state, sometimes city, and on legislatures. Two lifetimes, one method.

// Tax rules and order arithmetic, sharing one method.
method getOrderTotal(order) is
total = 0
foreach item in order.lineItems
total += item.price * item.quantity
 
if (order.country == "US")
total += total * 0.07 // US sales tax
else if (order.country == "EU")
total += total * 0.20 // European VAT
 
return total
 
// The rate depends on country, state, sometimes city, and
// changes whenever a legislature feels productive. Every one
// of those edits lands in a method whose name says it only
// cares about the total.
// The volatile part now has its own front door.
method getOrderTotal(order) is
total = 0
foreach item in order.lineItems
total += item.price * item.quantity
 
total += total * getTaxRate(order.country)
 
return total
 
method getTaxRate(country) is
if (country == "US")
return 0.07 // US sales tax
else if (country == "EU")
return 0.20 // European VAT
else
return 0
 
// Tax changes are isolated in one method. And when the rules
// outgrow it - state, city, product category - it lifts out
// into a class without touching the arithmetic.

At the class level

Methods accumulate. The tax rule that was one if becomes a rate table, then per-product categories, then exemptions - each with its own helper fields and methods, all of them blurring what the Order class is supposed to be about. When the volatile part has grown its own gravity, give it a class.

Order- lineItems- country- state- city...20+ fields+ getOrderTotal()+ getTaxRate(country, state, product)
BEFORE: tax is calculated inside the Order class.

Objects of the Order class delegate all tax-related work to an object that does just that.

Order- taxCalculator- lineItems- country- state- city...20+ fields+ getOrderTotal()TaxCalculator...+ getTaxRate(country, state, product)- getUSTax(state)- getEUTax(country)- getChineseTax(product)total = 0foreach item in lineItems subtotal = item.price * item.quantity total += subtotal * taxCalc.getTaxRate(country, state, item.product) return total
aggregation
AFTER: the order still knows its total. It no longer knows how tax works.

Separation of Concerns

Split a program along its concerns, and let no concern know how another one does its job.

"Encapsulate what varies" tells you to wall off the part of a class that changes from the part that doesn't. Separation of Concerns is the older, blunter ancestor of that idea: split any program, not just a single class, into pieces where each piece answers exactly one question, and none of them has to understand another's job to do its own. The Single Responsibility Principle, later in this catalog under SOLID, is Separation of Concerns applied specifically to one class at a time.

Picture an OrderImportJob that reads a CSV off disk, parses each row into an Order, checks that the totals add up, and writes the survivors to the database, all inside one run() method. It works, right up until the file format changes, or validation needs a new rule, or storage swaps from a database to a queue. Every one of those changes touches the same method, because the method never separated "where the bytes come from" from "what a valid row looks like" from "where a valid row goes."

Split each question into its own object and each one gets a name that describes exactly what it does and nothing else.

OrderImportJob+ run(path)OrderFileReader+ read(path)OrderRowParser+ parse(rows)OrderValidator+ validate(order)OrderRepository+ save(order)
dependency
Four questions, four owners. The job itself just calls them in order.

None of the four new classes got smaller in total - the line count barely moved. What changed is that a bug in date parsing now points you at one file with one job, instead of a grep through a method that also happens to open sockets and write SQL.

Coupling and Cohesion

Every module should mind its own business tightly, and mind everyone else's loosely.

Two measurements, and the whole catalog spends its life trying to improve both at once. Cohesion asks how well a module's own responsibilities belong together - a class whose methods all touch the same fields for the same reason is highly cohesive. Coupling asks how much one module has to know about another to work - two classes that share only a narrow interface are loosely coupled; two classes that reach into each other's fields are tightly coupled. The target has a name: high cohesion, low coupling.

You already built an example of both moves earlier on this page. TaxCalculator, above, is highly cohesive - every method on it is about tax, and nothing else - and Order is now loosely coupled to it, holding only a reference and calling one method. Before the split, Order was low cohesion (arithmetic and tax law sharing one class) and the tax logic was tightly coupled to whatever fields Order happened to have lying around. Same behavior, opposite score on both measurements, which is exactly why that split, and the OrderImportJob one just above, were worth making.

The two pull in the same direction more often than not: shrinking a class down to one cohesive job is usually what gives you a narrow enough interface to loosen its coupling to everyone else. They stay two separate dials, though - a class can be perfectly cohesive and still tightly coupled if it hands out its internal state to anyone who asks, which is exactly the failure Law of Demeter, further down, is named for.

Program to an interface, not an implementation

Depend on abstractions, not on concrete classes.

A Cat that eats any Food is more flexible than a Cat that eats Sausage, and it costs you nothing at the call site - a sausage is still food. That is the whole principle: name the category, depend on the category.

The mechanical version, when you want two classes to collaborate:

  1. Work out what one object actually needs from the other. Which methods does it call?
  2. Describe those methods in a new interface or abstract class.
  3. Make the dependency implement that interface.
  4. Point the first class at the interface instead of the concrete class.

Take a software company simulator. Company.createSoftware() instantiates a Designer, a Programmer and a Tester and calls each one's specific method. It works, and it is welded shut - a new employee type means editing the company.

Company...+ createSoftware()Designer+ designArchitecture()Programmer+ writeCode()Tester+ testSoftware()Designer d = new Designer()d.designArchitecture()Programmer p = new Programmer()p.writeCode()Tester t = new Tester()t.testSoftware()
Before: the company knows every employee type by name.

Generalize the three methods into one - doWork() - and the company stops caring who turns up. Push the creation of employees into an abstract getEmployees() that concrete companies implement, and it stops caring which employees exist at all.

Company...+ getEmployees()+ createSoftware()«interface»Employee+ doWork()DesignerProgrammerTesteremployees = getEmployees()foreach (Employee e in employees) e.doWork()
After: the company depends on a promise, and subclasses decide who keeps it.

You have just watched the factory method pattern assemble itself out of one principle. Most of the catalog works like this.

The honest cost: the code got more complicated and, on the day you write it, no more capable. Spend the indirection where you expect variation, not everywhere.

Law of Demeter

Talk to your immediate friends. Don't talk to strangers you only met through a friend.

A method may call operations on itself, on its own fields, on anything handed to it as a parameter, and on anything it creates on the spot. It may not call an operation on whatever some other call happened to hand back - that return value is a stranger, and reaching past it into a third object's business is the "train wreck" this principle is named for.

// OrderService reaching three objects deep to do one thing.
method chargeCustomer(customer, amount) is
customer.getWallet().getCash().subtract(amount)
 
// This line knows that a Customer has a Wallet, that a Wallet
// holds Cash, and that Cash supports subtract(). Change any one
// of those three facts and this unrelated service breaks.
// OrderService talks to one friend: the Customer it was handed.
method chargeCustomer(customer, amount) is
customer.pay(amount)
 
method Customer.pay(amount) is
wallet.pay(amount)
 
method Wallet.pay(amount) is
cash.subtract(amount)
 
// Each hop only knows the one object right next to it. Wallet
// can switch from Cash to Card tomorrow and OrderService, which
// never knew Wallet held cash in the first place, feels nothing.

The fix is never "add more getters so the caller can keep reaching." It's the opposite: push the behavior down to whichever object already holds the data, and let the caller ask for the result instead of the parts. This is sometimes called "tell, don't ask" - tell the wallet to pay, don't ask it for its cash so you can subtract from it yourself.

The train wreck is also a coupling problem wearing a different name: a chain of four dots is four separate objects that a change anywhere can break, which is exactly the low-coupling target from two sections back, failed in one specific and very grep-able way.

Favor composition over inheritance

Inheritance is the obvious way to reuse code, which is exactly why it gets overused. The caveats only show up once you have hundreds of classes and no appetite for moving them:

  • A subclass cannot shrink its parent's interface. Unused abstract methods still need bodies.
  • Overrides must stay compatible with the base behavior, because client code holding a parent reference has no idea it got a child.
  • Inheritance leaks the superclass's internals into the subclass, which is encapsulation quietly going out the back door.
  • Subclasses are welded to superclasses. A change upstairs breaks things downstairs.
  • Worst of all, inheritance extends in exactly one dimension. Model two or three independent dimensions with it and the class count multiplies instead of adding.

Composition is the alternative. Inheritance is an "is a" relationship - a car is a transport. Composition is "has a" - a car has an engine. Instead of baking a behavior into the type, hold an object that provides it, and swap that object whenever you like, including at runtime.

Aggregation counts too, the looser variant where the container holds a reference without owning the lifecycle: a car has a driver, but the driver can walk away and take a bus.

«abstract»Transportengine: Enginenav: Navigationdeliver(dest, cargo)THE«interface»Enginemove()EXTRACTED«interface»Navigationnavigate(dest)EXTRACTEDCardeliver(dest, cargo)SUBCLASSTruckdeliver(dest, cargo)SUBCLASSElectricEnginemove()ENGINECombustionEnginemove()ENGINEAutoPilotnavigate(dest)NAVIGATION
implementsextends
// One hierarchy asked to model three independent dimensions
// (cargo type x engine type x navigation type).
class Transport is
method deliver(destination, cargo)
 
class Car extends Transport
class Truck extends Transport
 
class ElectricCar extends Car
class CombustionCar extends Car
class ElectricTruck extends Truck
class CombustionTruck extends Truck
 
class AutopilotElectricCar extends ElectricCar
class ManualElectricCar extends ElectricCar
class AutopilotCombustionCar extends CombustionCar
class ManualCombustionCar extends CombustionCar
// ...and four more for trucks. Add hydrogen engines
// and you write six new classes to say one new thing.
// Each dimension becomes its own small hierarchy,
// held by reference instead of by ancestry.
interface Engine is
method move()
 
class ElectricEngine implements Engine is
method move() is
// draw from the battery pack
 
class CombustionEngine implements Engine is
method move() is
// burn fuel, make noise
 
interface Navigation is
method navigate(destination)
 
class AutoPilot implements Navigation is
method navigate(destination) is
// let the computer drive
 
class Transport is
field engine: Engine
field nav: Navigation
 
method deliver(destination, cargo) is
nav.navigate(destination)
engine.move()
 
// Swap behavior at runtime, no new subclass required.
car.engine = new ElectricEngine()
// A hydrogen engine is exactly one new class, forever.

If that shape looks familiar later, good: it is the Strategy pattern, arrived at from first principles rather than from a catalog. Most patterns are this move applied to a specific kind of pain.

None of this makes inheritance a mistake. A single, shallow, genuinely "is a" hierarchy is clearer than three interfaces and a constructor full of wiring. Reach for composition when a second dimension of variation shows up, not before.

Composing Objects Principle

Assemble complex behavior from small objects with one job each, rather than growing one object that learns to do everything itself.

"Favor composition over inheritance," above, is about one specific fork in the road: when a new variant shows up, do you extend a parent or hold a reference? The Composing Objects Principle is the more general version, and it doesn't need an inheritance tree to be in the picture at all. It is a warning against the "god object" - a single class that has grown a method for every responsibility the system needs, because adding one more method to the class you already have was always the path of least resistance.

Go back to the software company. Nothing here needs a class hierarchy - there is no Company subclass in sight - but a Company that implements hiring, payroll, tax filing, client pitches, and office leasing itself, all as its own methods touching its own sprawling field list, has the same disease inheritance gets blamed for: one object doing five jobs, none of them cohesive with the others.

Company- recruiting- payroll- accounting- pr- facilities+ hire()+ runPayroll()+ fileTaxes()+ pitchClient()+ bookOffice()RecruitingDesk+ hire()PayrollService+ run()Accounting+ fileTaxes()PRDesk+ pitchClient()Facilities+ bookOffice()
composition
Company still answers five kinds of request. It personally knows how to do zero of them.

Company keeps every one of its public methods - callers notice nothing - but each method is now one line of delegation to an object whose entire reason to exist is that one job. Add a sixth responsibility later and it is a sixth small class, wired in next to the other five, not a sixth method competing for space in an already-overloaded one.

Check yourself

Question 1 of 9

What actually makes a piece of code hard to reuse in a new project?