Search…
Low Level Design · Part 5

Design patterns: Behavioral

In this series (15 parts)
  1. Introduction to low level design
  2. SOLID principles
  3. Design patterns: Creational
  4. Design patterns: Structural
  5. Design patterns: Behavioral
  6. Designing a parking lot
  7. Designing a library management system
  8. Designing an elevator system
  9. Designing a hotel booking system
  10. Designing a ride-sharing model
  11. Designing a rate limiter
  12. Designing a logging framework
  13. Designing a notification system
  14. API design and contract-first development
  15. Data modeling for system design

Structural patterns organized objects into larger units. Behavioral patterns govern how those units talk to each other. Where structural patterns answer “what connects to what,” behavioral patterns answer “who does what when a request arrives.”

Most behavioral patterns replace conditional logic (chains of if/else or switch statements) with polymorphic objects. The result is code where adding a new behavior means adding a new class, not modifying an existing branch.

Observer

Observer defines a one-to-many dependency between objects: when one object (the subject) changes state, all its dependents (observers) are notified automatically.

classDiagram
  class EventBus {
      -Map~String, List~EventListener~~ listeners
      +subscribe(String eventType, EventListener listener)
      +unsubscribe(String eventType, EventListener listener)
      +publish(String eventType, Event data)
  }
  class EventListener {
      <<interface>>
      +onEvent(Event data)
  }
  class EmailAlertListener {
      +onEvent(Event data)
  }
  class DashboardUpdateListener {
      +onEvent(Event data)
  }
  class AuditLogListener {
      +onEvent(Event data)
  }
  class MetricsListener {
      +onEvent(Event data)
  }
  EventBus --> EventListener
  EventListener <|.. EmailAlertListener
  EventListener <|.. DashboardUpdateListener
  EventListener <|.. AuditLogListener
  EventListener <|.. MetricsListener

Observer via an event bus. Publishers call publish(); subscribers react independently. Adding a new reaction (say, a Slack notification) means writing one class and subscribing it.

The key benefit is decoupling. The publisher does not know or care how many listeners exist or what they do. You can add, remove, or replace listeners at runtime.

When Observer fits

Use Observer when an action in one part of the system should trigger reactions in other parts, and those reactions might change over time. Classic examples: UI event handling, stock price tickers, chat room message broadcasting, and domain event systems in event-driven architectures.

Watch out for

Observers that trigger other observers can create cascading chains that are hard to debug. If observer A modifies state that triggers observer B, which modifies state that triggers observer C, tracing the flow requires walking through the entire subscription graph. Keep observer logic simple and side-effect-free when possible.

Strategy

Strategy defines a family of algorithms, encapsulates each one, and makes them interchangeable. The client picks a strategy at runtime; the algorithm varies without changing the client code.

classDiagram
  class SortStrategy {
      <<interface>>
      +sort(List~T~ items) List~T~
  }
  class QuickSort {
      +sort(List~T~ items) List~T~
  }
  class MergeSort {
      +sort(List~T~ items) List~T~
  }
  class TimSort {
      +sort(List~T~ items) List~T~
  }
  class DataProcessor {
      -SortStrategy strategy
      +setStrategy(SortStrategy)
      +processData(List~T~ data) List~T~
  }
  SortStrategy <|.. QuickSort
  SortStrategy <|.. MergeSort
  SortStrategy <|.. TimSort
  DataProcessor --> SortStrategy

Strategy for sorting algorithms. DataProcessor delegates sorting to whichever strategy is injected. Switching algorithms is a single setter call.

Strategy is one of the most frequently used patterns because it directly implements the Open/Closed Principle from SOLID. Every time you see a switch statement that selects an algorithm based on a type field, consider whether Strategy would be cleaner.

Production use: compression algorithms in file archivers (zip, gzip, brotli behind one interface), pricing strategies in e-commerce (flat rate, percentage discount, tiered pricing), authentication strategies in web frameworks (JWT, OAuth, API key).

Command

Command encapsulates a request as an object, letting you parameterize clients with different requests, queue them, log them, and support undo operations. The key insight: instead of calling a method directly, you wrap the call in an object that carries all the information needed to execute it later.

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

class AddTextCommand implements Command {
    private Document doc;
    private String text;
    private int position;

    public void execute() {
        doc.insert(position, text);
    }

    public void undo() {
        doc.delete(position, text.length());
    }
}

class CommandHistory {
    private Stack<Command> history = new Stack<>();

    public void execute(Command cmd) {
        cmd.execute();
        history.push(cmd);
    }

    public void undo() {
        if (!history.isEmpty()) {
            history.pop().undo();
        }
    }
}

Every user action becomes a command object pushed onto a stack. Undo pops the stack and calls undo(). Redo is a second stack that receives popped commands.

Production use: text editors (every keystroke is a command), database transaction logs, task queues in distributed systems (the job payload is a serialized command), macro recording in spreadsheet applications.

Iterator

Iterator provides a way to access elements of a collection sequentially without exposing its underlying representation. You use this pattern every time you write a for-each loop. The collection provides an iterator; the loop consumes it.

The value of a custom iterator shows up when the underlying structure is not a simple list. A tree might offer depth-first and breadth-first iterators. A database result set provides a forward-only iterator that fetches rows lazily. A paginated API wraps network calls behind an iterator that transparently fetches the next page when the current one is exhausted.

class PaginatedApiIterator<T> implements Iterator<T> {
    private ApiClient client;
    private String nextCursor;
    private Queue<T> buffer;

