Behavioral Design Patterns in Java: Strategy, Observer, Command, Chain of Responsibility, Template Method, State & More
Behavioral design patterns define how objects communicate and distribute responsibility at runtime. While creational patterns answer "how do I create objects?" and structural patterns answer "how do I compose them?", behavioral patterns answer "how do objects interact and collaborate to accomplish complex tasks?" In a Spring Boot microservices context, behavioral patterns are everywhere — from the strategy-based payment dispatcher to the chain-of-responsibility filter pipeline in every HTTP request. This guide covers eight core behavioral patterns with real Java code, Spring Boot wiring, and honest advice on when to reach for each one.
Table of Contents
- What Are Behavioral Patterns?
- Strategy Pattern — Interchangeable Algorithms
- Observer Pattern — Event-Driven Notifications
- Command Pattern — Encapsulate Requests
- Chain of Responsibility — Request Pipeline
- Template Method — Define Skeleton of Algorithm
- State Pattern — Context-Dependent Behavior
- Iterator Pattern — Traversal Abstraction
- Mediator Pattern — Decoupled Communication
- Spring Boot Real-World Mapping
- Comparison Table & Choosing the Right Pattern
1. What Are Behavioral Patterns?
Behavioral design patterns are concerned with the assignment of responsibilities between objects and the patterns of communication between them. The GoF (Gang of Four) catalog defines eleven behavioral patterns: Strategy, Observer, Command, Chain of Responsibility, Template Method, State, Iterator, Mediator, Memento, Visitor, and Interpreter. In day-to-day Java and Spring Boot work, the first eight are the workhorses you will encounter and implement regularly.
The common thread across all behavioral patterns is loose coupling between senders and receivers of behavior. A caller should not need to know which algorithm will execute, which objects are listening to an event, or how an object's response changes based on its internal state. This separation makes code extensible, testable, and understandable by new team members joining a service six months after it was written.
The Three Behavioral Concerns
- Algorithm variation — Strategy, Template Method: swap or extend the algorithm without touching callers.
- Request handling & decoupling — Command, Chain of Responsibility, Mediator: decouple who sends a request from who handles it.
- State & traversal — State, Observer, Iterator: manage how an object's behavior changes with state, how changes propagate, and how collections are traversed.
2. Strategy Pattern — Interchangeable Algorithms
Real problem: An e-commerce checkout service must support CreditCard, PayPal, and Crypto payments. Without a pattern, developers add a new if/else or switch branch to PaymentService for every new provider. Adding Apple Pay or Stripe means touching battle-tested code and re-testing everything — a classic Open/Closed violation.
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The context class (OrderService) delegates to a PaymentStrategy without knowing which concrete implementation it holds. Adding a new payment method means creating a new class — no modification to existing code.
// Strategy interface
public interface PaymentStrategy {
String supportedMethod();
PaymentResult pay(PaymentRequest request);
}
// Concrete strategies
@Component
public class CreditCardStrategy implements PaymentStrategy {
@Override public String supportedMethod() { return "CREDIT_CARD"; }
@Override
public PaymentResult pay(PaymentRequest request) {
// Charge card via Stripe SDK
return PaymentResult.success("CC-" + UUID.randomUUID(), request.getAmount());
}
}
@Component
public class PayPalStrategy implements PaymentStrategy {
@Override public String supportedMethod() { return "PAYPAL"; }
@Override
public PaymentResult pay(PaymentRequest request) {
// Call PayPal REST API
return PaymentResult.success("PP-" + UUID.randomUUID(), request.getAmount());
}
}
@Component
public class CryptoStrategy implements PaymentStrategy {
@Override public String supportedMethod() { return "CRYPTO"; }
@Override
public PaymentResult pay(PaymentRequest request) {
// Broadcast to blockchain node
return PaymentResult.success("BTC-" + UUID.randomUUID(), request.getAmount());
}
}// Context: Spring collects all PaymentStrategy beans into a Map automatically
@Service
@RequiredArgsConstructor
public class OrderService {
private final Map<String, PaymentStrategy> paymentStrategies;
private final OrderRepository orderRepository;
// Spring injects List<PaymentStrategy> — convert to lookup map in constructor
public OrderService(List<PaymentStrategy> strategies, OrderRepository orderRepository) {
this.paymentStrategies = strategies.stream()
.collect(Collectors.toMap(PaymentStrategy::supportedMethod, s -> s));
this.orderRepository = orderRepository;
}
public OrderConfirmation placeOrder(Order order, String paymentMethod) {
PaymentStrategy strategy = paymentStrategies.get(paymentMethod);
if (strategy == null) {
throw new UnsupportedPaymentMethodException("Unsupported: " + paymentMethod);
}
PaymentResult result = strategy.pay(new PaymentRequest(order.getTotal(), order.getCurrency()));
order.setPaymentReference(result.getReference());
orderRepository.save(order);
return OrderConfirmation.from(order, result);
}
}@Component public class ApplePayStrategy implements PaymentStrategy. Spring auto-discovers and injects it. Zero changes to OrderService.Benefits & When to Use
- Eliminates conditional dispatch — each algorithm lives in isolation and can be tested independently.
- Adding a new variant is a single-file change with no regression risk to existing strategies.
- Use when you have a family of interchangeable algorithms (payment methods, sorting strategies, notification channels, discount calculators) that vary independently of the context that uses them.
- Avoid when there is only one algorithm and it is unlikely to change — the abstraction adds indirection for no benefit.
3. Observer Pattern — Event-Driven Notifications
Real problem: A stock trading platform needs to notify multiple subscribers — a mobile push alert service, an email alert service, and a risk monitor — whenever a stock price crosses a threshold. If StockMarket directly calls each service, adding a new subscriber requires modifying StockMarket. The publisher should not know about its subscribers.
The Observer pattern defines a one-to-many dependency: when the subject (publisher) changes state, all registered observers (subscribers) are notified automatically. This is the foundation of event-driven architecture and is directly reflected in Spring's ApplicationEventPublisher.
// Observer interface
public interface StockObserver {
void onPriceChange(String ticker, BigDecimal oldPrice, BigDecimal newPrice);
}
// Subject (Publisher)
@Component
public class StockMarket {
private final Map<String, BigDecimal> prices = new ConcurrentHashMap<>();
private final List<StockObserver> observers = new CopyOnWriteArrayList<>();
public void addObserver(StockObserver observer) {
observers.add(observer);
}
public void removeObserver(StockObserver observer) {
observers.remove(observer);
}
public void updatePrice(String ticker, BigDecimal newPrice) {
BigDecimal oldPrice = prices.getOrDefault(ticker, BigDecimal.ZERO);
prices.put(ticker, newPrice);
observers.forEach(o -> o.onPriceChange(ticker, oldPrice, newPrice));
}
}
// Concrete observers
@Component
public class PriceAlertService implements StockObserver {
private final NotificationGateway notificationGateway;
@Override
public void onPriceChange(String ticker, BigDecimal oldPrice, BigDecimal newPrice) {
if (newPrice.compareTo(oldPrice) > 0) {
notificationGateway.sendPushAlert(ticker + " rose to " + newPrice);
}
}
}
@Component
public class RiskMonitor implements StockObserver {
private static final BigDecimal DROP_THRESHOLD = new BigDecimal("0.05");
@Override
public void onPriceChange(String ticker, BigDecimal oldPrice, BigDecimal newPrice) {
if (oldPrice.compareTo(BigDecimal.ZERO) > 0) {
BigDecimal dropRatio = oldPrice.subtract(newPrice).divide(oldPrice, 4, RoundingMode.HALF_UP);
if (dropRatio.compareTo(DROP_THRESHOLD) > 0) {
log.warn("RISK ALERT: {} dropped {}% — triggering circuit breaker", ticker, dropRatio.multiply(BigDecimal.valueOf(100)));
}
}
}
}// Spring Boot equivalent using ApplicationEventPublisher
// Event POJO
public record StockPriceChangedEvent(String ticker, BigDecimal oldPrice, BigDecimal newPrice) {}
// Publisher
@Service
@RequiredArgsConstructor
public class StockMarketService {
private final ApplicationEventPublisher eventPublisher;
public void updatePrice(String ticker, BigDecimal newPrice) {
BigDecimal old = priceStore.get(ticker);
priceStore.put(ticker, newPrice);
eventPublisher.publishEvent(new StockPriceChangedEvent(ticker, old, newPrice));
}
}
// Subscriber — no coupling to StockMarketService
@Component
public class PriceAlertListener {
@EventListener
public void handlePriceChange(StockPriceChangedEvent event) {
// React to price change
}
@EventListener
@Async // non-blocking
public void handleAsync(StockPriceChangedEvent event) { /* ... */ }
}CopyOnWriteArrayList for the observer list if observers can be added/removed concurrently. For Spring Boot, prefer @Async listeners to avoid blocking the publisher thread — and always configure a dedicated ThreadPoolTaskExecutor for async events.Benefits & When to Use
- Publisher and subscribers are loosely coupled — neither references the other's concrete class.
- New subscribers can be added without modifying the publisher.
- Use for domain events: order placed, user registered, payment captured — anything where multiple reactions should occur without the source knowing who reacts.
- Avoid for synchronous, tightly-coupled workflows where ordering and error propagation are critical — consider Saga or choreography-based event sourcing instead.
4. Command Pattern — Encapsulate Requests
Real problem: An order management system needs undo/redo capabilities. A customer service agent should be able to cancel the last action — whether it was placing an order, applying a discount, or updating a shipping address. Without a pattern, every action is a direct method call with no history and no way to reverse it.
The Command pattern encapsulates a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations. Each command knows both how to execute the action and how to reverse it.
// Command interface
public interface OrderCommand {
void execute();
void undo();
String getDescription();
}
// Concrete command: Place Order
public class PlaceOrderCommand implements OrderCommand {
private final OrderRepository orderRepository;
private final Order order;
private UUID savedOrderId;
public PlaceOrderCommand(OrderRepository repo, Order order) {
this.orderRepository = repo;
this.order = order;
}
@Override
public void execute() {
orderRepository.save(order);
this.savedOrderId = order.getId();
log.info("Order placed: {}", savedOrderId);
}
@Override
public void undo() {
if (savedOrderId != null) {
orderRepository.deleteById(savedOrderId);
log.info("Order placement undone: {}", savedOrderId);
}
}
@Override
public String getDescription() { return "Place order " + order.getId(); }
}
// Concrete command: Cancel Order
public class CancelOrderCommand implements OrderCommand {
private final OrderRepository orderRepository;
private final UUID orderId;
private Order cancelledOrderSnapshot;
public CancelOrderCommand(OrderRepository repo, UUID orderId) {
this.orderRepository = repo;
this.orderId = orderId;
}
@Override
public void execute() {
cancelledOrderSnapshot = orderRepository.findById(orderId).orElseThrow();
orderRepository.updateStatus(orderId, OrderStatus.CANCELLED);
log.info("Order cancelled: {}", orderId);
}
@Override
public void undo() {
if (cancelledOrderSnapshot != null) {
orderRepository.updateStatus(orderId, cancelledOrderSnapshot.getStatus());
log.info("Order cancellation undone: {}", orderId);
}
}
@Override
public String getDescription() { return "Cancel order " + orderId; }
}// Invoker with undo history stack
@Service
public class OrderInvoker {
private final Deque<OrderCommand> history = new ArrayDeque<>();
public void executeCommand(OrderCommand command) {
command.execute();
history.push(command);
}
public void undoLast() {
if (history.isEmpty()) {
log.warn("No commands to undo");
return;
}
OrderCommand lastCommand = history.pop();
lastCommand.undo();
log.info("Undone: {}", lastCommand.getDescription());
}
public void undoAll() {
while (!history.isEmpty()) {
undoLast();
}
}
public List<String> getCommandHistory() {
return history.stream().map(OrderCommand::getDescription).toList();
}
}JobLauncher and Step model is essentially the Command pattern at scale — each Job is an encapsulated unit of work that can be restarted, retried, or skipped independently.Benefits & When to Use
- Decouples the object that invokes an operation from the object that performs it.
- Undo/redo is a natural consequence of storing commands in a history structure.
- Commands can be serialized, queued (via Kafka/SQS), and replayed — foundational for event sourcing.
- Use when you need transactional scripting, undo support, operation logging, or queued task execution.
5. Chain of Responsibility — Request Pipeline
Real problem: Every incoming HTTP request must pass through authentication, rate limiting, and logging — in that order. Each concern is independent, but they must be applied sequentially. Hard-coding all three checks in a single service method creates a maintenance nightmare and makes it impossible to reorder or remove a step without touching business logic.
The Chain of Responsibility pattern passes a request along a chain of handlers. Each handler decides to process the request, pass it to the next handler, or stop the chain entirely. This is exactly how Spring Security's filter chain and MVC interceptors work.
// Abstract handler
public abstract class RequestHandler {
private RequestHandler next;
public RequestHandler setNext(RequestHandler next) {
this.next = next;
return next; // enables fluent chaining
}
public final void handle(HttpRequestContext ctx) {
if (doHandle(ctx) && next != null) {
next.handle(ctx);
}
}
// Returns true to continue chain, false to stop
protected abstract boolean doHandle(HttpRequestContext ctx);
}
// Auth handler
public class AuthFilter extends RequestHandler {
private final TokenValidator tokenValidator;
@Override
protected boolean doHandle(HttpRequestContext ctx) {
String token = ctx.getHeader("Authorization");
if (token == null || !tokenValidator.isValid(token)) {
ctx.setResponse(401, "Unauthorized");
return false; // stop the chain
}
ctx.setPrincipal(tokenValidator.extract(token));
return true;
}
}
// Rate limit handler
public class RateLimitFilter extends RequestHandler {
private final RateLimiter rateLimiter;
@Override
protected boolean doHandle(HttpRequestContext ctx) {
String clientId = ctx.getPrincipal().getId();
if (!rateLimiter.tryAcquire(clientId)) {
ctx.setResponse(429, "Too Many Requests");
return false;
}
return true;
}
}
// Logging handler — always passes through
public class LogFilter extends RequestHandler {
@Override
protected boolean doHandle(HttpRequestContext ctx) {
log.info("[{}] {} {} from {}", Instant.now(), ctx.getMethod(), ctx.getPath(), ctx.getRemoteAddr());
return true;
}
}// Assembling the chain
@Configuration
public class FilterChainConfig {
@Bean
public RequestHandler requestPipeline(AuthFilter auth, RateLimitFilter rate, LogFilter log) {
auth.setNext(rate).setNext(log);
return auth; // entry point
}
}
// Spring Boot equivalent: OncePerRequestFilter chain
@Component
@Order(1)
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (token != null && jwtService.isValid(token)) {
SecurityContextHolder.getContext().setAuthentication(jwtService.getAuthentication(token));
}
filterChain.doFilter(request, response); // continue chain
}
}SecurityFilterChain bean contains an ordered list of filters. Use @Order or SecurityFilterChain ordering to control the pipeline without modifying individual filters.Benefits & When to Use
- Decouples senders from receivers — handlers are unaware of each other.
- Handlers can be reordered, added, or removed without touching other handlers.
- Use for request pipelines, validation chains, middleware, approval workflows, and event processing pipelines.
- Avoid when you need guaranteed handling — if no handler matches, the request is silently dropped unless you add a catch-all.
6. Template Method — Define Skeleton of Algorithm
Real problem: A reporting service generates CSV, PDF, and Excel reports. All three formats share the same algorithm skeleton: connect to the data source, fetch the data, apply formatting, and write the output. The only differences are the formatting and writing steps. Without a pattern, this skeleton is copy-pasted into three classes, and bug fixes in the "fetch" step must be applied three times.
The Template Method pattern defines the skeleton of an algorithm in a base class, deferring the variable steps to subclasses. The base class controls the sequence; subclasses fill in the specifics. This is inheritance-based, in contrast to Strategy which uses composition.
// Abstract base — defines the skeleton
public abstract class ReportGenerator {
// Template method — final so subclasses cannot break the skeleton
public final Report generate(ReportRequest request) {
DataSet data = fetchData(request); // shared
data = filterAndSort(data, request); // shared
Object formatted = formatData(data); // hook — subclass
byte[] output = writeOutput(formatted); // hook — subclass
return new Report(output, getContentType(), getFileExtension());
}
// Shared steps
private DataSet fetchData(ReportRequest request) {
return dataSource.query(request.getQuery());
}
private DataSet filterAndSort(DataSet data, ReportRequest request) {
return data.applyFilters(request.getFilters())
.sort(request.getSortField(), request.getSortOrder());
}
// Hook methods — must be implemented by subclasses
protected abstract Object formatData(DataSet data);
protected abstract byte[] writeOutput(Object formatted);
protected abstract String getContentType();
protected abstract String getFileExtension();
}
// CSV subclass
@Component
public class CsvReportGenerator extends ReportGenerator {
@Override
protected Object formatData(DataSet data) {
return data.toCsvRows();
}
@Override
protected byte[] writeOutput(Object formatted) {
return String.join("\n", (List<String>) formatted).getBytes(StandardCharsets.UTF_8);
}
@Override protected String getContentType() { return "text/csv"; }
@Override protected String getFileExtension() { return ".csv"; }
}
// PDF subclass
@Component
public class PdfReportGenerator extends ReportGenerator {
private final PdfWriter pdfWriter;
@Override
protected Object formatData(DataSet data) {
return data.toPdfTable();
}
@Override
protected byte[] writeOutput(Object formatted) {
return pdfWriter.render((PdfTable) formatted);
}
@Override protected String getContentType() { return "application/pdf"; }
@Override protected String getFileExtension() { return ".pdf"; }
}
// Excel subclass
@Component
public class ExcelReportGenerator extends ReportGenerator {
@Override
protected Object formatData(DataSet data) { return data.toExcelWorkbook(); }
@Override
protected byte[] writeOutput(Object formatted) {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
((Workbook) formatted).write(out);
return out.toByteArray();
} catch (IOException e) { throw new ReportGenerationException(e); }
}
@Override protected String getContentType() { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; }
@Override protected String getFileExtension() { return ".xlsx"; }
}JdbcTemplate, RestTemplate, and HibernateTemplate are all Template Method implementations.7. State Pattern — Context-Dependent Behavior
Real problem: An order lifecycle has distinct states: PENDING, CONFIRMED, SHIPPED, DELIVERED, and CANCELLED. In each state, the valid operations and transitions differ. A PENDING order can be confirmed or cancelled; a SHIPPED order cannot be confirmed; a DELIVERED order cannot be cancelled. Without a pattern, this logic becomes a massive nest of if (status == PENDING) ... else if (status == SHIPPED) blocks that grow exponentially with each new state.
The State pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class. Each state is represented by a separate class that implements a common interface. The context (Order) delegates all behavior to its current state object.
// State interface
public interface OrderState {
void confirm(Order order);
void ship(Order order);
void deliver(Order order);
void cancel(Order order);
String getStatusName();
}
// Pending state — can confirm or cancel
public class PendingState implements OrderState {
@Override
public void confirm(Order order) {
log.info("Order {} confirmed", order.getId());
order.setState(new ConfirmedState());
}
@Override public void ship(Order order) {
throw new InvalidStateTransitionException("Cannot ship a pending order");
}
@Override public void deliver(Order order) {
throw new InvalidStateTransitionException("Cannot deliver a pending order");
}
@Override
public void cancel(Order order) {
log.info("Order {} cancelled from PENDING", order.getId());
order.setState(new CancelledState());
}
@Override public String getStatusName() { return "PENDING"; }
}
// Confirmed state — can ship or cancel
public class ConfirmedState implements OrderState {
@Override public void confirm(Order order) {
throw new InvalidStateTransitionException("Order is already confirmed");
}
@Override
public void ship(Order order) {
log.info("Order {} shipped", order.getId());
order.setState(new ShippedState());
}
@Override public void deliver(Order order) {
throw new InvalidStateTransitionException("Cannot deliver before shipping");
}
@Override
public void cancel(Order order) {
log.info("Order {} cancelled from CONFIRMED", order.getId());
order.setState(new CancelledState());
}
@Override public String getStatusName() { return "CONFIRMED"; }
}
// Shipped state — can only deliver
public class ShippedState implements OrderState {
@Override public void confirm(Order order) { throw new InvalidStateTransitionException("Already shipped"); }
@Override public void ship(Order order) { throw new InvalidStateTransitionException("Already shipped"); }
@Override
public void deliver(Order order) {
log.info("Order {} delivered", order.getId());
order.setState(new DeliveredState());
}
@Override public void cancel(Order order) {
throw new InvalidStateTransitionException("Cannot cancel a shipped order. Initiate a return instead.");
}
@Override public String getStatusName() { return "SHIPPED"; }
}
// Delivered and Cancelled states are terminal — all transitions throw
public class DeliveredState implements OrderState {
@Override public void confirm(Order o) { throw new InvalidStateTransitionException("Order delivered"); }
@Override public void ship(Order o) { throw new InvalidStateTransitionException("Order delivered"); }
@Override public void deliver(Order o) { throw new InvalidStateTransitionException("Already delivered"); }
@Override public void cancel(Order o) { throw new InvalidStateTransitionException("Cannot cancel delivered order"); }
@Override public String getStatusName() { return "DELIVERED"; }
}
public class CancelledState implements OrderState {
@Override public void confirm(Order o) { throw new InvalidStateTransitionException("Order cancelled"); }
@Override public void ship(Order o) { throw new InvalidStateTransitionException("Order cancelled"); }
@Override public void deliver(Order o) { throw new InvalidStateTransitionException("Order cancelled"); }
@Override public void cancel(Order o) { throw new InvalidStateTransitionException("Already cancelled"); }
@Override public String getStatusName() { return "CANCELLED"; }
}// Context class — delegates to current state
@Entity
public class Order {
@Id private UUID id;
@Transient private OrderState state = new PendingState(); // not persisted directly
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.PENDING;
public void setState(OrderState newState) {
this.state = newState;
this.status = OrderStatus.valueOf(newState.getStatusName());
}
public void confirm() { state.confirm(this); }
public void ship() { state.ship(this); }
public void deliver() { state.deliver(this); }
public void cancel() { state.cancel(this); }
public String getStatusName() { return state.getStatusName(); }
}Order State Transition Table
| Current State | confirm() | ship() | deliver() | cancel() |
|---|---|---|---|---|
| PENDING | → CONFIRMED | ✗ Error | ✗ Error | → CANCELLED |
| CONFIRMED | ✗ Error | → SHIPPED | ✗ Error | → CANCELLED |
| SHIPPED | ✗ Error | ✗ Error | → DELIVERED | ✗ Error |
| DELIVERED | ✗ Error | ✗ Error | ✗ Error | ✗ Error |
| CANCELLED | ✗ Error | ✗ Error | ✗ Error | ✗ Error |
8. Iterator Pattern — Traversal Abstraction
Real problem: A reporting service needs to process millions of database records without loading them all into memory. Pagination is required, but callers should not deal with page numbers, cursors, or LIMIT/OFFSET clauses — they just want to iterate over results in a standard Java for loop.
The Iterator pattern provides a way to access elements of a collection sequentially without exposing its underlying representation. Java's java.util.Iterator<T> is the canonical implementation. By implementing it, any custom collection gains compatibility with the enhanced for loop and Java Streams.
// Paginating Iterator over database results
public class PageIterator<T> implements Iterator<T> {
private final Function<PageRequest, List<T>> fetcher;
private final int pageSize;
private int currentPage = 0;
private Queue<T> buffer = new ArrayDeque<>();
private boolean exhausted = false;
public PageIterator(Function<PageRequest, List<T>> fetcher, int pageSize) {
this.fetcher = fetcher;
this.pageSize = pageSize;
loadNextPage();
}
private void loadNextPage() {
if (exhausted) return;
List<T> page = fetcher.apply(PageRequest.of(currentPage++, pageSize));
if (page.isEmpty() || page.size() < pageSize) {
exhausted = true;
}
buffer.addAll(page);
}
@Override
public boolean hasNext() {
if (!buffer.isEmpty()) return true;
if (exhausted) return false;
loadNextPage();
return !buffer.isEmpty();
}
@Override
public T next() {
if (!hasNext()) throw new NoSuchElementException();
return buffer.poll();
}
}
// Usage — callers see a simple Iterator
@Service
@RequiredArgsConstructor
public class ReportingService {
private final OrderRepository orderRepository;
public void exportAllOrders(OutputStream out) {
Iterator<Order> iterator = new PageIterator<>(
page -> orderRepository.findAll(page).getContent(),
500 // fetch 500 records at a time
);
// Enhanced for loop works because we implement Iterator
while (iterator.hasNext()) {
Order order = iterator.next();
writeCsvRow(out, order);
}
}
}// Java enhanced for loop works if you implement Iterable<T>
public class PageIterable<T> implements Iterable<T> {
private final Function<PageRequest, List<T>> fetcher;
private final int pageSize;
@Override
public Iterator<T> iterator() {
return new PageIterator<>(fetcher, pageSize);
}
}
// Now callers can use for-each syntax:
for (Order order : new PageIterable<>(page -> repo.findAll(page).getContent(), 500)) {
processOrder(order);
}
// Or convert to Stream
StreamSupport.stream(new PageIterable<>(fetcher, 500).spliterator(), false)
.filter(o -> o.getStatus() == OrderStatus.PENDING)
.forEach(this::processOrder);Benefits & When to Use
- Callers are decoupled from the storage model — swap in-memory, database, or S3 enumeration without changing consumer code.
- Memory-efficient for large datasets — only one page is in memory at a time.
- Use when you need lazy or paginated traversal, or when exposing a collection API where the underlying structure may change.
- In modern Java, prefer
Streampipelines and Spring Data'sPage<T>for most use cases; implementIteratoronly when you need custom traversal semantics.
9. Mediator Pattern — Decoupled Communication
Real problem: In a real-time chat room, users send messages to each other. If each User object holds references to all other users in the room, adding or removing a user requires updating N objects. With 100 users, each user knows 99 others — an O(N²) coupling explosion.
The Mediator pattern defines an object that encapsulates how a set of objects interact. Instead of users communicating directly, they communicate exclusively through a ChatMediator. Each user only knows the mediator, not other users. This reduces the connections from O(N²) to O(N).
// Mediator interface
public interface ChatMediator {
void sendMessage(String message, User sender);
void addUser(User user);
void removeUser(User user);
}
// Concrete mediator
@Component
public class ChatRoom implements ChatMediator {
private final Set<User> users = ConcurrentHashMap.newKeySet();
@Override
public void addUser(User user) {
users.add(user);
broadcast(user.getUsername() + " joined the room.", null);
}
@Override
public void removeUser(User user) {
users.remove(user);
broadcast(user.getUsername() + " left the room.", null);
}
@Override
public void sendMessage(String message, User sender) {
broadcast("[" + sender.getUsername() + "]: " + message, sender);
}
private void broadcast(String message, User exclude) {
users.stream()
.filter(u -> !u.equals(exclude))
.forEach(u -> u.receive(message));
}
}
// Colleague — only knows the mediator
public class User {
private final String username;
private final ChatMediator mediator;
public User(String username, ChatMediator mediator) {
this.username = username;
this.mediator = mediator;
}
public void send(String message) {
mediator.sendMessage(message, this); // delegates to mediator
}
public void receive(String message) {
log.info("{} received: {}", username, message);
}
public String getUsername() { return username; }
}// Spring equivalent: ApplicationContext as a Mediator
// Services publish events through ApplicationEventPublisher (mediator)
// Listeners subscribe to specific event types — no service-to-service dependencies
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher publisher; // the mediator
public void placeOrder(Order order) {
orderRepository.save(order);
publisher.publishEvent(new OrderPlacedEvent(order)); // notify all interested parties
}
}
// Multiple listeners — mutually unaware of each other
@Component public class InventoryListener {
@EventListener public void onOrderPlaced(OrderPlacedEvent e) { /* decrement stock */ }
}
@Component public class EmailListener {
@EventListener public void onOrderPlaced(OrderPlacedEvent e) { /* send confirmation */ }
}
@Component public class LoyaltyListener {
@EventListener public void onOrderPlaced(OrderPlacedEvent e) { /* award points */ }
}10. Spring Boot Real-World Mapping
Every behavioral pattern has a natural home in the Spring ecosystem. Understanding these mappings lets you leverage Spring's built-in infrastructure rather than reinventing it. The following table shows how each pattern manifests in production Spring Boot applications.
| Pattern | Spring Feature / Idiom | Key Benefit in Spring |
|---|---|---|
| Strategy | @Component + Map<String, Strategy> injection |
Auto-discovery of strategies; zero-configuration dispatch |
| Observer | ApplicationEventPublisher / @EventListener |
Decoupled domain events; @Async for non-blocking handlers |
| Command | Spring Batch JobLauncher / TaskExecutor |
Restartable, retryable, parallelizable units of work |
| Chain of Responsibility | Spring Security FilterChain / MVC HandlerInterceptor |
Ordered, composable request pipeline with short-circuit support |
| Template Method | JdbcTemplate, RestTemplate, AbstractMessageConverterMethodArgumentResolver |
Boilerplate elimination; customization through callback/hook methods |
| State | Spring State Machine (spring-statemachine) |
Persistent state, distributed guards, history states |
| Iterator | Spring Data Page<T> / Streamable<T> / ScrollPosition |
Memory-efficient lazy loading; cursor-based pagination in Spring Data 3.x |
| Mediator | ApplicationContext / ApplicationEventPublisher |
Central event bus; services publish to context, not to each other directly |
StateMachinePersist implementation backed by Redis or a relational database. This ensures state survives service restarts and pod rescheduling in Kubernetes.11. Comparison Table & Choosing the Right Pattern
Choosing the wrong behavioral pattern is usually a symptom of misidentifying the core problem. Strategy and Template Method both vary algorithms, but by different mechanisms. Observer and Mediator both reduce coupling in multi-party communication, but with different routing control. Use the table below as a decision guide.
| Pattern | Core Purpose | Spring Boot Equivalent | When to Switch |
|---|---|---|---|
| Strategy | Swap algorithms at runtime | @Component map injection |
Use Template Method if skeleton is fixed and only 1–2 steps vary |
| Observer | Broadcast state changes to N listeners | @EventListener |
Use Mediator if routing logic is complex or bi-directional |
| Command | Encapsulate requests with undo/queue | Spring Batch | Use Strategy if no undo/history is needed |
| Chain of Responsibility | Sequential pipeline with short-circuit | Spring Security FilterChain | Use Strategy for parallel dispatch to a single handler |
| Template Method | Fixed skeleton, variable steps via inheritance | JdbcTemplate, RestTemplate | Use Strategy if algorithm needs runtime swapping |
| State | Object behavior changes with internal state | Spring State Machine | Use enum + switch for 2–3 states with no complex transitions |
| Iterator | Decouple traversal from collection structure | Spring Data Page / Streamable | Use Java Stream directly for most in-memory traversals |
| Mediator | Central hub for M:N communication | ApplicationEventPublisher | Use Observer for simple 1:N broadcasting without routing logic |
Pattern Selection Decision Tree
- Do you need to switch algorithms at runtime? → Strategy. If the skeleton is fixed and only steps vary → Template Method.
- Do you need one event to trigger multiple independent reactions? → Observer (
@EventListener). If routing logic determines which receiver handles it → Mediator. - Do you need undo/redo or operation history? → Command.
- Do you need to pass a request through ordered, independent stages? → Chain of Responsibility (Spring filter chain).
- Does your object's behavior fundamentally change based on its internal state? → State pattern. For 2–3 states, an enum + switch is fine; beyond 4–5, use State classes or Spring State Machine.
- Do you need lazy or paginated traversal over a large collection? → Iterator. For most in-memory cases, use Java Streams.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices