← Back to Articles

Modern Design Patterns: Beyond the Bookmarks

January 24, 2026
Software Engineering Architecture Design Patterns Maintainability Refactoring TypeScript Clean Code

Modern Design Patterns: Beyond the Bookmarks

A copy of Head First Design Patterns still sits on my bookshelf. The cover is falling off and there are coffee stains on the Observer pattern chapter. It was the first book I actually finished when I moved into software development, and for a long time, it was my entire personality.

I learned the hard way that patterns aren’t just academic exercises. At Ford, I watched a 30-day update process cost us $2M in opportunity because the system was too “sticky” to change.

Updating a single price meant opening dozens of Excel files, copy-pasting data from emails, and praying that the person yelling at me on the phone didn’t find another typo.

I spent my nights teaching myself Java and Design Patterns specifically because I never wanted to deal with that level of manual trauma again.

Modern systems look different now. We ship event-driven pipelines and distributed jobs instead of just desktop apps. But the pressures are the same. Requirements shift, teams grow, and complexity piles up quietly until you’re afraid to touch the code.

Patterns help because they give names to the “shapes” of problems. When you can name a shape, you can talk about it with your team and refactor it with intent. This article covers the classic patterns that I actually use every week to keep code maintainable, without turning projects into pattern museums.

Why maintenance kills projects

In reality, maintainability isn’t about clean diagrams. It’s about whether you can add a feature without breaking something unrelated in a completely different file. I’ve been in codebases where a “small fix” felt like a game of Jenga—pull one block and the whole thing wobbles.

You know you have a structural problem when:

  • A change to a pricing rule touches 15 different files.
  • Tests are a nightmare because the setup requires mocking the entire world.
  • Onboarding a new dev takes a month because nothing has a clear name.
  • A single module is doing four different things and has 10 reasons to change.

Patterns help by creating “seams.” A seam is a place where you can break the code apart and swap pieces out. This makes testing cheaper and refactoring safer.

Choosing a pattern without the “Architecture Astronaut” vibes

Don’t start with a pattern. Start with the friction. If the code is easy to read and easy to change, leave it alone. I’ve seen too many junior devs (and a few seniors) wrap simple logic in five layers of abstraction just because they learned a new pattern that morning. It’s exhausting.

I usually look at a few specific signals before I reach for a pattern:

  • What is actually changing? If the logic is static, you don’t need a Strategy.
  • Who knows too much? If your User module knows how the Inventory database is structured, you need a boundary.
  • How heavy is the test setup? If you’re writing 50 lines of boilerplate for a 5-line test, you need a fake or a factory.

Aim for local wins. Let patterns grow through refactoring when the pressure becomes obvious.

Strategy: keep behavior flexible without branching everywhere

Strategy helps when you have multiple ways to do something. It removes if ladders that grow with each new rule. It also lets you add behavior without rewriting the core flow.

I use Strategy for things like:

  • Pricing rules that change per region or subscription plan.
  • Swapping authentication methods for different tenants.
  • Toggling ranking algorithms in search results.
  • Handling different serialization formats for integration partners.

Example: Shipping cost rules

In this case, the CheckoutService doesn’t need to know how shipping is calculated, just that it can be.

// Define a Strategy interface.
export interface ShippingCostStrategy {
  costFor(orderTotalCents: number, weightGrams: number): number;
}

// Concrete strategies.
export class FlatRateShipping implements ShippingCostStrategy {
  constructor(private flatCents: number) {}
  costFor(): number { return this.flatCents; }
}

export class WeightBasedShipping implements ShippingCostStrategy {
  costFor(orderTotalCents: number, weightGrams: number): number {
    const cents = Math.ceil(weightGrams / 500) * 199;
    return orderTotalCents > 10_000 ? Math.floor(cents * 0.8) : cents;
  }
}

// The context stays stable.
export class CheckoutService {
  constructor(private shipping: ShippingCostStrategy) {}

  totalCents(subtotalCents: number, weightGrams: number): number {
    return subtotalCents + this.shipping.costFor(subtotalCents, weightGrams);
  }
}

A clean seam appears: ShippingCostStrategy. Tests can cover each rule in isolation. Adding a new rule becomes additive.

Tips that keep Strategy maintainable

  • Put input data into a small value object if it grows.
  • Keep strategies stateless when you can.
  • Name strategies after intent, not implementation details.

Observer: Letting events carry the weight

Observer is what you reach for when one action needs to trigger a bunch of side effects without bloating the original function. It keeps your core use case small and lets you attach things like analytics, notifications, or audit logs later without touching the checkout logic.

You’ll see this everywhere in:

  • Domain events within a monolith.
  • Cross-service events in a microservice architecture.
  • UI state management.
  • Compliance and audit trails.

Example: Domain events for “OrderPlaced”

