jongkwan.dev
Development · Essay №011

Behavioral Patterns - Distributing Responsibility and Communication Between Objects

A full tour of behavioral patterns: Observer, Strategy, Command, Saga, Circuit Breaker, Event Sourcing, and more

Jongkwan Lee2025년 5월 24일17 min read
Contents

Behavioral patterns split responsibility across objects and define how they communicate, which lowers coupling.

Overview

Behavioral patterns deal with how responsibility is distributed among objects and how those objects talk to each other. They define interaction while keeping coupling to a minimum, so the system stays flexible under change. In distributed systems the same ideas grew into architectural patterns covering inter-service communication and failure handling.

Observer pattern

Definition

Observer defines a one-to-many dependency in which a change to one object (the Subject) is automatically broadcast to every dependent object (the Observers).

Implementation

java
// Subject interface
public interface EventPublisher<T> {
    void subscribe(EventListener<T> listener);
    void unsubscribe(EventListener<T> listener);
    void notify(T event);
}
 
// Observer interface
@FunctionalInterface
public interface EventListener<T> {
    void onEvent(T event);
}
 
// Implementation: order event publisher
public class OrderEventPublisher implements EventPublisher<OrderEvent> {
    private final List<EventListener<OrderEvent>> listeners =
        new CopyOnWriteArrayList<>(); // thread safe
 
    public void subscribe(EventListener<OrderEvent> listener) {
        listeners.add(listener);
    }
 
    public void unsubscribe(EventListener<OrderEvent> listener) {
        listeners.remove(listener);
    }
 
    public void notify(OrderEvent event) {
        listeners.forEach(listener -> listener.onEvent(event));
    }
}
 
// Usage
OrderEventPublisher publisher = new OrderEventPublisher();
publisher.subscribe(event -> sendEmail(event));           // email notification
publisher.subscribe(event -> updateInventory(event));     // inventory update
publisher.subscribe(event -> recordAnalytics(event));     // analytics record
publisher.subscribe(event -> notifyWarehouse(event));     // warehouse notification

Modern descendants of Observer

DescendantDescriptionExamples
Event listenerDOM events, callback basedaddEventListener()
Pub/Sub (publish-subscribe)Publisher and subscriber fully decoupledRedis Pub/Sub, NATS
Reactive StreamsBackpressure supportRxJava, Project Reactor, Akka Streams
Message brokerAsynchronous communication across distributed systemsApache Kafka, RabbitMQ, AWS SNS/SQS

Push and pull models

Observer splits into two models depending on how data reaches the observer.

  • Push: the Subject hands the data directly to the Observer (the example above)
  • Pull: the Subject only signals that something changed, and the Observer fetches what it needs

Strategy pattern

Definition

Strategy defines a family of interchangeable algorithms, encapsulates each one, and lets the algorithm be swapped without touching the client.

Implementation: choosing a payment method

java
// Strategy interface
public interface PaymentStrategy {
    PaymentResult pay(Money amount, PaymentDetails details);
    boolean supports(PaymentType type);
}
 
// Concrete strategies
public class CreditCardStrategy implements PaymentStrategy {
    public PaymentResult pay(Money amount, PaymentDetails details) {
        // credit card payment logic
        return callCardGateway(details.getCardNumber(), amount);
    }
    public boolean supports(PaymentType type) {
        return type == PaymentType.CREDIT_CARD;
    }
}
 
public class BankTransferStrategy implements PaymentStrategy {
    public PaymentResult pay(Money amount, PaymentDetails details) {
        // bank transfer logic
        return initiateBankTransfer(details.getAccountNumber(), amount);
    }
    public boolean supports(PaymentType type) {
        return type == PaymentType.BANK_TRANSFER;
    }
}
 
public class CryptoStrategy implements PaymentStrategy {
    public PaymentResult pay(Money amount, PaymentDetails details) {
        // cryptocurrency payment logic
        return sendCryptoTransaction(details.getWalletAddress(), amount);
    }
    public boolean supports(PaymentType type) {
        return type == PaymentType.CRYPTO;
    }
}
 
// Context: the class that uses a strategy
public class PaymentProcessor {
    private final List<PaymentStrategy> strategies;
 
    public PaymentProcessor(List<PaymentStrategy> strategies) {
        this.strategies = strategies;
    }
 
