Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 026 / 100

Behavioral patterns overview: what developers should know

How objects communicate and share responsibilities.

Expona Admin10 min read

By now you've built systems where objects talk to each other—triggering events, updating state, executing actions. The question isn't whether your code needs to communicate; it's how cleanly it does. Behavioral patterns overview: what developers should know starts with understanding that these patterns solve a single problem: how objects interact and share responsibility without tight coupling.

We've covered creational patterns (how to build objects) and structural patterns (how to organize them). Now we tackle the hardest part: watching them work together in production. When your trading system needs to notify multiple dashboards after a price update, when your e-commerce backend chains validation rules, when your CRM reducer needs to handle five different action types—you're already living in behavioral pattern territory.

The family: pattern vs purpose

Behavioral patterns aren't scattered ideas. They form a coherent family united by a single mission: control the flow of responsibility and communication between objects. Think of them as five different conversational styles for different situations.

Strategy: swap algorithms

Strategy lets you select an algorithm at runtime. Instead of hardcoding one payment method, shipping calculator, or validation rule, you inject the right one based on context.

Purpose: Eliminate conditional logic by encapsulating interchangeable behaviors.

Real-world scenario: Your ride-hailing platform charges different prices based on surge pricing, off-peak discounts, or VIP rates. Rather than nesting if-else blocks in your fare calculator, you define each pricing strategy as a separate class and swap them.

typescript
interface PricingStrategy {
  calculateFare(baseRate: number, distance: number): number;
}

class SurgeStrategy implements PricingStrategy {
  constructor(private multiplier: number) {}
  calculateFare(baseRate: number, distance: number): number {
    return baseRate * distance * this.multiplier;
  }
}

class OffPeakStrategy implements PricingStrategy {
  calculateFare(baseRate: number, distance: number): number {
    return baseRate * distance * 0.7; // 30% discount
  }
}

class FareCalculator {
  constructor(private strategy: PricingStrategy) {}
  
  calculate(baseRate: number, distance: number): number {
    return this.strategy.calculateFare(baseRate, distance);
  }
}

const calculator = new FareCalculator(
  isSurgeHour ? new SurgeStrategy(1.5) : new OffPeakStrategy()
);

The calculator doesn't know or care which strategy it uses. You swap strategies without modifying the calculator itself.

Observer: notify subscribers

Observer creates a publish-subscribe relationship: one object (subject) maintains a list of dependents (observers) and notifies them when something changes.

Purpose: Decouple event producers from event consumers.

Real-world scenario: Your trading platform needs to alert multiple dashboards, log transactions, and update risk metrics whenever a trade executes. The execution engine shouldn't know about any of these consumers; it just announces "trade executed," and anyone listening responds.

typescript
interface Observer {
  update(trade: Trade): void;
}

class Dashboard implements Observer {
  update(trade: Trade): void {
    console.log(`Dashboard: Trade ${trade.id} at $${trade.price}`);
  }
}

class AuditLog implements Observer {
  update(trade: Trade): void {
    console.log(`Log: Recording trade ${trade.id} in database`);
  }
}

class RiskEngine implements Observer {
  update(trade: Trade): void {
    console.log(`Risk: Checking exposure for ${trade.symbol}`);
  }
}

class TradeExecutor {
  private observers: Observer[] = [];
  
  subscribe(observer: Observer): void {
    this.observers.push(observer);
  }
  
  executeTrade(trade: Trade): void {
    // Execute the trade
    console.log(`Executing ${trade.symbol} at ${trade.price}`);
    // Notify all observers
    this.observers.forEach(obs => obs.update(trade));
  }
}

The executor publishes events; dashboards, logs, and risk engines consume them independently.

Command: encapsulate a request

Command wraps a request as an object, allowing you to parameterize it, queue it, log it, or undo it.

Purpose: Decouple the entity making a request from the entity executing it.