export type DomainEvent =
  | { type: "OrderPlaced"; orderId: string; userId: string; totalCents: number };

export interface EventBus {
  publish(event: DomainEvent): Promise<void>;
  subscribe(type: DomainEvent["type"], handler: (e: DomainEvent) => Promise<void>): void;
}

export class InMemoryEventBus implements EventBus {
  private handlers = new Map<string, Array<(e: any) => Promise<void>>>();

  async publish(event: DomainEvent): Promise<void> {
    const hs = this.handlers.get(event.type) ?? [];
    for (const h of hs) await h(event);
  }

  subscribe(type: DomainEvent["type"], handler: (e: any) => Promise<void>): void {
    const hs = this.handlers.get(type) ?? [];
    hs.push(handler);
    this.handlers.set(type, hs);
  }
}

Once this is in place, your checkout flow publishes one event and moves on. Other modules can subscribe and do their own thing independently.

If you’re running this in production, a few habits will save you:

  • ID everything: Add an event ID and correlation ID immediately. You’ll thank me when you’re digging through logs.
  • Expect failure: Handlers will fail. Decide on your retry policy (and whether you need an outbox) before you ship.
  • Payload stability: Keep your event shapes stable. If you have to change them, version the event type.

Factory: Centralizing the messy stuff

Factories are great for when your object creation logic starts to sprawl. This usually happens when you have different implementations for dev vs. prod, or when an object needs six different collaborators just to wake up.

A factory makes the “how” of creation explicit and keeps it out of your business logic.

Example: API client factory

interface PaymentsClient {
  charge(userId: string, cents: number): Promise<string>;
}

class RealPaymentsClient implements PaymentsClient {
  constructor(private baseUrl: string, private apiKey: string) {}
  async charge(userId: string, cents: number): Promise<string> {
    // call network here
    return `ch_${userId}_${cents}`;
  }
}

class FakePaymentsClient implements PaymentsClient {
  async charge(userId: string, cents: number): Promise<string> {
    return `fake_${userId}_${cents}`;
  }
}

export function createPaymentsClient(env: { mode: "prod" | "test"; baseUrl: string; apiKey: string }): PaymentsClient {
  return env.mode === "test"
    ? new FakePaymentsClient()
    : new RealPaymentsClient(env.baseUrl, env.apiKey);
}

Tests stop leaking production setup. The rest of the code depends on PaymentsClient, not on environment details.

Adapter: Protecting your domain

Adapters are your defense against “vendor infection.” They wrap external APIs and present a stable interface that your code understands. This is vital because third-party APIs change all the time, and you don’t want a SendGrid update to break your entire business layer.

I use these for:

  • Payment and identity providers.
  • Message queues and cloud storage SDKs.
  • Basically any library where I don’t control the interface.
// Your internal shape.
export interface EmailSender {
  send(to: string, subject: string, body: string): Promise<void>;
}

// Adapter around a vendor SDK.
export class VendorEmailAdapter implements EmailSender {
  constructor(private vendor: { deliver: (req: any) => Promise<any> }) {}

  async send(to: string, subject: string, body: string): Promise<void> {
    const req = { recipient: to, title: subject, content: body };
    const res = await this.vendor.deliver(req);
    if (res.status !== "ok") throw new Error(`Email failed: ${res.code}`);
  }
}

This keeps vendor vocabulary out of business logic. It also gives you a clean mock boundary.

Decorator: add cross-cutting behavior without bloating core logic

Decorator wraps an object to add behavior while keeping the same interface. It’s a practical way to add concerns like:

  • Logging
  • Metrics
  • Caching
  • Rate limiting
  • Tracing headers

This pattern fits modern observability work well. It keeps instrumentation consistent and testable.

Example: logging decorator for a repository

export interface UserRepository {
  getById(id: string): Promise<{ id: string; email: string } | null>;
}

export class LoggingUserRepository implements UserRepository {
  constructor(private inner: UserRepository, private log: (msg: string) => void) {}

  async getById(id: string) {
    const start = Date.now();
    try {
      const user = await this.inner.getById(id);
      this.log(`UserRepository.getById ok id=${id} ms=${Date.now() - start}`);
      return user;
    } catch (e) {
      this.log(`UserRepository.getById err id=${id} ms=${Date.now() - start}`);
      throw e;
    }
  }
}

Now logging is composable. You can wrap the same repository with caching later, in a predictable order.

Command: make actions first-class and easier to queue, retry, and audit

Command turns an operation into a value. That value can be logged, stored, retried, and scheduled. It fits workflows where actions may fail and need control.

Modern uses:

  • Background jobs.
  • Undo operations in editors.
  • Batch processing with retries.
  • Admin actions needing audit trails.

Example: command objects for jobs

export interface Command {
  name: string;
  run(): Promise<void>;
}

export class SendWelcomeEmail implements Command {
  name = "SendWelcomeEmail";
  constructor(private userId: string, private email: EmailSender) {}
  async run(): Promise<void> {
    await this.email.send("user@example.com", "Welcome", `Hello ${this.userId}`);
  }
}

export class JobRunner {
  constructor(private log: (m: string) => void) {}

  async run(cmd: Command): Promise<void> {
    this.log(`job_start name=${cmd.name}`);
    await cmd.run();
    this.log(`job_end name=${cmd.name}`);
  }
}

Commands give you a stable unit for queuing. They also improve audit logging because the “what happened” is explicit.

Template Method: standardize workflows while allowing variation

Template Method defines a workflow skeleton. Subclasses or collaborators fill in steps. It works well when the overall flow stays stable, but details vary.

Good fits include:

  • Import pipelines with different sources.
  • Validation flows with product-specific rules.
  • Provisioning steps across environments.

Keep the template small. Push details into collaborators so inheritance stays shallow.

Repository: keep domain logic independent from storage choices

Repository is a persistence boundary. It gives the domain a simple interface. It also hides query languages, indexes, and storage SDKs.

Modern teams use it to:

  • Move from direct SQL calls to a clearer boundary.
  • Keep tests fast with in-memory repos.
  • Reduce leakage of ORM types into business code.

Practical repository rules

  • Return domain types, not database records.
  • Keep methods task-focused, not “generic CRUD.”
  • Avoid passing query builders across the boundary.

Dependency Injection: make dependencies obvious and swappable

DI shows up as a framework feature, yet it’s also a design pattern. It reduces hidden coupling by moving creation out of business logic.

It helps when you want:

  • Fast unit tests with fakes.
  • Clear module boundaries.
  • Controlled wiring for environments.

You can do DI without a container. Passing dependencies through constructors is often enough. A small composition root can assemble the graph.

Patterns that play well together

Most patterns don’t live in isolation. In the systems I build, these combinations show up the most:

  • Strategy + Factory: For picking an implementation at runtime based on config.
  • Observer + Command: For handling event-driven background jobs.
  • Adapter + Repository: For isolating your data storage from the business logic.
  • Decorator + Adapter: For adding logging or metrics to a third-party integration without touching their code.

The Trap: Pattern Overuse

Patterns can easily become a reflex. That’s usually when maintainability actually starts to drop. You see extra interfaces for no reason and indirection that hides simple intent.

I once built a “trash” app that used every pattern in the book. It was a simple task manager, but I had Abstract Factories for the tasks and Decorators for the logging and a Strategy for the sorting. It was a nightmare to maintain. I spent more time wiring dependencies than writing features.

A few guardrails:

  • Start with the simplest code possible.
  • Only refactor toward a pattern when the friction repeats.
  • If you can’t explain the pattern to a junior dev in two minutes, it’s too complex.
  • Prefer composition over inheritance. Always.

A healthy pattern makes code easier to read, not harder. You should feel the friction decrease within a few commits.

A practical workflow for introducing patterns safely

Design patterns land best as iterative refactors. You don’t need a rewrite.

A reliable approach:

  1. Write a failing test that captures the new requirement.
  2. Extract a seam around the unstable behavior.
  3. Introduce the pattern at the seam, not everywhere.
  4. Rename types and methods until intent reads clearly.
  5. Delete dead code and reduce branching.
  6. Add one more example to prove extensibility.

This process keeps change localized. It also gives reviewers a story to follow.

Communication: patterns help teams scale their thinking

Patterns also work as vocabulary. They compress design discussion.

Useful moments to name a pattern:

  • During PR reviews, to justify structure.
  • In ADRs, to document the chosen tradeoffs.
  • In onboarding docs, to explain module boundaries.
  • In incident reviews, to identify missing seams.

Keep the language practical. A short explanation plus a pointer to code usually beats a long lecture.

Closing: Build Your Own Shelf

That old Head First book earned its spot on my shelf. It didn’t give me perfect designs, but it gave me a way to see structure in messy problems. It helped me move from the $2M spreadsheet nightmare at Ford to building systems that handle 600k events a day without breaking a sweat.

Pick one module in your code that feels brittle. Identify the pressure. Apply a single pattern with a clear seam and a test. Ship it.

If you’re looking for a simple next step, open a PR that replaces one if/else hotspot with a Strategy. Your future self will thank you.


SEO Title (<= 60 chars)
Modern Design Patterns: Practical Guide to Clean Architecture

Meta Description (150–160 chars)
Classic design patterns still solve modern software problems. Learn how to pick the right patterns like Strategy, Observer, and Adapter to build maintainable systems.

5–8 Target Keywords (comma-separated)
design patterns, software architecture, strategy pattern, clean code, refactoring, maintainability, adapter pattern, dependency injection

Wrestling with a technical challenge?

I help companies automate complex workflows, integrate AI into their stacks, and build scalable cloud architectures.