    public PaymentResult process(PaymentType type, Money amount,
                                  PaymentDetails details) {
        return strategies.stream()
            .filter(s -> s.supports(type))
            .findFirst()
            .orElseThrow(() -> new UnsupportedPaymentException(type))
            .pay(amount, details);
    }
}

Strategy in functional programming

Functional programming expresses Strategy as a higher-order function. Instead of a strategy object, pass the comparison function as an argument.

typescript
// Functional strategy
type SortStrategy<T> = (a: T, b: T) => number;
 
const byPrice: SortStrategy<Product> = (a, b) => a.price - b.price;
const byRating: SortStrategy<Product> = (a, b) => b.rating - a.rating;
const byName: SortStrategy<Product> = (a, b) => a.name.localeCompare(b.name);
 
// Pass the strategy as a function argument
const sortedProducts = products.toSorted(byPrice);

Template Method pattern

Definition

Template Method defines the skeleton of an algorithm and delegates selected steps to subclasses. Individual steps can be redefined without altering the structure of the algorithm.

Implementation: a data processing pipeline

java
public abstract class DataProcessor {
    // Template method: the algorithm skeleton (final prevents overriding)
    public final ProcessingResult process(DataSource source) {
        RawData raw = extract(source);          // 1. extract
        ValidatedData validated = validate(raw); // 2. validate
        TransformedData transformed = transform(validated); // 3. transform
        load(transformed);                       // 4. load
        return createResult(transformed);        // 5. build the result
    }
 
    // Shared step: default implementation provided
    protected RawData extract(DataSource source) {
        return source.read();
    }
 
    // Abstract steps: subclasses must implement
    protected abstract ValidatedData validate(RawData raw);
    protected abstract TransformedData transform(ValidatedData data);
 
    // Hook methods: optional override
    protected void load(TransformedData data) {
        defaultStorage.save(data);
    }
 
    protected ProcessingResult createResult(TransformedData data) {
        return new ProcessingResult(data.getRecordCount());
    }
}
 
// CSV data processor
public class CsvDataProcessor extends DataProcessor {
    @Override
    protected ValidatedData validate(RawData raw) {
        // CSV specific validation
        return csvValidator.validate(raw);
    }
 
    @Override
    protected TransformedData transform(ValidatedData data) {
        // CSV specific transformation
        return csvTransformer.transform(data);
    }
}

Template Method vs Strategy

DimensionTemplate MethodStrategy
Variation mechanismInheritanceComposition
When the algorithm is swappedCompile timeRuntime
Control flowDecided by the parent classDecided by the client
FlexibilityLow (inheritance based)High (composition based)

Command pattern

Definition

Command wraps a request in an object, which makes it possible to parameterize, queue, log, and undo the request.

Implementation: a job queue

java
// Command interface
public interface Command {
    CommandResult execute();
    void undo(); // roll back the execution
    String describe(); // for logging
}
 
// Concrete command
public class CreateOrderCommand implements Command {
    private final OrderService orderService;
    private final OrderRequest request;
    private String createdOrderId;
 
    public CreateOrderCommand(OrderService orderService, OrderRequest request) {
        this.orderService = orderService;
        this.request = request;
    }
 
    @Override
    public CommandResult execute() {
        Order order = orderService.create(request);
        this.createdOrderId = order.getId(); // stored so undo can use it
        return CommandResult.success(order);
    }
 
    @Override
    public void undo() {
        if (createdOrderId != null) {
            orderService.cancel(createdOrderId);
        }
    }
 
    @Override
    public String describe() {
        return "CreateOrder: " + request.getProductId();
    }
}
 
// Invoker
public class CommandQueue {
    private final Queue<Command> pending = new ConcurrentLinkedQueue<>();
    private final Deque<Command> executed = new ConcurrentLinkedDeque<>();
 
    public void enqueue(Command command) {
        pending.offer(command);
    }
 
    public void processAll() {
        while (!pending.isEmpty()) {
            Command cmd = pending.poll();
            try {
                cmd.execute();
                executed.push(cmd);
            } catch (Exception e) {
                // on failure, roll back everything executed so far
                rollbackAll();
                throw e;
            }
        }
    }
 
    public void rollbackAll() {
        while (!executed.isEmpty()) {
            executed.pop().undo();
        }
    }
}

Extending Command: CQRS

Command is the foundation of CQRS (Command Query Responsibility Segregation):

text
Client ──→ Command Bus ──→ Command Handler ──→ Write DB
Client ──→ Query Bus  ──→ Query Handler  ──→ Read DB
  • Command: a request that mutates state (create, update, delete)
  • Query: a request that reads state
  • Separating the read and write models lets each be optimized on its own

Event Sourcing pattern

Definition

Event Sourcing stores every change to application state as an ordered log of events. Current state is derived by replaying those events in order.

Traditional storage vs Event Sourcing

text
[Traditional: store the state]
Account: { id: 1, balance: 1500 }
→ no way to tell how the balance became 1500
 
[Event Sourcing: store the events]
1. AccountCreated { id: 1, initialBalance: 0 }
2. MoneyDeposited { id: 1, amount: 2000 }
3. MoneyWithdrawn { id: 1, amount: 500 }
→ replaying gives balance = 0 + 2000 - 500 = 1500
→ the full change history is preserved

Implementation

java
// Domain events
public sealed interface AccountEvent {
    record AccountCreated(String accountId, Money initialBalance,
                          Instant timestamp) implements AccountEvent {}
    record MoneyDeposited(String accountId, Money amount,
                          Instant timestamp) implements AccountEvent {}
    record MoneyWithdrawn(String accountId, Money amount,
                          Instant timestamp) implements AccountEvent {}
}
 
// Event store
public interface EventStore {
    void append(String streamId, List<AccountEvent> events, long expectedVersion);
    List<AccountEvent> getEvents(String streamId);
    List<AccountEvent> getEvents(String streamId, long fromVersion);
}
 
// Aggregate: rebuild state by replaying events
public class Account {
    private String id;
    private Money balance;
    private long version;
 
    // restore state by replaying events
    public static Account reconstitute(List<AccountEvent> events) {
        Account account = new Account();
        events.forEach(account::apply);
        return account;
    }
 
    private void apply(AccountEvent event) {
        switch (event) {
            case AccountCreated e -> {
                this.id = e.accountId();
                this.balance = e.initialBalance();
            }
            case MoneyDeposited e -> {
                this.balance = this.balance.add(e.amount());
            }
            case MoneyWithdrawn e -> {
                this.balance = this.balance.subtract(e.amount());
            }
        }
        this.version++;
    }
}

Trade-offs of Event Sourcing

UpsideDownside
A complete audit trailManaging event schema evolution is complex
Time travel debuggingThe event store keeps growing
New read models can be built by replaying eventsEventual consistency
Combines naturally with CQRSSteep learning curve

Snapshots

As events pile up, replay takes longer. Snapshots cut that cost:

text
event 1 → event 2 → ... → event 1000 → [snapshot] → event 1001 → ...

                             restore replays from here onward

Saga pattern

Definition

Saga breaks a distributed transaction into a sequence of local transactions. When one local transaction fails, a compensating transaction runs to undo the work already done.

Two ways to implement it

Choreography

  • Each service publishes events and other services react to them
  • No central coordinator
  • Simple, but hard to trace once the flow gets complex

Orchestration

  • A central orchestrator owns the transaction ordering
  • Suits complex flows
  • The orchestrator can become a single point of failure (SPOF)

Choreography vs Orchestration

DimensionChoreographyOrchestration
CouplingLowMedium (depends on the orchestrator)
Complexity managementHard as the service count growsManaged centrally
VisibilityDistributed and hard to traceEasy to monitor from one place
ScalabilityHighThe orchestrator can bottleneck
Best fitSimple flows (3-4 steps)Complex business flows

Saga on AWS

  • AWS Step Functions: build an orchestration-style Saga visually
  • Amazon EventBridge: event routing for the choreography style
  • Amazon SQS/SNS: asynchronous messaging between services

Circuit Breaker pattern

Definition

Circuit Breaker detects repeated failures on an outbound call and blocks further calls, failing fast until the dependency recovers.

State transitions

Behavior per state

StateBehaviorTransition condition
CLOSED (normal)All requests pass throughFailure rate above threshold → OPEN
OPEN (tripped)All requests rejected immediately (fail fast)Timeout elapsed → HALF-OPEN
HALF-OPEN (probing)Only a limited number of requests allowedSuccess → CLOSED, failure → OPEN

Implementation

java
public class CircuitBreaker {
    private enum State { CLOSED, OPEN, HALF_OPEN }
 
    private volatile State state = State.CLOSED;
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final int failureThreshold;
    private final Duration timeout;
    private volatile Instant lastFailureTime;
 
    public <T> T execute(Supplier<T> operation, Supplier<T> fallback) {
        if (state == State.OPEN) {
            if (isTimeoutExpired()) {
                state = State.HALF_OPEN;
            } else {
                return fallback.get(); // fail fast
            }
        }
 
        try {
            T result = operation.get();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure();
            return fallback.get();
        }
    }
 
    private void onSuccess() {
        failureCount.set(0);
        state = State.CLOSED;
    }
 
    private void onFailure() {
        lastFailureTime = Instant.now();
        if (failureCount.incrementAndGet() >= failureThreshold) {
            state = State.OPEN;
        }
    }
 
    private boolean isTimeoutExpired() {
        return Duration.between(lastFailureTime, Instant.now()).compareTo(timeout) > 0;
    }
}

Production libraries

  • Resilience4j (Java): lightweight fault tolerance library
  • Polly (.NET): policy-based resilience library
  • Hystrix (Netflix, no longer maintained): migration to Resilience4j is recommended
  • Istio/Envoy: circuit breaking at the service mesh layer

Mediator pattern

Definition

Mediator forbids direct communication between objects and routes everything through a mediator object, which lowers coupling.

Use case: a chat room

java
public interface ChatMediator {
    void sendMessage(String message, User sender);
    void addUser(User user);
}
 
public class ChatRoom implements ChatMediator {
    private final List<User> users = new ArrayList<>();
 
    public void addUser(User user) {
        users.add(user);
    }
 
    public void sendMessage(String message, User sender) {
        users.stream()
            .filter(user -> !user.equals(sender))
            .forEach(user -> user.receive(message, sender.getName()));
    }
}

sendMessage does not let users exchange messages directly. The message goes through ChatRoom, which forwards it to everyone except the sender. User objects never hold references to each other, so coupling stays low.

Mediator at the architecture level

  • Message broker: Kafka and RabbitMQ act as mediators between services
  • Event bus: event-driven communication between microservices
  • API gateway: a mediator between clients and services

Chain of Responsibility pattern

Definition

Chain of Responsibility passes a request along a chain of handlers, where each handler either processes the request or forwards it to the next one.

Implementation: HTTP middleware

java
public interface RequestHandler {
    Response handle(Request request);
}
 
public abstract class Middleware implements RequestHandler {
    private Middleware next;
 
    public Middleware setNext(Middleware next) {
        this.next = next;
        return next; // fluent API
    }
 
    public Response handle(Request request) {
        if (next != null) {
            return next.handle(request);
        }
        return Response.ok();
    }
}
 
public class AuthMiddleware extends Middleware {
    public Response handle(Request request) {
        if (!isAuthenticated(request)) {
            return Response.unauthorized();
        }
        return super.handle(request); // forward to the next handler
    }
}
 
public class RateLimitMiddleware extends Middleware {
    public Response handle(Request request) {
        if (isRateLimited(request)) {
            return Response.tooManyRequests();
        }
        return super.handle(request);
    }
}
 
public class LoggingMiddleware extends Middleware {
    public Response handle(Request request) {
        log(request);
        Response response = super.handle(request);
        log(response);
        return response;
    }
}
 
// Build the chain
Middleware chain = new LoggingMiddleware();
chain.setNext(new AuthMiddleware())
     .setNext(new RateLimitMiddleware())
     .setNext(new BusinessLogicHandler());

Use cases

  • HTTP middleware chains (Express.js, Spring interceptors, ASP.NET middleware)
  • Log level filters (DEBUG → INFO → WARN → ERROR)
  • Approval workflows (team lead → department head → executive)

State pattern

Definition

State changes an object's behavior according to its internal state, modeling each transition as an explicit object.

Implementation: an order state machine

java
public interface OrderState {
    OrderState pay(Order order);
    OrderState ship(Order order);
    OrderState deliver(Order order);
    OrderState cancel(Order order);
}
 
public class PendingState implements OrderState {
    public OrderState pay(Order order) {
        order.setPaymentDate(Instant.now());
        return new PaidState();
    }
    public OrderState cancel(Order order) {
        order.setCancelReason("Cancelled before payment");
        return new CancelledState();
    }
    public OrderState ship(Order order) {
        throw new IllegalStateException("Cannot ship unpaid order");
    }
    public OrderState deliver(Order order) {
        throw new IllegalStateException("Cannot deliver unpaid order");
    }
}
 
public class PaidState implements OrderState {
    public OrderState ship(Order order) {
        order.setShippingDate(Instant.now());
        return new ShippedState();
    }
    public OrderState cancel(Order order) {
        order.refund(); // process the refund
        return new CancelledState();
    }
    // ...
}

Each method returns the next state object, and Order replaces its current state with that return value. Transitions that are not allowed are blocked with IllegalStateException. Calling ship on PendingState, for instance, refuses to ship an order that has not been paid for.

Reactor pattern

Definition

Reactor is a pattern for event-driven I/O. A single thread runs an event loop and services many concurrent I/O requests.

Core structure

Systems built on Reactor

SystemImplementation
Node.jsEvent loop on libuv
Nginxepoll/kqueue based
Netty (Java)Reactor on NIO
Tokio (Rust)Async runtime on io_uring/epoll
Spring WebFluxProject Reactor (on Netty)

io_uring here is the Linux kernel's asynchronous I/O interface; it reduces the number of system calls and delivers higher throughput than epoll.

Difference from the Proactor pattern

DimensionReactorProactor
I/O completion modelReadiness notification, then the handler does the I/OCompletion notification
OS supportepoll, kqueueIOCP (Windows), io_uring (Linux)
ComplexityRelatively lowHigh

Actor model

Definition

The actor model is a concurrency model whose unit of computation is the actor. Each actor owns private state and communicates only through message passing.

Core rules

  1. Receive messages: an actor takes a message and processes it
  2. Create actors: an actor can spawn other actors
  3. Send messages: an actor can send messages to other actors
  4. Change state: an actor can mutate its own state, which is inaccessible from outside

Characteristics of an actor system

  • Location transparency: message delivery works the same whether the actor lives in the same process or on another server
  • Fault isolation: one actor failing does not affect the others
  • Supervision strategy: a parent actor manages failures of its children

Major implementations

ImplementationLanguageNotes
AkkaScala, JavaThe most mature actor framework
Erlang/OTPErlangThe original actor model (BEAM VM)
Microsoft OrleansC#Virtual actor (grain) model
Proto.ActorGo, C#, KotlinCross-platform actors
PekkoScala, JavaApache fork created after the Akka license change

Actor model plus Event Sourcing

The actor model combines with Event Sourcing by persisting received messages as events.

  • Each actor stores the messages it received as events
  • After an actor fails, its state is restored by replaying those events
  • Akka Persistence implements exactly this pattern

Behavioral pattern comparison

PatternCore purposeScopeTypical use
ObserverNotify about state changesObject/serviceEvent systems, pub/sub
StrategySwap algorithmsObjectPayments, sorting, discount policies
Template MethodAlgorithm skeletonClassETL pipelines, frameworks
CommandEncapsulate a requestObjectJob queues, undo, CQRS
Event SourcingRecord eventsSystemFinance, auditing, time travel
SagaDistributed transactionsServiceOrder processing, payment flows
Circuit BreakerFault isolationServiceProtecting external API calls
MediatorMediate communicationObject/serviceMessage brokers, event buses
Chain of ResponsibilityForward a requestObjectHTTP middleware
StateState-driven behaviorObjectOrder states, workflows
ReactorEvent-driven I/OSystemWeb servers, networking
Actor modelMessage-driven concurrencySystemDistributed systems, real-time processing

Summary

Behavioral patterns lower coupling by splitting responsibility among objects and fixing how they communicate. Observer, Strategy, Command, and State handle collaboration between objects inside a single process, so pick them at the class design level. Saga, Circuit Breaker, and Event Sourcing cross service boundaries, so decide on failure handling and consistency requirements before adopting them. Two patterns with the same intent cost very different amounts once scope changes, which makes the scope column in the comparison table a practical selection criterion. The next post in the series covers creational patterns, which deal with object construction.