Real-world scenario: Your SaaS app needs to support undo/redo, batch operations, and scheduled tasks. Instead of scattering logic across controllers, you encapsulate each user action as a command that can be stored, executed, and reversed.

typescript
interface Command {
  execute(): void;
  undo(): void;
}

class CreateUserCommand implements Command {
  constructor(private userId: string, private email: string, private db: Database) {}
  
  execute(): void {
    this.db.users.create({ id: this.userId, email: this.email });
  }
  
  undo(): void {
    this.db.users.delete(this.userId);
  }
}

class CommandQueue {
  private history: Command[] = [];
  
  execute(command: Command): void {
    command.execute();
    this.history.push(command);
  }
  
  undo(): void {
    const command = this.history.pop();
    if (command) command.undo();
  }
}

Each action becomes a first-class object. You can log it, replay it, or reverse it without touching business logic.

Chain of responsibility: pass along a handler chain

Chain of Responsibility passes requests down a chain of handlers until one handles it. Each handler decides whether to process the request or pass it along.

Purpose: Avoid hardcoding dependencies between handlers and let multiple objects handle a request.

Real-world scenario: Your e-commerce checkout needs to validate payment method, then check fraud signals, then verify inventory, then apply discounts. Each validator should run independently, and new validators should be pluggable.

typescript
interface Handler {
  setNext(handler: Handler): Handler;
  handle(request: CheckoutRequest): void;
}

abstract class AbstractHandler implements Handler {
  protected nextHandler: Handler | null = null;
  
  setNext(handler: Handler): Handler {
    this.nextHandler = handler;
    return handler;
  }
  
  handle(request: CheckoutRequest): void {
    if (this.canHandle(request)) {
      this.process(request);
    }
    if (this.nextHandler) {
      this.nextHandler.handle(request);
    }
  }
  
  protected abstract canHandle(request: CheckoutRequest): boolean;
  protected abstract process(request: CheckoutRequest): void;
}

class PaymentValidator extends AbstractHandler {
  protected canHandle(request: CheckoutRequest): boolean {
    return !!request.paymentMethod;
  }
  protected process(request: CheckoutRequest): void {
    console.log("Validating payment method...");
  }
}

class FraudChecker extends AbstractHandler {
  protected canHandle(request: CheckoutRequest): boolean {
    return true; // Always check fraud
  }
  protected process(request: CheckoutRequest): void {
    console.log("Running fraud detection...");
  }
}

class InventoryChecker extends AbstractHandler {
  protected canHandle(request: CheckoutRequest): boolean {
    return true;
  }
  protected process(request: CheckoutRequest): void {
    console.log("Checking stock...");
  }
}

// Build the chain
const validator = new PaymentValidator();
validator.setNext(new FraudChecker()).setNext(new InventoryChecker());

validator.handle(checkoutRequest);

Handlers don't know about each other. Add, remove, or reorder them without changing any handler logic.

State: behavior changes with state

State lets an object change its behavior when its internal state changes. The object appears to change its class, even though you're swapping implementations under the hood.

Purpose: Eliminate long conditional chains based on an object's state.

Real-world scenario: An order in your CRM starts as "pending," moves to "processing," then "shipped," then "delivered." Actions available at each stage differ. You can't cancel a shipped order, can't reship a pending one. Rather than littering order logic with state checks, you define behavior per state.

typescript
interface OrderState {
  cancel(order: Order): void;
  ship(order: Order): void;
  deliver(order: Order): void;
}

class PendingState implements OrderState {
  cancel(order: Order): void {
    console.log("Cancelling pending order");
    order.setState(new CancelledState());
  }
  ship(order: Order): void {
    throw new Error("Cannot ship pending order");
  }
  deliver(order: Order): void {
    throw new Error("Cannot deliver pending order");
  }
}

class ProcessingState implements OrderState {
  cancel(order: Order): void {
    throw new Error("Cannot cancel processing order");
  }
  ship(order: Order): void {
    console.log("Shipping order");
    order.setState(new ShippedState());
  }
  deliver(order: Order): void {
    throw new Error("Cannot deliver unshipped order");
  }
}

