Theory summary
Imagine you build things with LEGO. After a while you notice that you keep solving the same small problems: how to make a wall strong, how to build a door that opens, how to join two sets that do not fit together. Smart builders gave names to the good answers, so they can say "use the hinge trick" instead of explaining every brick. A design pattern is exactly that: a name for a good answer to a problem that keeps coming back when we write programs.
Before the patterns there are five rules of thumb called SOLID. They are not laws. They are advice about how to write classes (a classa blueprint that says what data an object keeps and what it can do is like a cookie cutter, and each cookie is an object) so that the program is easy to change later. Programs always change: a new rule from the boss, a new payment company, a new kind of customer. Code that is easy to change saves days of work and avoids new bugs.
You will meet all of this in real life: in the apps you use, in the frameworks that programmers download, and in every job interview about software. At the end of this page you will know the five SOLID principles, ten well-known patterns (Factory, Builder, Singleton, Adapter, Decorator, Facade, Strategy, Observer, Command and State), the Repository and dependency injection used in back-end systems, and the warning signs of bad design. You will also know when not to use a pattern, which is just as important.
The big picture and the vocabulary
Software is never finished. Somebody always asks for one more thing. So good design is not about making the code pretty. It is about making the next change cheap: the change should touch few places, and it should not break things that already work.
To talk about design we need a few words. Learn them now, because every section uses them.
| Word | What it means | Everyday picture |
|---|---|---|
| Cohesion | How well the parts inside one class belong together. High cohesion is good. | A pencil case that holds only pencils and pens |
| Coupling | How much one class depends on other classes. Low coupling is good. | Toys joined by one thin string, not welded together |
| Interface | The list of things a class promises to do, without saying how. | The buttons of a remote control |
| Contract | The full promise of a method: what the caller must give, what the method gives back, and which errors it may raise. | A pizza order: you give an address, you get a pizza |
| Abstraction | A simple view that hides the details. | You drive a car without knowing the engine |
| Pattern | A named, reusable answer to a problem that keeps appearing. | A recipe with a name |
There are three families of patterns. Creational patterns are about creating objects. Structural patterns are about how objects fit together. Behavioural patterns are about how objects talk to each other and share work. The famous book that listed 23 of them was written by four authors, nicknamed the "Gang of Four".
Watch the video: Design Patterns in Object Oriented Programming (playlist by Christopher Okhravi)↑ Back to the contentsSingle responsibility principle (SRP)
Think of a school bag that is also a lunch box, a pillow and a bicycle helmet. When one part breaks, you cannot fix it without touching the others. The single responsibility principle says that a class should have only one reason to change.
A "reason to change" is a person or a group who may ask for a change. Look at the class below. The finance team may change the tax rule, the design team may change the receipt text, and the database team may change how data is saved. That is three reasons to change in one place.
class Invoice {
calculateTax(): number { /* tax rules */ }
saveToDatabase(): void { /* SQL */ }
sendByEmail(): void { /* SMTP */ }
}The cure is to split it: one class for the tax calculation, one for saving, one for e-mail. Now each change touches one small class, and the tests of the tax rule do not need a database.
Cohesion and two mirror smells
A cohesiveall its parts belong to one idea class has methods and fields that all deal with a single concept. A hint to find a missing split: if some methods use only some fields and other methods use only the other fields, there are probably two classes hiding inside one. A class that keeps growing a new block for every new case (a new job role, a new customer kind) is also a sign.
| Smell | What you see | What it says |
|---|---|---|
| Divergent Change | One class is edited for many different reasons (tax rule and report layout) | Lack of cohesion: the class has several responsibilities |
| Shotgun Surgery | One change forces small edits in many classes | A responsibility was broken into too many pieces |
Open-closed principle (OCP)
A game console has slots for new controllers. You do not open the console and solder wires to add one more. The console is closed for modification (nobody edits its insides) and open for extension (new controllers just plug in). The open-closed principle asks the same of code: new behaviour should be added as new code, without editing the code that already exists and is already tested.
The usual way is an abstractionan interface that hides the details. The class talks to an interface, and each new behaviour is a new class that implements it. This is called polymorphismone call, many possible behaviours, depending on the real object. The place where the class calls something it does not know in detail is its extension point.
interface DiscountRule {
apply(total: number): number;
}
class PriceCalculator {
constructor(private readonly rules: DiscountRule[]) {}
total(base: number): number {
return this.rules.reduce((value, rule) => rule.apply(value), base);
}
}BEFORE: area() has an if per shape
area(shape) --> if circle ... if square ... (edit me!)
AFTER: each shape knows its own area
area(shape) --> shape.area()
|-- Circle.area()
|-- Square.area()
'-- (new) Triangle.area()Where the change goes
No program is closed against every change. Somewhere, the new class must be created. A factory (a function that decides which class to create) can hold one switch for that. This is acceptable: the variation is confined to one point, and the rest of the code only knows the abstraction. The principle does not remove change. It chooses where change happens.
The same if (x instanceof ...) chain copied in a helper, in a report and in a screen is the real warning sign. When a new type appears, every copy must be found and edited, and a forgotten copy fails silently.
Liskov substitution principle (LSP)
Suppose you ask for "a pet" and you get a goldfish. Then you try to take it for a walk. It fails. A goldfish is a pet, but not a pet that can walk. The Liskov substitution principle says that a subtype (a child class, or any class that implements an interface) must work wherever its base type is expected. If code works with the parent, it must work with the child, and without knowing which child it got.
class Bird {
fly(): string { return 'flying'; }
}
class Penguin extends Bird {
fly(): string { throw new Error('penguins cannot fly'); }
}
function release(birds: Bird[]): string[] {
return birds.map((bird) => bird.fly());
}In biology a penguin is a bird. In code, what decides inheritance is the contract of the methods, not the phrase "is a". The fix is to change the hierarchy, for example keeping fly only in a type for birds that fly.
The contract has two sides
A method has a preconditionwhat the caller must guarantee before the call and a postconditionwhat the method guarantees after the call. It also has a way of reporting failure, such as returning null or throwing an error. The compiler checks only the types of the signature. It does not check these promises, so tests of the contract must do it.
| Part of the contract | What a subtype may do |
|---|---|
| Precondition (what it asks for) | Keep it or weaken it (ask for less). Never make it stricter |
| Postcondition (what it delivers) | Keep it or strengthen it (deliver more). Never weaken it |
| How failure is reported | Use the same way. If the base returns null for a missing user, the subtype must not throw |
The classic example is Square extends Rectangle. A Rectangle promises that changing the width does not change the height. A Square cannot keep that promise, because changing one side changes the other. Another case: a base Account promises that after a withdrawal the balance is never negative, and an OverdraftAccount allows a negative balance. It weakened a postcondition.
The strongest symptom of a broken substitution is instanceof in the client code (if (account instanceof StudentAccount) ...). The client had to learn the subtypes, so every new subtype needs another branch. Then a Liskov violation becomes an open-closed violation too.
Interface segregation principle (ISP)
You borrow a Swiss army knife just to open a letter. You are given 30 tools you do not need, and if one of them is broken you worry about it too. The interface segregation principle says that clients should not be forced to depend on methods they do not use. An interface is a contract seen from the side of the one who uses it. If it mixes methods for different users, everybody is tied to everything.
interface Machine {
print(doc: string): void;
scan(): string;
fax(number: string): void;
}
class SimplePrinter implements Machine {
print(doc: string): void { /* prints */ }
scan(): string { throw new Error('not supported'); }
fax(number: string): void { throw new Error('not supported'); }
}A method written as "not supported" is a loud sign: the interface asks for more than the class has. The fix is to split by capability: Printer, Scanner, Fax. Now code that needs to scan asks for a Scanner, and a simple printer never gets there. The type system works for you.
What a fat interface costs
- Implementers must write methods they cannot honour, and they throw errors.
- Clients depend on methods they never call, so a change in an unused method can still force them to change.
- When a thirteenth method, used by one client, is added to an interface with twelve methods and eight implementers, all eight classes must change, even those whose clients never call it.
- Test doubles (fake objects used in tests) must have all the methods. A report that only reads needs a fake with one method, not four.
A good example is a user repository. A report only needs to read. Give it a UserReader with findAll, and keep save and delete in another interface. The concrete class implements both, so it stays the same. The report cannot write or delete by mistake, and its fake needs a single method.
ISP and SRP are two views of cohesion. The SRP asks why the type itself changes. The ISP asks what each client needs from a type. A repository can have one responsibility (storing users) and still need separate reader and writer interfaces.
↑ Back to the contentsDependency inversion principle (DIP)
A lamp has a plug. It does not have a wire fixed into one wall of one house. The lamp (the important thing) defines the shape of the plug, and any socket that fits can be used. The dependency inversion principle says: high-level modules and low-level modules should both depend on abstractions. The high-levelthe business rules: what the program is for code must not import the low-levelthe details: database, e-mail, payment company details. Instead, the rule says what it needs through an interface, and the detail implements it.
class OrderService {
private readonly database = new MySqlDatabase();
place(order: Order): void {
if (order.total() <= 0) throw new Error('empty order');
this.database.insert('orders', order);
}
}The new inside the class is a double decision: the class chooses what it needs and which implementation it gets. The fix is to declare an interface in the language of the business (OrderStore) and receive it in the constructor. Then the rule can be tested with a fake in memory.
NORMAL INVERTED
OrderService OrderService --> OrderStore
| ^
v |
MySqlDatabase MySqlOrderStore -----'
source code arrow: rule -> detail detail -> rule's interfaceNotice the difference between the flow of calls and the source code dependency. At run time OrderService still calls the database. In the code, both know only the interface. Also notice who owns the interface: it lives with the high-level module, and the database adapter implements it. That ownership is what "inversion" means. Putting the interface beside the database would not invert anything.
DIP is not the same as DI
| Topic | DIP (dependency inversion) | DI (dependency injection) |
|---|---|---|
| What it is | A design rule: depend on abstractions | A technique: someone from outside hands the dependency in |
| Question it answers | What should I depend on? | Who creates it and gives it to me? |
| Can exist alone? | Yes | Yes |
Interfaces are less risky than concrete classes because they change less often: they carry no detail and many classes depend on them. But the rule is not "put an interface on everything". The criterion is stability. The text class of the standard library (String) changes almost never, does no input or output and behaves the same every time, so you can use it directly. Things that change, talk to the outside world or give a different answer on each call (database, clock, network, random numbers) go behind an interface.
Creational patterns: Factory, Builder, Singleton
Making an object involves choices: which class, with which parts, in what starting state. If every user of the object repeats those choices, a change must be repeated everywhere. Creational patterns take the choices of creation out of the code that uses the object, so the user depends only on the abstract type.
| Pattern | The problem | The idea |
|---|---|---|
| Factory | There are several classes for the same role, and who decides which one is copied around | One function or class decides which class to create |
| Builder | One class with many data, some optional, in a long list of parameters | Named steps, then a final build that checks everything |
| Singleton | We want exactly one instance of a class | One instance plus a global way to reach it |
Factory
function createNotifier(channel: string): Notifier {
if (channel === 'email') return new EmailNotifier();
if (channel === 'sms') return new SmsNotifier();
throw new Error(`unknown channel: ${channel}`);
}The value of a factory is what it hides. The client should depend on the interface of the product, not on its concrete class. If the client casts the result to the concrete class, the benefit is lost.
Builder
// hard to read: what do null, 30, true and false mean?
new HttpRequest('POST', '/orders', null, null, 30, true, false, body);
// with a builder, every value has a name
new RequestBuilder().method('POST').path('/orders').timeout(30).body(body).build();The build method is the natural place for rules that cross fields, such as "a POST must have a body". The object is only born if the whole set makes sense. This is also why a rich constructor is better than an empty constructor followed by setters: with setters, between new and the last set the object exists in an invalid state. Another care: build must give the product a copy of mutable data, such as a list of headers. If it hands out the builder's own array, a later call on the builder changes a product that was already delivered.
Singleton
Singleton joins two decisions: there is only one instance, and it is reachable from anywhere. The first can be a fair need. The second is a global variable in disguise, and it is why people look at the pattern with suspicion today.
class InvoiceService {
issue(order: Order): Invoice {
const now = Clock.getInstance().now();
const rate = TaxTable.getInstance().rateFor(order.state);
return new Invoice(order, rate, now);
}
}A constructor signature is the most honest documentation of what a class needs. A hidden global access makes it lie. Worse, a Singleton with changeable state links all the code that uses it. In tests this shows up as order dependence: the first test increments a counter, the second test starts with the leftover value and fails, although each one passes alone. A constant exported from a module (export const config = loadConfig()) is the same thing in JavaScript: importers are tied to that instance.
Structural patterns: Adapter, Decorator, Facade
Structural patterns are about how to put classes together. Three of them look alike, because each one puts a class in front of other code. What differs is the intent.
| Pattern | Interface it shows | Intent |
|---|---|---|
| Adapter | A different one: it converts a class to the interface the client already expects | Make a mismatch fit |
| Decorator | The same interface as the object it wraps | Add behaviour (log, cache, retry) |
| Facade | A new, simpler one, for several classes | One easy entry point to a complex subsystem |
Adapter
interface PaymentGateway {
charge(cents: number): Receipt;
}
class AcmePayGateway implements PaymentGateway {
constructor(private readonly sdk: AcmePaySdk) {}
charge(cents: number): Receipt {
const result = this.sdk.createTransaction({ amount: cents / 100, currency: 'BRL' });
return { id: result.transactionId, approved: result.status === 'OK' };
}
}The system owns the stable contract (PaymentGateway). The vendor's types never leave the adapter. When the vendor is replaced, only the adapter changes and the business rules stay as they are. The cost of changing vendor becomes the cost of one class.
Decorator
A decorator implements an interface and receives, in its constructor, another object of the same interface. Because the shape is the same, decorators can be stacked in any combination, like putting layers of clothes on a doll.
// Logged writes every call, Cached remembers answers
const a: Users = new Logged(new Cached(new SqlUsers()));
const b: Users = new Cached(new Logged(new SqlUsers()));The order is part of the behaviour. In a, Logged is outside, so every call is recorded, even those answered by the cache. In b, Cached is outside, so the second find('42') is answered by the cache and never reaches Logged: it is not written to the log. Both are valid and they answer different questions.
Why not inheritance? With inheritance, every combination of features is a subclass. With n independent features that is 2ⁿ − 1 classes (counting each combination with at least one feature, ignoring order). With decorators you write n classes and combine them when the program is assembled.
- Inheritance, 3 features
- 7 classes
- Decorators, 3 features
- 3 classes
- Inheritance, 6 features
- 63 classes
- Decorators, 6 features
- 6 classes
A decorator is a subtype of what it wraps, so the Liskov principle applies. A cache decorator whose save does not clear the stored product makes find return old data after a write. It broke the contract of the interface. A contract test, run against the plain repository and against the decorated one, catches it.
Facade
A subsystem often has many classes that must be called in a certain order. The facade keeps that knowledge in one place, so clients depend on one class and not on ten. The subsystem can change inside without hurting them. The limit is clear: the facade knows the order of the calls, not the rules.
↑ Back to the contentsBehavioural patterns: Strategy, Observer, Command, State
Behavioural patterns are about how objects share work and talk to each other. In each of them a class stops deciding something itself, and hands the decision to another object that can be swapped.
| Pattern | Intent | Sign in the code |
|---|---|---|
| Strategy | Put interchangeable algorithms behind one interface | An if per kind, each branch an independent calculation |
| Observer | One object tells many others when something happens | A pair of calls: subscribe and publish |
| Command | Keep a request as an object, with its data | Queue, history, undo and redo |
| State | Change behaviour when the internal state changes | The same status field tested in every method |
Strategy
Imagine going to school: by bus, by bike or on foot. The goal is the same, and the way changes. Strategy separates what varies (how to calculate) from what stays (who needs the result). Each shipping kind gets a class that can be tested alone, and a new kind does not edit the others.
type Pricing = (base: number) => number;
const regular: Pricing = (base) => base;
const blackFriday: Pricing = (base) => base * 0.7;
function checkout(base: number, pricing: Pricing): number {
return pricing(base);
}Patterns describe roles and relations, not syntax. When the strategy has one method and no state, a function type plays the role of the interface. Use a class when it needs data or more than one operation.
Observer
A YouTube channel does not know who its subscribers are. It just publishes, and everyone who subscribed is told. In the Observer pattern the publisher does not know who listens, so a new reaction (add loyalty points when an order is paid) is one more subscribe at start-up, and the order class is not edited. It is the open-closed principle applied to events.
publish(event: string): void {
for (const listener of [...this.listeners]) {
try { listener(event); } catch (error) { this.errors.push(error); }
}
}- Iterate over a copy. If listener A cancels itself during delivery, a plain loop moves B to its place and skips it. Publishing over a snapshot means: who was subscribed when the event started receives it.
- Isolate each listener. If B throws and nobody catches, C is never called and the error reaches the publisher, exactly what the pattern meant to decouple. Catch and record the error.
- Cancel when you leave. The subject keeps a reference to each observer. A screen that closes without cancelling stays in memory and keeps being called. This leak is the "lapsed listener". A good
subscribereturns the function that cancels.
Command
A normal method call happens and vanishes. A command is the same intention kept as data: which action, on whom, with which arguments. That lets you put it in a queue, repeat it, and undo it. If each command knows how to reverse itself, a history with two stacks gives undo and redo.
run(a) run(b) run(c) history = [a, b, c]
undo() pops c -> history = [a, b]
undo() pops b -> history = [a]
Effects still applied: only a
Running a new command clears the redo stackState
An order goes from pending to paid to shipped. Each phase answers the same operation differently: you cannot ship a pending order. With conditionals, every method begins with a test on status. With the State pattern each phase is a class, and the allowed transitions are explicit. The rule that refuses ship for a pending order lives in the pending state class.
Count what grows. With 4 states and 5 operations written as methods with conditionals, a fifth state forces you to review all 5 methods. With State, it is one new class plus the transitions that lead to it. The reverse also holds: if operations grow more than states, conditionals can be the better choice.
| Topic | Strategy | State |
|---|---|---|
| What the alternatives are | Independent ways to do the same task | Phases of a life cycle |
| Who swaps them | Chosen from outside | Swapped by the operations themselves, as a consequence |
Repository and dependency injection
When you ask a librarian for a book, you do not walk into the storage room and search the shelves. You say the title, and the librarian brings it. A Repository is that librarian for your data. It gives the business rules an interface that looks like a collection (add, find, save) and hides how the data is stored: SQL, files or memory.
interface OrderRepository {
find(id: string): Order | null;
save(order: Order): void;
}
class InMemoryOrders implements OrderRepository {
private readonly items = new Map<string, Order>();
find(id: string) { return this.items.get(id) ?? null; }
save(order: Order) { this.items.set(order.id, order); }
}Because the rule only knows the interface, it can be tested with an in-memory repository: fast and the same every time. A second, slower group of tests checks the real database repository. A contract test that runs against both implementations makes sure they behave the same way.
Dependency injection
Dependency injection means a class stops creating or fetching what it needs and receives it from outside. The most common form is constructor injection: dependencies are visible in the signature, mandatory and can be read-only. Looking at the constructor tells you everything the class needs.
class RegisterUser {
constructor(
private readonly users: UserRepository,
private readonly mailer: Mailer,
) {}
execute(email: string): void {
this.users.add(new User(email));
this.mailer.send(email, 'Welcome');
}
}main() <-- composition root: the only place with 'new'
| creates connection
| creates SqlOrderRepository(connection)
| creates RegisterUser(repo, mailer)
v
the rest of the program only knows interfacesThe place where everything is created and joined is called the composition root, near the entry point of the program. Writing new SqlOrderRepository(connection) there keeps the rest of the system dependent only on abstractions.
Two traps
- Transaction boundary. To save an order and reduce the stock atomically (all or nothing), the transaction belongs to the use case, which opens a unit of work that covers both repositories. A repository takes care of one kind of object and does not know which larger operation it is part of.
- Captive dependency. A service created once at start-up that receives, in its constructor, an object that should be created per request keeps the object of the first request forever. An object may depend only on objects that live as long as it does or longer. Per-request data comes as a method argument, or through a provider asked at each use.
Anti-patterns and when not to use a pattern
A code smell is a hint that the design may have a problem, like a strange smell in the fridge. An anti-pattern is a popular bad answer. Patterns also have a price: more types, more files and one more jump to understand the flow. They pay off only if the problem they solve really exists.
| Smell | How to recognise it | Why it hurts |
|---|---|---|
| God Class | A huge class that holds much of the logic and knows almost all others | Low cohesion, hard to test, everybody edits it and conflicts |
| Shotgun Surgery | One change forces small edits in many classes | Something that changes together is not together |
| Feature Envy | A method reads many getters of another object to calculate something about that object | The calculation belongs to the other object |
| Refused Bequest | A subclass overrides inherited methods so they do nothing or throw | Breaks Liskov: it wanted the code, not the contract |
| Anaemic model | Classes with only fields, getters and setters, and all rules in services | Procedural code in disguise: any service can leave an object invalid |
| Speculative generality | Interface, factory and builder, each with one implementation, "in case" | Everybody pays for flexibility nobody used |
Tell, Don't Ask and the Law of Demeter
// asks for everything, then decides
const city = order.getCustomer().getAddress().getCity().getName();
// tells the order what is needed
const city = order.shippingCity();The Law of Demeter says a method should talk only to its immediate neighbours. A chain of getters crosses several objects, so if the address changes shape, every chain scattered through the system breaks. Asking the first object for the result keeps the knowledge of the structure in one place. The same idea, Tell, Don't Ask, cures Feature Envy: move the behaviour next to the data it uses.
About the anaemic model: an object-oriented design keeps each rule next to the data it protects (encapsulation). If the domain classes are just bags of fields, any service can put an order in an invalid state, and the same rule gets rewritten in many places.
When not to apply a pattern
- With one algorithm and no sign of a second, Strategy is not justified.
- A record screen with two fields built with an interface, an abstract factory and a Builder, each with one implementation, is speculative generality. Wait for the second real case: refactoring to a pattern when the need appears costs less than carrying the structure for years.
- Applying a pattern as a precaution usually produces the wrong abstraction, and the wrong flexibility gets in the way of the change that really arrives.
Putting it all together
The principles and the patterns are connected. The patterns are often the way to follow a principle. When you see a problem, ask which pain you feel, then pick the principle or the pattern that removes it.
| Pain you feel | Principle that explains it | Pattern or technique that helps |
|---|---|---|
| A class changes for many reasons | SRP | Extract each subject into its own class |
Every new case edits an old if chain | OCP | Strategy, Factory, polymorphism |
| A subtype throws or surprises its clients | LSP | Change the hierarchy, or use composition |
| Implementers write "not supported" methods | ISP | Split the interface by role |
| Business rules import database or e-mail | DIP | Interface owned by the rule, DI, Adapter, Repository |
| A constructor with many parameters | (creation) | Builder, rich constructor |
| A hidden global that breaks tests | DIP | Inject the single instance instead of a Singleton |
| Behaviours that stack in any order | OCP, composition | Decorator |
| Many classes to call in order | (simplicity) | Facade |
| Reactions to an event | OCP | Observer |
| Undo, queue, repeat | (request as data) | Command |
| Status tested in every method | OCP | State |
A fair note on cost: the refactored code has more types and more lines than the first version. That is the price. It pays off where changes like these really arrive, and it is waste where they never do. Change does not disappear. The designs only choose where it lands.
- Write simple code that works, with tests.
- Watch for pain: a class that changes for many reasons, an
ifchain that grows, a test that needs a database. - Name the pain with a principle or a smell.
- Apply the smallest pattern that removes it, and no more.
- Keep the tests green during the refactoring.