Behavioral Patterns - Distributing Responsibility and Communication Between Objects
A full tour of behavioral patterns: Observer, Strategy, Command, Saga, Circuit Breaker, Event Sourcing, and more
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
// 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 notificationModern descendants of Observer
| Descendant | Description | Examples |
|---|---|---|
| Event listener | DOM events, callback based | addEventListener() |
| Pub/Sub (publish-subscribe) | Publisher and subscriber fully decoupled | Redis Pub/Sub, NATS |
| Reactive Streams | Backpressure support | RxJava, Project Reactor, Akka Streams |
| Message broker | Asynchronous communication across distributed systems | Apache 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
// 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.
// 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
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
| Dimension | Template Method | Strategy |
|---|---|---|
| Variation mechanism | Inheritance | Composition |
| When the algorithm is swapped | Compile time | Runtime |
| Control flow | Decided by the parent class | Decided by the client |
| Flexibility | Low (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
// 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):
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
[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 preservedImplementation
// 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
| Upside | Downside |
|---|---|
| A complete audit trail | Managing event schema evolution is complex |
| Time travel debugging | The event store keeps growing |
| New read models can be built by replaying events | Eventual consistency |
| Combines naturally with CQRS | Steep learning curve |
Snapshots
As events pile up, replay takes longer. Snapshots cut that cost:
event 1 → event 2 → ... → event 1000 → [snapshot] → event 1001 → ...
↑
restore replays from here onwardSaga 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
| Dimension | Choreography | Orchestration |
|---|---|---|
| Coupling | Low | Medium (depends on the orchestrator) |
| Complexity management | Hard as the service count grows | Managed centrally |
| Visibility | Distributed and hard to trace | Easy to monitor from one place |
| Scalability | High | The orchestrator can bottleneck |
| Best fit | Simple 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
| State | Behavior | Transition condition |
|---|---|---|
| CLOSED (normal) | All requests pass through | Failure 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 allowed | Success → CLOSED, failure → OPEN |
Implementation
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
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
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
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
| System | Implementation |
|---|---|
| Node.js | Event loop on libuv |
| Nginx | epoll/kqueue based |
| Netty (Java) | Reactor on NIO |
| Tokio (Rust) | Async runtime on io_uring/epoll |
| Spring WebFlux | Project 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
| Dimension | Reactor | Proactor |
|---|---|---|
| I/O completion model | Readiness notification, then the handler does the I/O | Completion notification |
| OS support | epoll, kqueue | IOCP (Windows), io_uring (Linux) |
| Complexity | Relatively low | High |
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
- Receive messages: an actor takes a message and processes it
- Create actors: an actor can spawn other actors
- Send messages: an actor can send messages to other actors
- 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
| Implementation | Language | Notes |
|---|---|---|
| Akka | Scala, Java | The most mature actor framework |
| Erlang/OTP | Erlang | The original actor model (BEAM VM) |
| Microsoft Orleans | C# | Virtual actor (grain) model |
| Proto.Actor | Go, C#, Kotlin | Cross-platform actors |
| Pekko | Scala, Java | Apache 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
| Pattern | Core purpose | Scope | Typical use |
|---|---|---|---|
| Observer | Notify about state changes | Object/service | Event systems, pub/sub |
| Strategy | Swap algorithms | Object | Payments, sorting, discount policies |
| Template Method | Algorithm skeleton | Class | ETL pipelines, frameworks |
| Command | Encapsulate a request | Object | Job queues, undo, CQRS |
| Event Sourcing | Record events | System | Finance, auditing, time travel |
| Saga | Distributed transactions | Service | Order processing, payment flows |
| Circuit Breaker | Fault isolation | Service | Protecting external API calls |
| Mediator | Mediate communication | Object/service | Message brokers, event buses |
| Chain of Responsibility | Forward a request | Object | HTTP middleware |
| State | State-driven behavior | Object | Order states, workflows |
| Reactor | Event-driven I/O | System | Web servers, networking |
| Actor model | Message-driven concurrency | System | Distributed 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.