class ShippedState implements OrderState {
  cancel(order: Order): void {
    throw new Error("Cannot cancel shipped order");
  }
  ship(order: Order): void {
    throw new Error("Order already shipped");
  }
  deliver(order: Order): void {
    console.log("Delivering order");
    order.setState(new DeliveredState());
  }
}

class Order {
  private state: OrderState = new PendingState();
  
  setState(state: OrderState): void {
    this.state = state;
  }
  
  cancel(): void { this.state.cancel(this); }
  ship(): void { this.state.ship(this); }
  deliver(): void { this.state.deliver(this); }
}

Each state encapsulates valid transitions. Invalid actions fail fast, and adding new states doesn't touch existing ones.

Most-used in my work: Strategy, Observer, Command, and State

We've shipped production code using all five patterns, but four dominate our real-world systems.

Strategy appears constantly in payment processing, shipping calculations, and recommendation algorithms. Different merchants have different rules; different regions have different tax logic. Strategy lets us swap implementations without touching the core engine.

Observer powers event propagation across microservices. When a user signs up, it's not just a database insert—audit logs fire, welcome emails trigger, analytics pipelines update. Observer keeps these concerns separate and makes adding new subscribers trivial.

Command emerged as essential for undo/redo in our SaaS workflows and for queuing background jobs in our ride-hailing system. Every user action becomes an object that can be stored, logged, replayed, or scheduled.

State saves us from spaghetti logic in our CRM order flows and trading system lifecycle. When an entity can be in multiple states with different valid actions at each, State prevents bugs and makes the valid paths obvious.

Chain of Responsibility we use less often, but when validation or request processing needs to be modular and extensible, it shines. Middleware in web frameworks is essentially Chain of Responsibility in disguise.

Framework sightings: Event emitters, middleware, and reducers

You don't need to implement these patterns from scratch. Modern frameworks embed them.

Event emitters (Node.js EventEmitter, browser DOM events) are Observer. The framework handles subscription and notification; you just register listeners.

Middleware (Express, NestJS, Next.js, Flutter) is Chain of Responsibility. Requests flow through a chain of processors, each doing its work and passing to the next.

Reducers (Redux, React useReducer, NgRx) combine Command and State. Each action is a command; the reducer is a state machine that accepts commands and returns new state.

Recognizing these patterns in your framework helps you use them correctly and extend them safely.

Common mistakes

  • Over-engineering with Strategy. Don't create a strategy for every conditional. Use it when you have multiple algorithms that vary independently or need to be swapped at runtime.
  • Observer as a global event bus. Observers should have semantic relationships. Don't subscribe to everything; subscribe to what matters to you. Otherwise, debugging becomes a nightmare.
  • Command without clear semantics. Each command should represent a meaningful user action or system operation. Don't make commands for tiny operations; the overhead isn't worth it.
  • Chain of Responsibility with unclear ownership. If no handler claims responsibility, requests silently drop. Ensure every chain ends with a default handler or throws an error.
  • State without clear transitions. Document which state transitions are valid. If your State object throws errors half the time, you've buried logic in exceptions rather than solving the problem.

Key takeaways

  • Behavioral patterns solve how objects communicate and share responsibility without tight coupling.
  • Strategy swaps algorithms; Observer notifies subscribers; Command encapsulates requests; Chain of Responsibility passes handlers; State changes behavior based on state.
  • Strategy, Observer, Command, and State appear most often in production systems because they solve real problems in payment processing, event handling, undo/redo, and lifecycle management.
  • Modern frameworks embed behavioral patterns: event emitters, middleware, and reducers. Recognize them and use them idiomatically.
  • Each pattern has a specific purpose. Use it when that problem arises, not because you've memorized the pattern.
  • The hardest part isn't understanding the pattern; it's knowing when to apply it and resisting the urge to apply it everywhere.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles