Overview
Design patterns are solutions to recurring design problems — not templates to force into code. The most dangerous misapplication is using a pattern when a simpler solution exists. Modern Java (lambdas, records, sealed classes) lets you implement many GoF patterns with far less ceremony than the original Java 2-era examples.
Architecture
PATTERN TAXONOMY
════════════════════════════════════════════════════════
CREATIONAL (how objects are created)
├── Factory Method — subclass decides what to instantiate
├── Abstract Factory — family of related objects
├── Builder — step-by-step complex object construction
└── Singleton — one instance (prefer Spring-managed beans)
STRUCTURAL (how objects are composed)
├── Decorator — wrap to add behavior (Java I/O, Spring interceptors)
├── Adapter — incompatible interface bridge
├── Proxy — control access (Spring AOP, lazy loading)
└── Composite — tree structures (UI component trees)
BEHAVIORAL (how objects communicate)
├── Strategy — swap algorithm at runtime (sorting, pricing)
├── Observer — event subscription (EventBus, reactive streams)
├── Command — encapsulate request as object (undo/redo, queuing)
├── Template Method — skeleton algorithm, subclasses fill gaps
└── Chain of Responsibility — pipeline (servlet filters, middleware)
DECORATOR LAYERING (Java I/O)
════════════════════════════════════════════════════════
FileInputStream (base — reads bytes)
│ wrapped by
BufferedInputStream (adds buffering)
│ wrapped by
GZIPInputStream (adds decompression)
│ wrapped by
InputStreamReader (bytes → chars)
│ wrapped by
BufferedReader (adds readLine())
Each layer adds behavior without modifying the base class.
Runtime composition: plug in any combination.
Technical Implementation
Strategy via Functional Interfaces (Modern Java)
// Strategy = a function. In Java 8+, use @FunctionalInterface.
// No need for a separate interface hierarchy per strategy.
@FunctionalInterface
public interface PricingStrategy {
BigDecimal apply(BigDecimal basePrice, Customer customer);
}
@Service
public class PricingService {
// Strategies defined as lambdas — no ceremony
private static final PricingStrategy STANDARD = (p, c) -> p;
private static final PricingStrategy PREMIUM = (p, c) -> p.multiply(BigDecimal.valueOf(0.85));
private static final PricingStrategy EMPLOYEE = (p, c) -> p.multiply(BigDecimal.valueOf(0.60));
private static final PricingStrategy BULK =
(p, c) -> c.getOrderCount() > 100 ? p.multiply(BigDecimal.valueOf(0.90)) : p;
private final Map<CustomerTier, PricingStrategy> strategies = Map.of(
CustomerTier.STANDARD, STANDARD,
CustomerTier.PREMIUM, PREMIUM,
CustomerTier.EMPLOYEE, EMPLOYEE,
CustomerTier.BULK, BULK
);
public BigDecimal getPrice(Product product, Customer customer) {
PricingStrategy strategy = strategies.getOrDefault(customer.getTier(), STANDARD);
return strategy.apply(product.getBasePrice(), customer);
}
}
Builder Pattern for Immutable Objects
// Modern Java: use @Builder from Lombok, or hand-write for full control
public final class HttpRequest {
private final String method;
private final String url;
private final Map<String, String> headers;
private final byte[] body;
private final Duration timeout;
private HttpRequest(Builder b) {
this.method = Objects.requireNonNull(b.method, "method required");
this.url = Objects.requireNonNull(b.url, "url required");
this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(b.headers));
this.body = b.body != null ? b.body.clone() : new byte[0];
this.timeout = b.timeout != null ? b.timeout : Duration.ofSeconds(30);
}
public static Builder get(String url) { return new Builder("GET", url); }
public static Builder post(String url) { return new Builder("POST", url); }
public static final class Builder {
private final String method;
private final String url;
private final Map<String, String> headers = new LinkedHashMap<>();
private byte[] body;
private Duration timeout;
private Builder(String method, String url) {
this.method = method;
this.url = url;
}
public Builder header(String name, String value) { headers.put(name, value); return this; }
public Builder body(byte[] body) { this.body = body; return this; }
public Builder contentType(String ct) { return header("Content-Type", ct); }
public Builder timeout(Duration t) { this.timeout = t; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
// Usage — fluent, readable, immutable result
HttpRequest request = HttpRequest.post("https://api.example.com/orders")
.header("Authorization", "Bearer " + token)
.contentType("application/json")
.body(json.getBytes(UTF_8))
.timeout(Duration.ofSeconds(10))
.build();
Observer with a Type-Safe Event Bus
// Type-safe event bus using generics — no casting, no string-based routing
public class EventBus {
private final Map<Class<?>, List<Consumer<Object>>> handlers = new ConcurrentHashMap<>();
private final Executor executor;
public EventBus(Executor executor) { this.executor = executor; }
@SuppressWarnings("unchecked")
public <T> void subscribe(Class<T> eventType, Consumer<T> handler) {
handlers.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>())
.add((Consumer<Object>) handler);
}
public <T> void publish(T event) {
List<Consumer<Object>> eventHandlers = handlers.get(event.getClass());
if (eventHandlers != null) {
eventHandlers.forEach(h -> executor.execute(() -> h.accept(event)));
}
}
}
// Usage
EventBus bus = new EventBus(Executors.newVirtualThreadPerTaskExecutor());
bus.subscribe(OrderPlaced.class, event -> {
log.info("Order placed: {}", event.orderId());
inventoryService.reserve(event.items());
});
bus.subscribe(OrderPlaced.class, event ->
notificationService.send(event.userId(), "Your order is confirmed!")
);
bus.publish(new OrderPlaced(orderId, userId, items));
Interview Preparation
Q: When would you choose Decorator over Inheritance for adding behavior?
Inheritance adds behavior statically at compile time and to all instances of a class. Decorator adds behavior dynamically at runtime to specific instances, in any combination. The classic failure of inheritance: if you have Coffee, MilkCoffee, SugarCoffee, and MilkSugarCoffee, you get 2^n subclasses for n add-ons. Decorator gives you new SugarDecorator(new MilkDecorator(new Coffee())) — any combination without a class explosion. Use Decorator when: you need to add responsibilities to objects at runtime, combinations would require many subclasses, or you can't modify the base class (third-party code). Java's I/O stream hierarchy is the canonical example.
Q: How is Strategy different from Template Method?
Template Method uses inheritance: a base class defines the skeleton algorithm with abstract "hook" methods that subclasses fill in. The algorithm structure is fixed in the base class. Strategy uses composition: the algorithm is encapsulated in a separate object passed to (or injected into) the context. Strategy is more flexible — you can swap algorithms at runtime, test strategies independently, and avoid a subclass hierarchy. In modern Java, Strategy via @FunctionalInterface + lambda is usually preferred over Template Method because it avoids inheritance and enables anonymous, inline implementations.
Q: Why is Singleton considered an anti-pattern in some contexts?
The classic static Singleton (getInstance()) creates hidden global state, makes unit testing nearly impossible (tests share the same instance, side effects from one test leak into the next), and complicates dependency injection. In a Spring application, all Spring beans are singletons by default — but they're managed by the container, injectable, mockable, and replaceable. The problem with Singleton isn't one-instance semantics; it's the static access pattern. Prefer dependency injection over static getInstance. The pattern is problematic; the concept (one shared instance per context) is not.
Q: How does the Command pattern enable undo/redo?
Each Command encapsulates both execute() and undo() operations. A command history stack stores executed commands. Redo: push a new command onto the stack, call execute(). Undo: pop the top command, call undo(). For a text editor: InsertCommand stores the position and text; undo() deletes those characters. DeleteCommand stores the deleted text; undo() reinserts it. This also enables macro recording (store a list of commands and replay), and transactional behavior (undo all commands if any step fails).
Learning Resources
- Design Patterns: Elements of Reusable Object-Oriented Software — GoF (Gamma, Helm, Johnson, Vlissides)
- Head First Design Patterns — Freeman & Robson — visual explanations
- Effective Java — Bloch, Items 1, 2, 4 (creational patterns)
- Refactoring.Guru — modern Java examples