Behavioral Patterns — Cheat Sheet
Low Level Design · 10 topics. Download the PDF or the Instagram carousel and share it.
Observer Pattern
Defines a one-to-many dependency so that when one object (Subject) changes state, all its dependents (Observers) are notified automatically.
- ✓Observer defines one-to-many dependency — Subject notifies all registered observers on state change.
- ✓The "lapsed listener" problem: failing to deregister observers causes memory leaks.
- ✓Spring's @EventListener is the production Observer implementation — use it over raw Observer.
- ✓java.util.Observable is deprecated since Java 9 — avoid it.
- ✓@Async on @EventListener makes the observer run on a separate thread (fire-and-forget).
- ✓Reactive streams (RxJava, Project Reactor) extend Observer with backpressure, error handling, and operators.
import java.util.ArrayList;
import java.util.List;
// Observer interface
public interface StockObserver {
void onPriceChange(String symbol, double newPrice, double oldPrice);
}
// Subject interface
public interface StockSubject {
void addObserver(StockObserver observer);
void removeObserver(StockObserver observer);
void notifyObservers();
}
// Concrete Subject
public class StockTicker implements StockSubject {
private final String symbol;
private double currentPrice;
private final List<StockObserver> observers = new ArrayList<>();
public StockTicker(String symbol, double initialPrice) {
this.symbol = symbol;
this.currentPrice = initialPrice;
}
@Override public void addObserver(StockObserver o) { observers.add(o); }
@Override public void removeObserver(StockObserver o) { observers.remove(o); }
public void setPrice(double newPrice) {
double oldPrice = this.currentPrice;
this.currentPrice = newPrice;
notifyObservers(oldPrice); // state changed — notify
}
private void notifyObservers(double oldPrice) {
observers.forEach(o -> o.onPriceChange(symbol, currentPrice, oldPrice));
}
@Override public void notifyObservers() {
observers.forEach(o -> o.onPriceChange(symbol, currentPrice, currentPrice));
}
}
// Concrete Observers
public class AlertObserver implements StockObserver {
private final double threshold;
public AlertObserver(double threshold) { this.threshold = threshold; }
@Override
public void onPriceChange(String symbol, double newPrice, double oldPrice) {
if (Math.abs(newPrice - oldPrice) / oldPrice > threshold) {
System.out.printf("ALERT: %s moved by >%.0f%% → ₹%.2f%n",
symbol, threshold * 100, newPrice);
}
}
}
public class PortfolioObserver implements StockObserver {
private final int quantity;
public PortfolioObserver(int quantity) { this.quantity = quantity; }
@Override
public void onPriceChange(String symbol, double newPrice, double oldPrice) {
double pnl = (newPrice - oldPrice) * quantity;
System.out.printf("Portfolio P&L for %s: %+.2f%n", symbol, pnl);
}
}
// Usage
StockTicker infosys = new StockTicker("INFY", 1500.0);
infosys.addObserver(new AlertObserver(0.05)); // alert on 5% move
infosys.addObserver(new PortfolioObserver(100)); // holds 100 shares
infosys.setPrice(1600.0); // triggers both observers
infosys.setPrice(1580.0); // triggers both observersStrategy Pattern
Defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime without changing the client.
- ✓Strategy replaces conditional branches with polymorphism — each branch becomes a class.
- ✓The Context delegates to the Strategy — it does not know or care which strategy is active.
- ✓Prefer @FunctionalInterface strategies; pass lambdas instead of creating concrete classes.
- ✓Java Comparator is the canonical Strategy — Comparator.comparing() composes strategies.
- ✓Strategy (behavioral) vs Bridge (structural): Strategy swaps algorithms; Bridge separates abstraction from implementation.
// Strategy interface
public interface PaymentStrategy {
boolean pay(double amount);
String methodName();
}
// Concrete Strategies
public class CreditCardStrategy implements PaymentStrategy {
private final String cardNumber;
private final String cvv;
public CreditCardStrategy(String cardNumber, String cvv) {
this.cardNumber = cardNumber;
this.cvv = cvv;
}
@Override
public boolean pay(double amount) {
System.out.printf("Paid ₹%.2f via Credit Card ending %s%n",
amount, cardNumber.substring(cardNumber.length() - 4));
return true;
}
@Override public String methodName() { return "CREDIT_CARD"; }
}
public class UpiStrategy implements PaymentStrategy {
private final String upiId;
public UpiStrategy(String upiId) { this.upiId = upiId; }
@Override
public boolean pay(double amount) {
System.out.printf("Paid ₹%.2f via UPI ID: %s%n", amount, upiId);
return true;
}
@Override public String methodName() { return "UPI"; }
}
public class WalletStrategy implements PaymentStrategy {
private double balance;
public WalletStrategy(double balance) { this.balance = balance; }
@Override
public boolean pay(double amount) {
if (balance < amount) {
System.out.println("Insufficient wallet balance");
return false;
}
balance -= amount;
System.out.printf("Paid ₹%.2f via Wallet. Remaining: ₹%.2f%n", amount, balance);
return true;
}
@Override public String methodName() { return "WALLET"; }
}
// Context
public class CheckoutContext {
private PaymentStrategy strategy;
public void setStrategy(PaymentStrategy strategy) {
this.strategy = Objects.requireNonNull(strategy);
}
public boolean checkout(double amount) {
if (strategy == null) throw new IllegalStateException("No payment strategy set");
System.out.println("Attempting payment via: " + strategy.methodName());
return strategy.pay(amount);
}
}
// Usage — strategy swapped at runtime
CheckoutContext ctx = new CheckoutContext();
ctx.setStrategy(new UpiStrategy("akshay@okaxis"));
ctx.checkout(999.0);
ctx.setStrategy(new WalletStrategy(500.0));
ctx.checkout(999.0); // fails — insufficient balance
ctx.setStrategy(new CreditCardStrategy("4111111111111234", "123"));
ctx.checkout(999.0); // succeedsCommand Pattern
Encapsulates a request as an object, enabling parameterization, queuing, logging, and undo/redo of operations.
- ✓Command encapsulates a request as an object — enables queuing, logging, and undo/redo.
- ✓execute() performs the action; undo() reverses it. The deleted/old state must be saved in execute().
- ✓Invoker knows nothing about the command receiver — it only calls execute()/undo().
- ✓Command pattern is used in Java Swing (AbstractAction), Spring Batch (Job/Step), and transactional outbox.
- ✓A MacroCommand is a Composite of Commands — execute() calls execute() on each child.
import java.util.ArrayDeque;
import java.util.Deque;
// Command interface
public interface Command {
void execute();
void undo();
String description();
}
// Receiver
public class TextEditor {
private final StringBuilder text = new StringBuilder();
public void insertText(int position, String s) {
text.insert(position, s);
}
public void deleteText(int position, int length) {
text.delete(position, position + length);
}
public String getText() { return text.toString(); }
}
// Concrete Command — insert
public class InsertCommand implements Command {
private final TextEditor editor;
private final int position;
private final String text;
public InsertCommand(TextEditor editor, int position, String text) {
this.editor = editor;
this.position = position;
this.text = text;
}
@Override public void execute() { editor.insertText(position, text); }
@Override public void undo() { editor.deleteText(position, text.length()); }
@Override public String description() { return "Insert '" + text + "' at " + position; }
}
// Concrete Command — delete
public class DeleteCommand implements Command {
private final TextEditor editor;
private final int position;
private final int length;
private String deleted; // saved for undo
public DeleteCommand(TextEditor editor, int position, int length) {
this.editor = editor;
this.position = position;
this.length = length;
}
@Override
public void execute() {
deleted = editor.getText().substring(position, position + length);
editor.deleteText(position, length);
}
@Override public void undo() { editor.insertText(position, deleted); }
@Override public String description() { return "Delete " + length + " chars at " + position; }
}
// Invoker — manages history
public class CommandInvoker {
private final Deque<Command> history = new ArrayDeque<>();
private final Deque<Command> undone = new ArrayDeque<>();
public void execute(Command cmd) {
cmd.execute();
history.push(cmd);
undone.clear(); // new command clears redo stack
System.out.println("Executed: " + cmd.description());
}
public void undo() {
if (history.isEmpty()) { System.out.println("Nothing to undo"); return; }
Command cmd = history.pop();
cmd.undo();
undone.push(cmd);
System.out.println("Undone: " + cmd.description());
}
public void redo() {
if (undone.isEmpty()) { System.out.println("Nothing to redo"); return; }
Command cmd = undone.pop();
cmd.execute();
history.push(cmd);
System.out.println("Redone: " + cmd.description());
}
}
// Usage
TextEditor editor = new TextEditor();
CommandInvoker ctrl = new CommandInvoker();
ctrl.execute(new InsertCommand(editor, 0, "Hello"));
ctrl.execute(new InsertCommand(editor, 5, " World"));
System.out.println(editor.getText()); // Hello World
ctrl.undo();
System.out.println(editor.getText()); // Hello
ctrl.redo();
System.out.println(editor.getText()); // Hello WorldIterator Pattern
Provides a way to sequentially access elements of a collection without exposing its underlying representation.
- ✓Implementing Iterable<T> allows use in enhanced for-each and java.util.stream.StreamSupport.
- ✓Always throw NoSuchElementException (not null) when next() is called beyond the last element.
- ✓Iterator state is per-iterator — multiple iterators can traverse the same collection concurrently.
- ✓ConcurrentModificationException is thrown when a collection is modified during iteration; use CopyOnWriteArrayList or ListIterator.remove() instead.
- ✓Java Streams are lazy iterators that compose operations without materializing intermediate collections.
import java.util.Iterator;
import java.util.NoSuchElementException;
// Custom collection: a range of integers
public class IntRange implements Iterable<Integer> {
private final int start;
private final int end; // exclusive
public IntRange(int start, int end) {
if (start > end) throw new IllegalArgumentException("start must be <= end");
this.start = start;
this.end = end;
}
@Override
public Iterator<Integer> iterator() {
return new RangeIterator();
}
// Inner class iterator — has access to start/end
private class RangeIterator implements Iterator<Integer> {
private int current = start;
@Override
public boolean hasNext() { return current < end; }
@Override
public Integer next() {
if (!hasNext()) throw new NoSuchElementException();
return current++;
}
}
}
// Usage — works in for-each
IntRange range = new IntRange(1, 6);
for (int n : range) {
System.out.print(n + " "); // 1 2 3 4 5
}
// Reverse iterator for a list
public class ReverseListIterator<T> implements Iterator<T> {
private final List<T> list;
private int index;
public ReverseListIterator(List<T> list) {
this.list = list;
this.index = list.size() - 1;
}
@Override public boolean hasNext() { return index >= 0; }
@Override
public T next() {
if (!hasNext()) throw new NoSuchElementException();
return list.get(index--);
}
}
List<String> names = List.of("Alice", "Bob", "Charlie");
Iterator<String> rev = new ReverseListIterator<>(names);
while (rev.hasNext()) System.out.print(rev.next() + " "); // Charlie Bob AliceTemplate Method Pattern
Defines the skeleton of an algorithm in a base class, deferring specific steps to subclasses without changing the algorithm's structure.
- ✓Template method is final — subclasses cannot reorder the algorithm steps.
- ✓Abstract steps are mandatory; hook methods are optional with default behavior.
- ✓Template Method uses inheritance (compile-time); Strategy uses composition (runtime-swappable).
- ✓Spring JdbcTemplate, RestTemplate, and AbstractBeanFactory use Template Method extensively.
- ✓The Hollywood Principle: base class calls subclass methods — inversion of control at the class level.
// Abstract class with template method
public abstract class DataProcessor {
// Template method — final: defines the algorithm skeleton
public final void process(String dataSource) {
readData(dataSource); // abstract — must implement
validateData(); // abstract — must implement
if (shouldTransform()) { // hook — optional override
transformData();
}
writeData(); // abstract — must implement
onComplete(); // hook — optional override
}
protected abstract void readData(String source);
protected abstract void validateData();
protected abstract void writeData();
// Hook — default: transform is enabled
protected boolean shouldTransform() { return true; }
// Hook — default: no-op
protected void transformData() {}
// Hook — default: no-op
protected void onComplete() {}
}
// Concrete class: CSV to database
public class CsvToDatabaseProcessor extends DataProcessor {
private List<String[]> rows;
@Override
protected void readData(String source) {
System.out.println("Reading CSV from: " + source);
rows = List.of(new String[]{"Alice","25"}, new String[]{"Bob","30"});
}
@Override
protected void validateData() {
rows.forEach(row -> {
if (row.length != 2) throw new IllegalStateException("Invalid row format");
});
System.out.println("CSV validated: " + rows.size() + " rows");
}
@Override
protected void writeData() {
System.out.println("Writing " + rows.size() + " rows to database");
}
@Override
protected void onComplete() {
System.out.println("CSV processing complete. Sending notification.");
}
}
// Concrete class: JSON — no transformation needed
public class JsonProcessor extends DataProcessor {
@Override
protected void readData(String source) { System.out.println("Reading JSON: " + source); }
@Override
protected void validateData() { System.out.println("Validating JSON schema"); }
@Override
protected void writeData() { System.out.println("Indexing JSON to Elasticsearch"); }
@Override
protected boolean shouldTransform() { return false; } // skip transform step
}
// Client
new CsvToDatabaseProcessor().process("students.csv");
new JsonProcessor().process("courses.json");State Pattern
Allows an object to alter its behavior when its internal state changes, appearing to change its class.
- ✓State eliminates if-else/switch chains on status fields — each state is a class.
- ✓Context delegates all operations to the current State; State transitions by replacing the state object.
- ✓State classes can reference the Context to trigger transitions (state.dispense() calls machine.setState(...)).
- ✓Invalid operations in a state are handled locally (throw or silently ignore) — no scattered null checks.
- ✓Order lifecycle (PENDING→CONFIRMED→SHIPPED→DELIVERED) and TCP connection are real-world State machines.
// State interface
public interface VendingMachineState {
void insertCoin(VendingMachine machine, double amount);
void selectProduct(VendingMachine machine, String product);
void dispense(VendingMachine machine);
void refund(VendingMachine machine);
}
// Context
public class VendingMachine {
private VendingMachineState state;
private double balance = 0;
private int stockCount = 10;
public VendingMachine() {
this.state = new IdleState();
}
public void setState(VendingMachineState state) { this.state = state; }
public double getBalance() { return balance; }
public void setBalance(double b) { this.balance = b; }
public int getStock() { return stockCount; }
public void decrementStock() { stockCount--; }
// Delegates all behavior to current state
public void insertCoin(double amount) { state.insertCoin(this, amount); }
public void selectProduct(String product) { state.selectProduct(this, product); }
public void dispense() { state.dispense(this); }
public void refund() { state.refund(this); }
}
// Concrete States
public class IdleState implements VendingMachineState {
@Override
public void insertCoin(VendingMachine m, double amount) {
m.setBalance(m.getBalance() + amount);
System.out.println("Coin inserted: ₹" + amount + ". Balance: ₹" + m.getBalance());
m.setState(new HasMoneyState()); // transition
}
@Override public void selectProduct(VendingMachine m, String p) { System.out.println("Please insert coin first"); }
@Override public void dispense(VendingMachine m) { System.out.println("Please insert coin first"); }
@Override public void refund(VendingMachine m) { System.out.println("No money to refund"); }
}
public class HasMoneyState implements VendingMachineState {
private String selectedProduct;
@Override
public void insertCoin(VendingMachine m, double amount) {
m.setBalance(m.getBalance() + amount);
System.out.println("Added ₹" + amount + ". Total: ₹" + m.getBalance());
}
@Override
public void selectProduct(VendingMachine m, String product) {
double price = 20.0; // simplified
if (m.getBalance() >= price) {
this.selectedProduct = product;
System.out.println("Selected: " + product);
m.setState(new DispensingState(product, price));
} else {
System.out.println("Insufficient balance. Need ₹" + price);
}
}
@Override public void dispense(VendingMachine m) { System.out.println("Please select a product first"); }
@Override
public void refund(VendingMachine m) {
System.out.println("Refunding ₹" + m.getBalance());
m.setBalance(0);
m.setState(new IdleState());
}
}
public class DispensingState implements VendingMachineState {
private final String product;
private final double price;
public DispensingState(String product, double price) {
this.product = product; this.price = price;
}
@Override
public void dispense(VendingMachine m) {
System.out.println("Dispensing: " + product);
m.setBalance(m.getBalance() - price);
m.decrementStock();
if (m.getBalance() > 0) System.out.println("Change: ₹" + m.getBalance());
m.setBalance(0);
m.setState(m.getStock() > 0 ? new IdleState() : new OutOfStockState());
}
@Override public void insertCoin(VendingMachine m, double a) { System.out.println("Dispensing in progress"); }
@Override public void selectProduct(VendingMachine m, String p) { System.out.println("Dispensing in progress"); }
@Override public void refund(VendingMachine m) { System.out.println("Cannot refund while dispensing"); }
}
public class OutOfStockState implements VendingMachineState {
@Override public void insertCoin(VendingMachine m, double a) { System.out.println("Out of stock — coin returned"); }
@Override public void selectProduct(VendingMachine m, String p) { System.out.println("Out of stock"); }
@Override public void dispense(VendingMachine m) { System.out.println("Out of stock"); }
@Override public void refund(VendingMachine m) { System.out.println("No money inserted"); }
}
// Usage
VendingMachine vm = new VendingMachine();
vm.insertCoin(20.0);
vm.selectProduct("Water");
vm.dispense();Chain of Responsibility
Passes a request along a chain of handlers, each deciding to process it or forward it to the next handler.
- ✓CoR decouples sender from receiver — the sender does not know which handler processes the request.
- ✓Classic CoR stops at the first handler that processes; Filter Chain always forwards unless short-circuited.
- ✓Handlers can be added/removed/reordered at runtime without changing client code.
- ✓Spring Security Filter Chain processes authentication/authorization through a fixed sequence of filters.
- ✓Debugging long chains is hard — consider adding logging at each handler for traceability.
public enum LogLevel { DEBUG, INFO, WARN, ERROR }
// Abstract Handler
public abstract class Logger {
protected final LogLevel level;
protected Logger next;
public Logger(LogLevel level) { this.level = level; }
public Logger setNext(Logger next) {
this.next = next;
return next;
}
public final void log(LogLevel msgLevel, String message) {
if (msgLevel.ordinal() >= this.level.ordinal()) {
write(message); // this handler can process it
}
if (next != null) {
next.log(msgLevel, message); // always forward (unlike classic CoR stop)
}
}
protected abstract void write(String message);
}
// Concrete Handlers
public class ConsoleLogger extends Logger {
public ConsoleLogger(LogLevel level) { super(level); }
@Override protected void write(String msg) {
System.out.println("[CONSOLE] " + msg);
}
}
public class FileLogger extends Logger {
public FileLogger(LogLevel level) { super(level); }
@Override protected void write(String msg) {
System.out.println("[FILE] " + msg); // writes to rotating log file
}
}
public class AlertLogger extends Logger {
public AlertLogger(LogLevel level) { super(level); }
@Override protected void write(String msg) {
System.out.println("[ALERT] " + msg); // sends PagerDuty alert
}
}
// Build chain: Console handles DEBUG+, File handles WARN+, Alert handles ERROR+
Logger chain = new ConsoleLogger(LogLevel.DEBUG);
chain.setNext(new FileLogger(LogLevel.WARN))
.setNext(new AlertLogger(LogLevel.ERROR));
chain.log(LogLevel.DEBUG, "Starting application"); // Console only
chain.log(LogLevel.WARN, "High memory usage"); // Console + File
chain.log(LogLevel.ERROR, "Database unreachable"); // Console + File + AlertMediator Pattern
Defines an object that encapsulates how a set of objects interact, promoting loose coupling by preventing direct references between them.
- ✓Mediator reduces O(N²) peer-to-peer references to O(N) hub-and-spoke references.
- ✓Colleagues hold only a reference to the Mediator, never to each other.
- ✓The Mediator can become a "god object" anti-pattern if it takes on too much logic — keep it thin.
- ✓MediatR (C#) and Spring's ApplicationEventPublisher are Mediator implementations.
- ✓Difference from Facade: Facade simplifies a subsystem for external clients; Mediator coordinates objects within a subsystem.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// Mediator interface
public interface ChatMediator {
void register(User user);
void sendMessage(String message, String fromUserId, String toUserId); // DM
void broadcast(String message, String fromUserId); // group
}
// Colleague
public class User {
private final String id;
private final String name;
private final ChatMediator mediator;
public User(String id, String name, ChatMediator mediator) {
this.id = id;
this.name = name;
this.mediator = mediator;
mediator.register(this); // self-register
}
public String getId() { return id; }
public String getName() { return name; }
public void send(String message, String toUserId) {
mediator.sendMessage(message, this.id, toUserId);
}
public void broadcast(String message) {
mediator.broadcast(message, this.id);
}
// Called by mediator when a message is delivered here
public void receive(String message, String fromName) {
System.out.printf("[%s] ← %s: %s%n", this.name, fromName, message);
}
}
// Concrete Mediator — ChatRoom
public class ChatRoom implements ChatMediator {
private final Map<String, User> users = new ConcurrentHashMap<>();
@Override
public void register(User user) {
users.put(user.getId(), user);
System.out.println(user.getName() + " joined the chat");
}
@Override
public void sendMessage(String message, String fromId, String toId) {
User sender = users.get(fromId);
User recipient = users.get(toId);
if (sender == null || recipient == null) return;
recipient.receive(message, sender.getName()); // mediator routes
}
@Override
public void broadcast(String message, String fromId) {
User sender = users.get(fromId);
if (sender == null) return;
users.values().stream()
.filter(u -> !u.getId().equals(fromId))
.forEach(u -> u.receive(message, sender.getName()));
}
}
// Usage — users never hold references to each other
ChatMediator room = new ChatRoom();
User alice = new User("u1", "Alice", room);
User bob = new User("u2", "Bob", room);
User carol = new User("u3", "Carol", room);
alice.send("Hey Bob, saw the LLD course?", "u2"); // DM to Bob
bob.broadcast("Everyone check aicancode.org!"); // to Alice + CarolMemento Pattern
Captures and externalizes an object's internal state so it can be restored later, without violating encapsulation.
- ✓Memento preserves encapsulation — the Caretaker stores but never reads the Memento's state.
- ✓Use a private inner Memento class inside the Originator to restrict access to state fields.
- ✓Memory cost: each Memento stores a full state snapshot — use incremental/delta mementos for large objects.
- ✓Java serialization is an alternative Memento implementation for complex object graphs.
- ✓Undo history = stack of Mementos (push on save, pop on undo).
import java.util.ArrayDeque;
import java.util.Deque;
// Originator — the object whose state we want to save/restore
public class GameCharacter {
private String name;
private int health;
private int level;
private String location;
public GameCharacter(String name) {
this.name = name;
this.health = 100;
this.level = 1;
this.location = "start";
}
// Create a snapshot (Memento)
public Memento save() {
return new Memento(health, level, location);
}
// Restore from a snapshot
public void restore(Memento memento) {
this.health = memento.health;
this.level = memento.level;
this.location = memento.location;
System.out.println(name + " restored to: " + this);
}
public void takeDamage(int dmg) { health = Math.max(0, health - dmg); }
public void gainLevel() { level++; health = 100; }
public void moveTo(String loc) { location = loc; }
@Override
public String toString() {
return String.format("HP=%d, Level=%d, Loc=%s", health, level, location);
}
// Memento — inner class has access to private state
// Caretaker only sees the opaque Memento type, not its fields
public static final class Memento {
private final int health; // private — Caretaker cannot read
private final int level;
private final String location;
private Memento(int health, int level, String location) {
this.health = health;
this.level = level;
this.location = location;
}
}
}
// Caretaker — manages history, never inspects Memento internals
public class GameSaveManager {
private final Deque<GameCharacter.Memento> history = new ArrayDeque<>();
public void save(GameCharacter character) {
history.push(character.save());
System.out.println("Game saved (" + history.size() + " saves)");
}
public void undo(GameCharacter character) {
if (history.isEmpty()) { System.out.println("No saves to restore"); return; }
character.restore(history.pop());
}
}
// Usage
GameCharacter hero = new GameCharacter("Akshay");
GameSaveManager saves = new GameSaveManager();
System.out.println("Initial: " + hero);
saves.save(hero); // save: HP=100, L=1
hero.moveTo("dungeon");
hero.takeDamage(60);
System.out.println("After fight: " + hero); // HP=40, L=1, dungeon
saves.save(hero); // save: HP=40, L=1, dungeon
hero.gainLevel();
hero.moveTo("castle");
System.out.println("After level-up: " + hero); // HP=100, L=2, castle
saves.undo(hero); // restore: HP=40, L=1, dungeon
saves.undo(hero); // restore: HP=100, L=1, startVisitor Pattern
Lets you add new operations to an object structure without modifying the classes, using double dispatch.
- ✓Double dispatch: accept(visitor) calls visitor.visit(this) — both element type and visitor type are resolved at runtime.
- ✓Adding a new operation = new Visitor class (OCP). Adding a new element = update all Visitors (OCP violation).
- ✓Best for stable element hierarchies (e.g. AST nodes) with frequently added operations.
- ✓Java's instanceof pattern matching (Java 16+) and sealed classes can replace Visitor in some cases.
- ✓Visitor breaks encapsulation slightly — Visitor methods need access to element internals.
// Visitor interface — one method per element type
public interface ExpressionVisitor<T> {
T visitNumber(NumberExpr expr);
T visitAdd(AddExpr expr);
T visitMultiply(MultiplyExpr expr);
}
// Element interface
public interface Expression {
<T> T accept(ExpressionVisitor<T> visitor);
}
// Concrete Elements
public class NumberExpr implements Expression {
public final double value;
public NumberExpr(double value) { this.value = value; }
@Override
public <T> T accept(ExpressionVisitor<T> visitor) {
return visitor.visitNumber(this); // double dispatch
}
}
public class AddExpr implements Expression {
public final Expression left, right;
public AddExpr(Expression left, Expression right) {
this.left = left; this.right = right;
}
@Override
public <T> T accept(ExpressionVisitor<T> visitor) {
return visitor.visitAdd(this);
}
}
public class MultiplyExpr implements Expression {
public final Expression left, right;
public MultiplyExpr(Expression left, Expression right) {
this.left = left; this.right = right;
}
@Override
public <T> T accept(ExpressionVisitor<T> visitor) {
return visitor.visitMultiply(this);
}
}
// Visitor 1: Evaluate
public class EvaluateVisitor implements ExpressionVisitor<Double> {
@Override public Double visitNumber(NumberExpr e) { return e.value; }
@Override public Double visitAdd(AddExpr e) { return e.left.accept(this) + e.right.accept(this); }
@Override public Double visitMultiply(MultiplyExpr e) { return e.left.accept(this) * e.right.accept(this); }
}
// Visitor 2: Pretty Print
public class PrintVisitor implements ExpressionVisitor<String> {
@Override public String visitNumber(NumberExpr e) { return String.valueOf(e.value); }
@Override public String visitAdd(AddExpr e) { return "(" + e.left.accept(this) + " + " + e.right.accept(this) + ")"; }
@Override public String visitMultiply(MultiplyExpr e) { return "(" + e.left.accept(this) + " * " + e.right.accept(this) + ")"; }
}
// Usage — (3 + 4) * 2
Expression ast = new MultiplyExpr(
new AddExpr(new NumberExpr(3), new NumberExpr(4)),
new NumberExpr(2));
System.out.println(ast.accept(new PrintVisitor())); // ((3.0 + 4.0) * 2.0)
System.out.println(ast.accept(new EvaluateVisitor())); // 14.0