SDE Path

Patterns II: Strategy, Observer, Command, State

13 min read

Behavioral patterns solve how objects interact and pass behavior. These four show up constantly in LLD problems: rate limiters, pub-sub systems, undo/redo, and vending machines.

Strategy Pattern

What it does: Encapsulate a family of algorithms, make them interchangeable, and let the client pick one at runtime.

Where it appears in interviews: Rate limiting, sorting, filtering, discount calculations, encryption.

Example: Rate Limiter with Pluggable Strategies

interface RateLimitStrategy {
  boolean allowRequest(String userId);
}

class TokenBucketStrategy implements RateLimitStrategy {
  private final Map<String, Integer> tokens = new HashMap<>();
  private final int capacity;
  private final int refillRate; // tokens per second

  public TokenBucketStrategy(int capacity, int refillRate) {
    this.capacity = capacity;
    this.refillRate = refillRate;
  }

  @Override
  public boolean allowRequest(String userId) {
    int current = tokens.getOrDefault(userId, capacity);
    if (current > 0) {
      tokens.put(userId, current - 1);
      return true;
    }
    return false;
  }
}

class SlidingWindowStrategy implements RateLimitStrategy {
  private final Map<String, Queue<Long>> windows = new HashMap<>();
  private final int maxRequests;
  private final long windowMs;

  public SlidingWindowStrategy(int maxRequests, long windowMs) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
  }

  @Override
  public boolean allowRequest(String userId) {
    long now = System.currentTimeMillis();
    Queue<Long> queue = windows.computeIfAbsent(userId, k -> new LinkedList<>());
    while (!queue.isEmpty() && queue.peek() < now - windowMs) {
      queue.poll();
    }
    if (queue.size() < maxRequests) {
      queue.add(now);
      return true;
    }
    return false;
  }
}

class RateLimiter {
  private final RateLimitStrategy strategy;

  public RateLimiter(RateLimitStrategy strategy) {
    this.strategy = strategy;
  }

  public void handleRequest(String userId) throws TooManyRequestsException {
    if (!strategy.allowRequest(userId)) {
      throw new TooManyRequestsException();
    }
    // process request
  }
}

// Usage: swap strategies without touching RateLimiter
RateLimiter limiter = new RateLimiter(new TokenBucketStrategy(10, 5));
limiter.handleRequest("user123");

// Later: switch to sliding window
RateLimiter limiter = new RateLimiter(new SlidingWindowStrategy(100, 60000));

Observer Pattern

What it does: Define a one-to-many dependency between objects. When one object changes, notify all dependents automatically.

Where it appears: Pub-sub systems, event buses, listener patterns, notifications.

Example: EventBus for Ride-Sharing

interface RideEventListener {
  void onRideCreated(Ride ride);
  void onRideAccepted(Ride ride);
  void onRideCompleted(Ride ride);
}

class RideEventBus {
  private final List<RideEventListener> listeners = new ArrayList<>();

  public void subscribe(RideEventListener listener) {
    listeners.add(listener);
  }

  public void unsubscribe(RideEventListener listener) {
    listeners.remove(listener);
  }

  public void publishRideCreated(Ride ride) {
    for (RideEventListener listener : listeners) {
      listener.onRideCreated(ride);
    }
  }

  public void publishRideAccepted(Ride ride) {
    for (RideEventListener listener : listeners) {
      listener.onRideAccepted(ride);
    }
  }

  public void publishRideCompleted(Ride ride) {
    for (RideEventListener listener : listeners) {
      listener.onRideCompleted(ride);
    }
  }
}

class NotificationService implements RideEventListener {
  @Override
  public void onRideCreated(Ride ride) {
    sendNotification("Ride created: " + ride.getId());
  }

  @Override
  public void onRideAccepted(Ride ride) {
    sendNotification("Driver accepted: " + ride.getDriver().getName());
  }

  @Override
  public void onRideCompleted(Ride ride) {
    sendNotification("Ride complete. Rating: pending");
  }