    public boolean hasNext() {
        if (!buffer.isEmpty()) return true;
        if (nextCursor == null) return false;
        fetchNextPage();
        return !buffer.isEmpty();
    }

    public T next() {
        return buffer.poll();
    }
}

The consumer calls hasNext() and next() without knowing that network requests happen under the hood.

State

State lets an object change its behavior when its internal state changes. Instead of sprawling if/else branches checking a status field, each state is a separate class that handles its own transitions.

stateDiagram-v2
  [*] --> Draft
  Draft --> Submitted : submit()
  Submitted --> UnderReview : assignReviewer()
  UnderReview --> Approved : approve()
  UnderReview --> Rejected : reject()
  Rejected --> Draft : revise()
  Approved --> Published : publish()
  Published --> [*]

State diagram for a document approval workflow. Each state defines which transitions are valid.

The class structure mirrors this diagram directly:

interface DocumentState {
    void submit(Document doc);
    void approve(Document doc);
    void reject(Document doc);
    void publish(Document doc);
}

class DraftState implements DocumentState {
    public void submit(Document doc) {
        doc.setState(new SubmittedState());
    }
    public void approve(Document doc) {
        throw new IllegalStateException("Cannot approve a draft");
    }
    // other methods throw for invalid transitions
}

Each state class knows which transitions are valid and which are not. Adding a new state (say, “Archived”) means adding one class. No existing state classes change unless their transitions to or from the new state need updating.

Production use: TCP connection states (LISTEN, SYN_SENT, ESTABLISHED, CLOSE_WAIT), order lifecycle in e-commerce, game character states (idle, walking, attacking, dead), vending machine states.

Chain of Responsibility

Chain of Responsibility passes a request along a chain of handlers. Each handler either processes the request or forwards it to the next handler. The client does not know which handler will process the request.

abstract class SupportHandler {
    private SupportHandler next;

    public void setNext(SupportHandler handler) {
        this.next = handler;
    }

    public void handle(Ticket ticket) {
        if (canHandle(ticket)) {
            process(ticket);
        } else if (next != null) {
            next.handle(ticket);
        } else {
            throw new UnhandledTicketException(ticket);
        }
    }

    abstract boolean canHandle(Ticket ticket);
    abstract void process(Ticket ticket);
}

class BotHandler extends SupportHandler { ... }
class L1SupportHandler extends SupportHandler { ... }
class L2SupportHandler extends SupportHandler { ... }
class ManagerHandler extends SupportHandler { ... }

The chain for a support ticket system: Bot tries to answer with FAQ. If it cannot, L1 human support tries. Then L2. Then a manager. Each handler encapsulates its own logic and escalation criteria.

Production use: servlet filter chains, middleware pipelines in web frameworks, logging frameworks where a log record passes through formatters, filters, and appenders. Exception handling in many languages follows this pattern implicitly: catch blocks form a chain.

Template Method

Template Method defines the skeleton of an algorithm in a base class and lets subclasses override specific steps without changing the algorithm’s structure.

abstract class DataImporter {
    // Template method: the algorithm skeleton
    public final void importData() {
        String raw = readSource();
        List<Record> records = parse(raw);
        List<Record> valid = validate(records);
        save(valid);
    }

    abstract String readSource();
    abstract List<Record> parse(String raw);

    // Default implementation, can be overridden
    List<Record> validate(List<Record> records) {
        return records.stream()
            .filter(Record::isValid)
            .collect(toList());
    }

    abstract void save(List<Record> records);
}

class CsvImporter extends DataImporter {
    String readSource() { return readFile("data.csv"); }
    List<Record> parse(String raw) { return parseCsv(raw); }
    void save(List<Record> records) { batchInsert(records); }
}

The base class controls the sequence (read, parse, validate, save). Subclasses provide the specifics. You cannot accidentally skip validation or reorder steps because the template method is final.

Production use: testing frameworks (JUnit’s setUp, runTest, tearDown is a template method), build pipelines, ETL (Extract, Transform, Load) systems, and game loops (initialize, update, render).

Choosing the right behavioral pattern

ProblemPattern
Notify multiple objects of state changesObserver
Swap algorithms at runtimeStrategy
Encapsulate actions for undo, queuing, or loggingCommand
Traverse a collection without exposing internalsIterator
Object behavior changes with its stateState
Pass requests through a chain of potential handlersChain of Responsibility
Define an algorithm skeleton with customizable stepsTemplate Method

Strategy and State look similar in structure (both delegate to an interface). The difference is intent: Strategy swaps algorithms from outside; State transitions happen internally based on the object’s own lifecycle.

Combining behavioral patterns

Real systems rarely use a single pattern in isolation. A document editor might use Command for undo/redo, Observer to update the UI when the document changes, State for the document lifecycle, and Strategy for different export formats. Each pattern handles one concern cleanly. The patterns compose because they operate at different levels: Command captures actions, Observer distributes notifications, State governs transitions, and Strategy selects algorithms.

The skill is not memorizing the patterns. It is recognizing the problem shape in your codebase and reaching for the right tool. When you see a growing switch statement, think Strategy. When you see cascading if (status == ...) checks, think State. When you see tight coupling between a producer and its consumers, think Observer.

What comes next

This article completes the three-part survey of the Gang of Four design patterns. With creational, structural, and behavioral patterns in your toolkit, you are equipped to tackle the classic LLD interview problems: parking lots, elevators, chess games, and more. Future articles in this series will apply these patterns to full system designs, showing how the pieces fit together in realistic scenarios.

Start typing to search across all content
navigate Enter open Esc close