Design Patterns, Explained Simply · Modern Patterns and Choosing Well
Choosing the Right Pattern
A practical cheat sheet: start from the symptom in your code, find the matching pattern, and tell the look-alike patterns apart.
The problem
You know 23 patterns and several modern ones. In a real code review, you see a growing switch, a third-party class that doesn't fit, or a feature that needs undo, and you need to pick a tool quickly and explain why. Many patterns also look the same in code (a wrapper with the same interface could be a Decorator, a Proxy or an Adapter), so you need to know how to tell them apart.
Real-world analogy
A doctor doesn't start from "which medicine shall I prescribe today?". They start from the symptoms, narrow down the cause, and then choose a treatment, including "no medicine; rest". Pick patterns the same way: symptom first.
The idea
Start from the symptom:
| Symptom in your code | Consider |
|---|---|
if/else choosing between algorithms or rules |
Strategy (or a lookup table of functions) |
switch on a status field in many methods |
State |
| Constructor with many optional parameters | Builder (or named arguments) |
| Creating objects whose class depends on config/input | Factory Method / simple factory |
| Several related objects must match (theme, vendor) | Abstract Factory |
| A class with the wrong interface for your code | Adapter |
| Need logging, caching or retries around an object | Decorator (or middleware) |
| Need lazy loading, access control or a remote stand-in | Proxy |
| Callers repeat a complicated sequence of subsystem calls | Facade |
| Tree structures (folders, menus, UI) | Composite |
| Several things should react when something happens | Observer (in-process) or Pub/Sub (across services) |
| Need undo/redo, queues or retries of actions | Command (or Memento for snapshots) |
| Same steps in the same order, a few vary | Template Method (or Strategy for the varying step) |
| Request goes through ordered checks that can stop it | Chain of Responsibility / middleware |
| Business logic full of SQL | Repository (+ Unit of Work) |
null checks everywhere for an optional collaborator |
Null Object (or explicit optionals) |
Tell the look-alikes apart (they differ by intent, not by code shape):
| Pattern | Wraps an object with... | Intent |
|---|---|---|
| Adapter | a different interface | make an incompatible class fit |
| Decorator | the same interface | add behaviour, stackable |
| Proxy | the same interface | control access (lazy, permissions, remote) |
| Facade | a new, simpler interface over many classes | simplify a subsystem |
| Pattern | Who picks the variant? | Do variants know each other? |
|---|---|---|
| Strategy | the client / configuration | no |
| State | the states themselves (transitions) | yes, they switch to each other |
| Pattern | Creates... |
|---|---|
| Factory Method | one product, chosen by a subclass |
| Abstract Factory | a matching family of products |
| Builder | one complex product, step by step |
| Prototype | a copy of an existing configured object |
The code
The three "same interface" wrappers side by side, around one key-value store, so you can see that the shape is the same and only the intent differs.
class LegacyStore: # incompatible interface
def fetch_value(self, key):
return {"theme": "dark", "lang": "en"}.get(key, "?")
class StoreAdapter: # ADAPTER: different interface -> our get()
def __init__(self, legacy):
self.legacy = legacy
def get(self, key):
return self.legacy.fetch_value(key)
class LoggingStore: # DECORATOR: same get(), adds behaviour
def __init__(self, inner):
self.inner = inner
def get(self, key):
value = self.inner.get(key)
print(f" log: get({key}) = {value}")
return value
class AdminOnlyStore: # PROXY: same get(), controls access
def __init__(self, inner, role):
self.inner, self.role = inner, role
def get(self, key):
if self.role != "admin":
return "denied"
return self.inner.get(key)
store = StoreAdapter(LegacyStore())
print("adapter: ", store.get("theme"))
print("decorator:", LoggingStore(store).get("lang"))
print("proxy: ", AdminOnlyStore(store, "viewer").get("theme"))
print("stacked: ", AdminOnlyStore(LoggingStore(store), "admin").get("theme"))
Output
adapter: dark log: get(lang) = en decorator: en proxy: denied log: get(theme) = dark stacked: dark
interface Store { get(key: string): string; }
class LegacyStore { // incompatible interface
fetchValue(key: string) { return ({ theme: "dark", lang: "en" } as Record<string, string>)[key] ?? "?"; }
}
class StoreAdapter implements Store { // ADAPTER: different interface -> our get()
constructor(private legacy: LegacyStore) {}
get(key: string) { return this.legacy.fetchValue(key); }
}
class LoggingStore implements Store { // DECORATOR: same get(), adds behaviour
constructor(private inner: Store) {}
get(key: string) {
const value = this.inner.get(key);
console.log(` log: get(${key}) = ${value}`);
return value;
}
}
class AdminOnlyStore implements Store { // PROXY: same get(), controls access
constructor(private inner: Store, private role: string) {}
get(key: string) { return this.role !== "admin" ? "denied" : this.inner.get(key); }
}
const store = new StoreAdapter(new LegacyStore());
console.log("adapter: ", store.get("theme"));
console.log("decorator:", new LoggingStore(store).get("lang"));
console.log("proxy: ", new AdminOnlyStore(store, "viewer").get("theme"));
console.log("stacked: ", new AdminOnlyStore(new LoggingStore(store), "admin").get("theme"));
Output
adapter: dark log: get(lang) = en decorator: en proxy: denied log: get(theme) = dark stacked: dark
import java.util.Map;
public class Main {
public static void main(String[] args) {
Store store = new StoreAdapter(new LegacyStore());
System.out.println("adapter: " + store.get("theme"));
String lang = new LoggingStore(store).get("lang");
System.out.println("decorator: " + lang);
System.out.println("proxy: " + new AdminOnlyStore(store, "viewer").get("theme"));
String stacked = new AdminOnlyStore(new LoggingStore(store), "admin").get("theme");
System.out.println("stacked: " + stacked);
}
}
interface Store { String get(String key); }
class LegacyStore { // incompatible interface
String fetchValue(String key) { return Map.of("theme", "dark", "lang", "en").getOrDefault(key, "?"); }
}
class StoreAdapter implements Store { // ADAPTER: different interface -> our get()
private final LegacyStore legacy;
StoreAdapter(LegacyStore legacy) { this.legacy = legacy; }
public String get(String key) { return legacy.fetchValue(key); }
}
class LoggingStore implements Store { // DECORATOR: same get(), adds behaviour
private final Store inner;
LoggingStore(Store inner) { this.inner = inner; }
public String get(String key) {
String value = inner.get(key);
System.out.println(" log: get(" + key + ") = " + value);
return value;
}
}
class AdminOnlyStore implements Store { // PROXY: same get(), controls access
private final Store inner;
private final String role;
AdminOnlyStore(Store inner, String role) { this.inner = inner; this.role = role; }
public String get(String key) { return !role.equals("admin") ? "denied" : inner.get(key); }
}
Output
adapter: dark log: get(lang) = en decorator: en proxy: denied log: get(theme) = dark stacked: dark
Run this on your computer — see “Get Started” for setup.
<?php
interface Store { public function get(string $key): string; }
class LegacyStore { // incompatible interface
public function fetchValue(string $key): string { return ["theme" => "dark", "lang" => "en"][$key] ?? "?"; }
}
class StoreAdapter implements Store { // ADAPTER: different interface -> our get()
public function __construct(private LegacyStore $legacy) {}
public function get(string $key): string { return $this->legacy->fetchValue($key); }
}
class LoggingStore implements Store { // DECORATOR: same get(), adds behaviour
public function __construct(private Store $inner) {}
public function get(string $key): string {
$value = $this->inner->get($key);
echo " log: get($key) = $value\n";
return $value;
}
}
class AdminOnlyStore implements Store { // PROXY: same get(), controls access
public function __construct(private Store $inner, private string $role) {}
public function get(string $key): string { return $this->role !== "admin" ? "denied" : $this->inner->get($key); }
}
$store = new StoreAdapter(new LegacyStore());
echo "adapter: ", $store->get("theme"), "\n";
$lang = (new LoggingStore($store))->get("lang");
echo "decorator: $lang\n";
echo "proxy: ", (new AdminOnlyStore($store, "viewer"))->get("theme"), "\n";
$stacked = (new AdminOnlyStore(new LoggingStore($store), "admin"))->get("theme");
echo "stacked: $stacked\n";
Output
adapter: dark log: get(lang) = en decorator: en proxy: denied log: get(theme) = dark stacked: dark
All three wrappers have nearly identical code. What makes each one an Adapter, a Decorator or a Proxy is the reason it exists, which is also what its name should tell the next reader.
Common mistakes
- Choosing by shape instead of intent. Name and document wrappers by why they exist.
- Reaching for a pattern before simpler options: a function, a dictionary of functions, named arguments or an enum often solve the problem with less code.
- Not combining patterns. Real designs mix them: Strategy objects created by a factory, decorated with logging, triggered by commands from an event bus.
- Forgetting to explain the choice. In review, say which symptom the pattern addresses ("Strategy, because the shipping rules keep growing").
Key points
- Pick patterns from symptoms: growing conditionals, wrong interfaces, cross-cutting concerns, many optional parameters, trees, events, undo.
- Adapter, Decorator, Proxy and Facade look alike; they differ by intent: fit, add, control, simplify.
- Strategy vs State: the client picks a strategy; states pick their successors.
- Prefer the simplest thing that works, and refactor to a pattern when the variation is real.
- Further reading: Refactoring Guru: Design patterns catalog.
Practice questions
How do Adapter, Decorator, Proxy and Facade differ?
Answer
All wrap something. Adapter gives an existing object a different interface so it fits. Decorator keeps the same interface and adds behaviour (stackable). Proxy keeps the same interface and controls access (lazy, permissions, remote). Facade offers a new, simpler interface over a whole subsystem.
Which pattern would you consider for a `switch` on an order's status repeated across many methods, and why?
Answer
State: each status becomes a class implementing the allowed actions and transitions, removing the repeated switch and making illegal transitions fail by default.
Which pattern would you consider for a constructor with ten optional parameters?
Answer
Builder (named, chainable steps and a validating build()), or simply named/keyword arguments with defaults in languages that have them (Python, PHP 8, TypeScript object parameters).
How should you justify using a pattern in a code review?
Answer
Name the symptom it fixes and the trade-off: "Strategy, because shipping rules change every month and each rule can now be tested alone; the cost is one interface and a class per rule." If you can't name the symptom, the pattern probably isn't needed.
Open in the app to mark lessons complete, take notes and track your progress.