  private void sendNotification(String message) {
    // send via email/SMS/push
  }
}

class AnalyticsService implements RideEventListener {
  @Override
  public void onRideCreated(Ride ride) {
    logEvent("ride_created", ride.getId());
  }

  @Override
  public void onRideAccepted(Ride ride) {
    logEvent("ride_accepted", ride.getId());
  }

  @Override
  public void onRideCompleted(Ride ride) {
    logEvent("ride_completed", ride.getId());
  }

  private void logEvent(String event, String rideId) {
    // log to analytics service
  }
}

class RideService {
  private final RideEventBus eventBus;

  public RideService(RideEventBus eventBus) {
    this.eventBus = eventBus;
  }

  public void createRide(Ride ride) {
    // create logic
    eventBus.publishRideCreated(ride);
  }

  public void acceptRide(Ride ride, Driver driver) {
    // acceptance logic
    ride.setDriver(driver);
    eventBus.publishRideAccepted(ride);
  }

  public void completeRide(Ride ride) {
    // completion logic
    eventBus.publishRideCompleted(ride);
  }
}

// At startup:
RideEventBus eventBus = new RideEventBus();
eventBus.subscribe(new NotificationService());
eventBus.subscribe(new AnalyticsService());
RideService rideService = new RideService(eventBus);

Command Pattern

What it does: Encapsulate a request as an object so you can parameterize clients, queue requests, and support undo/redo.

Where it appears: Undo/redo stacks, macro recording, request queuing, transactions.

Example: Undo/Redo for a Document Editor

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

class InsertTextCommand implements Command {
  private final Document document;
  private final int position;
  private final String text;

  public InsertTextCommand(Document document, int position, String text) {
    this.document = document;
    this.position = position;
    this.text = text;
  }

  @Override
  public void execute() {
    document.insertAt(position, text);
  }

  @Override
  public void undo() {
    document.deleteRange(position, position + text.length());
  }
}

class DeleteTextCommand implements Command {
  private final Document document;
  private final int start, end;
  private String deletedText; // saved for undo

  public DeleteTextCommand(Document document, int start, int end) {
    this.document = document;
    this.start = start;
    this.end = end;
  }

  @Override
  public void execute() {
    deletedText = document.getRange(start, end);
    document.deleteRange(start, end);
  }

  @Override
  public void undo() {
    document.insertAt(start, deletedText);
  }
}

class Document {
  private StringBuilder text = new StringBuilder();

  public void insertAt(int position, String s) {
    text.insert(position, s);
  }

  public void deleteRange(int start, int end) {
    text.delete(start, end);
  }

  public String getRange(int start, int end) {
    return text.substring(start, end);
  }

  public String getContent() {
    return text.toString();
  }
}

class Editor {
  private final Document document = new Document();
  private final Stack<Command> undoStack = new Stack<>();
  private final Stack<Command> redoStack = new Stack<>();

  public void insertText(int position, String text) {
    Command cmd = new InsertTextCommand(document, position, text);
    cmd.execute();
    undoStack.push(cmd);
    redoStack.clear(); // clear redo on new action
  }

  public void deleteText(int start, int end) {
    Command cmd = new DeleteTextCommand(document, start, end);
    cmd.execute();
    undoStack.push(cmd);
    redoStack.clear();
  }

  public void undo() {
    if (!undoStack.isEmpty()) {
      Command cmd = undoStack.pop();
      cmd.undo();
      redoStack.push(cmd);
    }
  }

  public void redo() {
    if (!redoStack.isEmpty()) {
      Command cmd = redoStack.pop();
      cmd.execute();
      undoStack.push(cmd);
    }
  }

  public String getContent() {
    return document.getContent();
  }
}

// Usage:
Editor editor = new Editor();
editor.insertText(0, "Hello");
editor.insertText(5, " World");
editor.undo(); // "Hello"
editor.redo(); // "Hello World"

State Pattern

What it does: Allow an object to change its behavior when its state changes. Encapsulate state-specific logic in separate classes.

Where it appears: Vending machines, traffic lights, order workflows, game entities (idle, running, paused).

