Software Design Patterns: A Practical Guide to Architecture That Scales
Every codebase starts simple. A few files, a handful of functions, manageable complexity. Then features pile on, teams grow, and suddenly you are staring at a system nobody wants to touch. Software design patterns exist to prevent that trajectory — or to reverse it when it has already begun.
Why Patterns Matter#
Patterns are not academic exercises. They are named solutions to recurring problems that experienced engineers have encountered thousands of times. When a team shares a vocabulary of patterns, design conversations go from "I think we should wrap that thing in another thing" to "let's use a Facade here." Clarity compounds.
Creational Patterns#
Creational patterns control how objects are instantiated, decoupling construction from usage.
Factory Method#
Delegate instantiation to subclasses so the caller never knows the concrete type.
interface Logger {
log(msg: string): void;
}
class ConsoleLogger implements Logger {
log(msg: string) { console.log(msg); }
}
class FileLogger implements Logger {
log(msg: string) { fs.appendFileSync("app.log", msg + "\n"); }
}
function createLogger(env: string): Logger {
return env === "production" ? new FileLogger() : new ConsoleLogger();
}
Builder#
Construct complex objects step-by-step without a telescoping constructor.
class QueryBuilder {
private parts: string[] = [];
select(fields: string) { this.parts.push(`SELECT ${fields}`); return this; }
from(table: string) { this.parts.push(`FROM ${table}`); return this; }
where(cond: string) { this.parts.push(`WHERE ${cond}`); return this; }
build() { return this.parts.join(" "); }
}
const sql = new QueryBuilder().select("*").from("users").where("active = true").build();
Singleton#
Ensure exactly one instance — useful for connection pools and config objects. Use sparingly; singletons introduce hidden global state.
class Config {
private static instance: Config;
private constructor(public readonly dbUrl: string) {}
static get() {
if (!Config.instance) Config.instance = new Config(process.env.DB_URL!);
return Config.instance;
}
}
Structural Patterns#
Structural patterns deal with composition — how objects and classes combine into larger structures.
Adapter#
Wrap an incompatible interface so it conforms to the one your system expects. Essential in design patterns for microservices where services evolve independently.
class LegacyPaymentGateway {
submitPayment(amt: number, curr: string) { /* ... */ }
}
class PaymentAdapter {
constructor(private legacy: LegacyPaymentGateway) {}
charge(amount: { value: number; currency: string }) {
return this.legacy.submitPayment(amount.value, amount.currency);
}
}
Facade#
Provide a simplified interface to a complex subsystem. When three services need to be called in sequence to onboard a user, expose a single onboardUser() method.
Proxy#
Control access to an object — add caching, logging, or auth checks without modifying the original.
Behavioral Patterns#
Behavioral patterns govern how objects communicate and distribute responsibility.
Observer#
Publish events; let subscribers react. This is the backbone of event-driven systems.
type Listener<T> = (data: T) => void;
class EventBus<T> {
private listeners: Listener<T>[] = [];
subscribe(fn: Listener<T>) { this.listeners.push(fn); }
publish(data: T) { this.listeners.forEach(fn => fn(data)); }
}
const bus = new EventBus<string>();
bus.subscribe(msg => console.log("Received:", msg));
bus.publish("order.created");
Strategy#
Swap algorithms at runtime without changing the consumer.
type CompressionStrategy = (data: Buffer) => Buffer;
const gzip: CompressionStrategy = (data) => zlib.gzipSync(data);
const brotli: CompressionStrategy = (data) => zlib.brotliCompressSync(data);
function compress(data: Buffer, strategy: CompressionStrategy) {
return strategy(data);
}
Command#
Encapsulate a request as an object so you can queue, undo, or log operations.
Architecture Patterns#
At a higher level, architecture patterns shape entire systems.
MVC separates concerns into Model, View, and Controller — still relevant in server-rendered apps.
Hexagonal (Ports & Adapters) places business logic at the center, with adapters for databases, APIs, and UIs at the edges. Swapping PostgreSQL for DynamoDB becomes a single adapter change.
Clean Architecture formalizes this with concentric layers: entities at the core, use cases around them, then interface adapters, then frameworks. Dependencies always point inward.
CQRS (Command Query Responsibility Segregation) splits read and write models. Writes go through command handlers with validation; reads hit optimized projections. Pairs naturally with event sourcing.
Domain-Driven Design#
Domain-driven design aligns code structure with business reality.
- Bounded Contexts — draw clear boundaries around subdomains. The "User" in billing is not the same "User" in authentication; let each context own its own model.
- Aggregates — clusters of entities that enforce invariants. An
Orderaggregate ensures line items never violate business rules. - Domain Events —
OrderPlaced,PaymentReceived. Events decouple contexts and enable eventual consistency across microservices.
class Order {
private items: LineItem[] = [];
private events: DomainEvent[] = [];
addItem(product: string, qty: number, price: number) {
this.items.push({ product, qty, price });
this.events.push({ type: "ItemAdded", product, qty });
}
pullEvents() {
const pending = [...this.events];
this.events = [];
return pending;
}
}
SOLID in Practice#
SOLID principles are the foundation every pattern builds on.
| Principle | One-liner |
|---|---|
| S — Single Responsibility | A class has one reason to change |
| O — Open/Closed | Open for extension, closed for modification |
| L — Liskov Substitution | Subtypes must be substitutable for their base types |
| I — Interface Segregation | Many small interfaces beat one fat interface |
| D — Dependency Inversion | Depend on abstractions, not concretions |
Dependency Inversion is the principle that makes clean architecture possible. When your use case depends on a UserRepository interface rather than a PostgresUserRepository class, you can test with an in-memory stub and deploy against any database.
Anti-Patterns to Avoid#
- God Object — one class that does everything. Break it apart using Single Responsibility.
- Premature Abstraction — extracting patterns before you see the repetition. Wait for the third occurrence.
- Anemic Domain Model — entities with only getters and setters while logic lives in services. Push behavior into the domain.
- Cargo Culting — applying microservices or CQRS because a blog said so. Choose patterns that solve problems you actually have.
- Lava Layer — stacking new architectures on old ones without removing the dead code. Refactor or replace; do not accumulate.
Choosing the Right Pattern#
There is no universal answer. A startup with two engineers does not need CQRS. A monolith serving 50 requests per second does not need event sourcing. Start simple, measure, and introduce patterns when complexity demands them. The best architecture is the one your team can understand, test, and evolve.
Design patterns and architecture patterns are tools, not goals. Learn them deeply, apply them judiciously, and always let the domain — not the pattern catalog — drive your decisions.
Build something that works at codelit.io.
148 articles on system design at codelit.io/blog.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Try these templates
Netflix Video Streaming Architecture
Global video streaming platform with adaptive bitrate, CDN distribution, and recommendation engine.
10 componentsSearch Engine Architecture
Web-scale search with crawling, indexing, ranking, and sub-second query serving.
8 componentsFigma Collaborative Design Platform
Browser-based design tool with real-time multiplayer editing, component libraries, and developer handoff.
10 componentsBuild this architecture
Generate an interactive Software Design Patterns in seconds.
Try it in Codelit →
Comments