Example: Vending Machine

interface VendingMachineState {
  void insertMoney(int amount);
  void selectItem(String itemCode);
  void dispense();
}

class IdleState implements VendingMachineState {
  private final VendingMachine machine;

  public IdleState(VendingMachine machine) {
    this.machine = machine;
  }

  @Override
  public void insertMoney(int amount) {
    machine.addBalance(amount);
    machine.setState(new ReadyState(machine));
  }

  @Override
  public void selectItem(String itemCode) {
    System.out.println("Insert money first");
  }

  @Override
  public void dispense() {
    System.out.println("No item selected");
  }
}

class ReadyState implements VendingMachineState {
  private final VendingMachine machine;

  public ReadyState(VendingMachine machine) {
    this.machine = machine;
  }

  @Override
  public void insertMoney(int amount) {
    machine.addBalance(amount);
  }

  @Override
  public void selectItem(String itemCode) {
    Item item = machine.getItem(itemCode);
    if (machine.getBalance() >= item.getPrice()) {
      machine.setSelectedItem(item);
      machine.setState(new DispenseState(machine));
    } else {
      System.out.println("Insufficient balance");
    }
  }

  @Override
  public void dispense() {
    System.out.println("Select an item first");
  }
}

class DispenseState implements VendingMachineState {
  private final VendingMachine machine;

  public DispenseState(VendingMachine machine) {
    this.machine = machine;
  }

  @Override
  public void insertMoney(int amount) {
    System.out.println("Transaction in progress");
  }

  @Override
  public void selectItem(String itemCode) {
    System.out.println("Transaction in progress");
  }

  @Override
  public void dispense() {
    Item item = machine.getSelectedItem();
    machine.dispenseItem(item);
    int change = machine.getBalance() - item.getPrice();
    if (change > 0) {
      machine.returnChange(change);
    }
    machine.setState(new IdleState(machine));
  }
}

class VendingMachine {
  private VendingMachineState state;
  private int balance;
  private Item selectedItem;
  private Map<String, Item> inventory;

  public VendingMachine() {
    this.state = new IdleState(this);
    this.balance = 0;
  }

  public void insertMoney(int amount) {
    state.insertMoney(amount);
  }

  public void selectItem(String itemCode) {
    state.selectItem(itemCode);
  }

  public void dispense() {
    state.dispense();
  }

  public void setState(VendingMachineState state) {
    this.state = state;
  }

  public void addBalance(int amount) {
    balance += amount;
  }

  public int getBalance() {
    return balance;
  }

  public Item getItem(String code) {
    return inventory.get(code);
  }

  public void setSelectedItem(Item item) {
    this.selectedItem = item;
  }

  public Item getSelectedItem() {
    return selectedItem;
  }

  public void dispenseItem(Item item) {
    System.out.println("Dispensing: " + item.getName());
  }

  public void returnChange(int amount) {
    System.out.println("Returning change: " + amount);
  }
}

In the interview

  1. Recognize the smell, name the pattern. "If we add a third pricing strategy, the if-else will explode. I'll use Strategy pattern and define PricingStrategy interface."

  2. Know when each shines: Strategy for algorithms, Observer for events, Command for undo/redo or queuing, State for workflows.

  3. Don't over-engineer. If there's only one strategy or one state, keep it simple. Patterns shine when there are multiple implementations.

Check yourself

5 questions — graded when you submit.

Question 1

In the rate limiter example, why use the Strategy pattern instead of an if-else inside RateLimiter?

Question 2

In the Observer pattern, why does the RideEventBus store a list of listeners instead of each listener storing a reference to the bus?

Question 3

In the Command pattern's undo/redo example, why is redo stack cleared every time a new command is executed?

Question 4

In the State pattern vending machine, why is state-specific logic (like 'can't select item in IdleState') encapsulated in separate state classes instead of a big if-else in VendingMachine?

Question 5

When should you use the Observer pattern in an LLD design?

Finished reading?

Sign in to save your progress across lessons.