GoF Patterns: Alive, Diminished, and Dead in Java 21¶
The Design Patterns book (Gamma, Helm, Johnson, Vlissides, 1994) shaped a generation. But it was written for a language without generics, without lambdas, without records, without pattern matching. Java 21 has all four. Some patterns are as relevant as ever; some have been quietly killed by language features you already use. Knowing which is which is a mark of seniority.
The Verdict Table¶
Pattern |
Status in Java 21 |
Why |
|---|---|---|
Strategy |
Alive & thriving |
Now expressed as a lambda in one line |
Factory (Method / Abstract) |
Alive |
Still the right answer for polymorphic construction |
Builder |
Alive |
Records help but do not replace it for many-optional-fields |
Adapter |
Alive |
Integration is forever |
Decorator |
Alive but often invisible |
|
Observer |
Diminished |
Reactive streams / |
Template Method |
Alive but risky |
Composition often cleaner |
Composite |
Alive |
Trees are trees |
Iterator |
Absorbed into language |
|
Singleton |
Diminished |
Spring beans, DI containers make it noise |
Command |
Diminished |
|
Prototype |
Diminished |
Records give free copy semantics |
Visitor |
Effectively dead |
Sealed types + switch pattern matching killed it |
Memento |
Rarely needed |
Immutability + snapshots do this naturally |
Below, only the interesting ones. Read Head First Design Patterns or Refactoring.Guru for the full catalog.
STILL ALIVE¶
Strategy — the king of live patterns¶
Strategy encapsulates an algorithm behind an interface so it can be swapped at runtime. In Java 21 it is usually a single-method interface (or Function/Predicate) implemented by a lambda.
// Classical Strategy — unchanged in spirit
interface DiscountStrategy {
BigDecimal apply(Order order);
}
final class FlatTenPercent implements DiscountStrategy {
public BigDecimal apply(Order order) {
return order.subtotal().multiply(new BigDecimal("0.10"));
}
}
// Modern lambda form
DiscountStrategy freeShipping = order ->
order.subtotal().compareTo(new BigDecimal("1000")) > 0
? order.shippingCost()
: BigDecimal.ZERO;
Why it’s alive: every framework you use (Spring, Jackson, Hibernate) is riddled with strategy interfaces. It is the primary way OCP works in Java.
Factory (Method and Abstract)¶
When creation itself is polymorphic — different inputs must produce different subtypes — Factory earns its keep. Prefer static factory methods (Bloch Item 1) over public constructors for most cases.
public sealed interface Notification
permits EmailNotification, SmsNotification, PushNotification {}
public final class NotificationFactory {
public static Notification of(NotificationRequest req) {
return switch (req.channel()) {
case EMAIL -> new EmailNotification(req.to(), req.body());
case SMS -> new SmsNotification(req.to(), req.body());
case PUSH -> new PushNotification(req.deviceToken(), req.body());
};
}
}
Static factories give you named constructors (of, from, valueOf), can return cached instances, and can return subtypes.
Builder — records help but do not kill it¶
Records give you compact construction for value objects. But when you have 10+ fields, half optional, with validation between them, builders still win.
public record HttpRequest(URI uri, String method,
Map<String,String> headers, Duration timeout,
int retries, boolean followRedirects) {}
// This constructor call is unreadable:
new HttpRequest(uri, "POST", headers, Duration.ofSeconds(30), 3, true);
// A builder reads like prose:
HttpRequest req = HttpRequest.builder()
.uri(uri)
.method("POST")
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.retries(3)
.build();
Rule of thumb: ≤ 4 fields → record canonical constructor. 5–8 fields → record with static factory methods per common shape. > 8 fields or many-optional → builder.
Adapter¶
Two libraries you cannot change speak different vocabularies. You write a thin class translating between them. This will never die because integration is forever — Jackson↔Gson, JDBC↔JPA, REST↔SOAP.
class LegacyPaymentAdapter implements PaymentProcessor { // new API
private final LegacyPaymentGateway legacy; // old API
public PaymentResult charge(Money amount, Card card) {
var result = legacy.processPayment(
amount.toCents(), card.pan(), card.cvv());
return result.success()
? PaymentResult.ok(result.txnId())
: PaymentResult.failed(result.reason());
}
}
Decorator — you use this every day without knowing¶
InputStream wraps InputStream wraps InputStream:
try (var in = new BufferedInputStream(
new GZIPInputStream(
new FileInputStream("data.gz")))) { /* ... */ }
That is Decorator. Every intermediate stream operation is Decorator too:
List<String> result = list.stream()
.filter(s -> !s.isBlank()) // decorated stream
.map(String::toUpperCase) // decorated stream
.sorted() // decorated stream
.toList(); // terminal
You do not implement Decorator often — you consume it constantly.
DIMINISHED¶
Singleton — mostly noise now¶
The GoF Singleton solved “one instance globally” in a world without DI containers. Today, if you use Spring, every @Component is effectively a singleton managed for you with thread-safe lazy init, easy replacement in tests, and no double-checked-locking boilerplate.
// GoF-era Singleton — rarely needed today
public final class ConfigManager {
private static final ConfigManager INSTANCE = new ConfigManager();
private ConfigManager() {}
public static ConfigManager getInstance() { return INSTANCE; }
}
// Modern equivalent — let Spring manage it
@Component
public class ConfigManager { /* ... */ }
When you do need a hand-rolled singleton, use a single-element enum (Bloch Item 3):
public enum Configuration {
INSTANCE;
public String get(String key) { /* ... */ }
}
This is thread-safe, serialization-safe, and reflection-safe without any effort.
Command — lambdas ate this¶
The Command pattern wraps an action in an object so it can be queued, undone, logged. Java’s functional interfaces (Runnable, Consumer, Supplier) already are Command in a lighter form.
// GoF Command
interface Command { void execute(); }
class PrintCommand implements Command {
private final String text;
public PrintCommand(String text) { this.text = text; }
public void execute() { System.out.println(text); }
}
queue.add(new PrintCommand("hello"));
// Modern equivalent
queue.add(() -> System.out.println("hello")); // Runnable is Command
Command still matters when you need undo (each command captures inverse state) or persistence (serialize commands to a queue). Otherwise lambdas suffice.
Prototype — records replaced it for value data¶
Prototype copies a prototype object rather than constructing anew. For value objects, records give you copy-with-modification for free via constructor calls:
record Customer(String name, String email, Address address) {
Customer withEmail(String newEmail) {
return new Customer(name, newEmail, address);
}
}
Prototype still matters for genuinely expensive-to-construct mutable objects (graphs, caches). Rare.
Observer — usually better as reactive / event bus¶
The naive Observer (an Observable holding a List<Observer> and calling update()) has been discouraged since java.util.Observable was deprecated in Java 9. Modern alternatives:
java.util.concurrent.Flow— the built-in reactive streams APIProject Reactor / RxJava — industrial reactive libraries
Spring
ApplicationEventPublisher— in-process event busKafka / message brokers — cross-process events
Use the naive pattern only inside a single small component (a UI widget, a state machine you own end-to-end).
EFFECTIVELY DEAD¶
Visitor — killed by sealed + switch pattern matching¶
Visitor solved: “I want to add operations to a class hierarchy without editing the hierarchy.” You had a Node interface, subclasses Add, Mul, Lit, and each new operation (evaluate, print, optimize) meant a Visitor interface with a method per subclass.
// GoF Visitor — verbose and error-prone
interface Expr { <R> R accept(Visitor<R> v); }
interface Visitor<R> {
R visit(Lit l);
R visit(Add a);
R visit(Mul m);
}
class EvalVisitor implements Visitor<Integer> {
public Integer visit(Lit l) { return l.value(); }
public Integer visit(Add a) { return a.left().accept(this) + a.right().accept(this); }
public Integer visit(Mul m) { return m.left().accept(this) * m.right().accept(this); }
}
Java 21 replaces the entire ceremony with sealed types plus pattern-matching switch:
sealed interface Expr permits Lit, Add, Mul {}
record Lit(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
int eval(Expr e) {
return switch (e) {
case Lit(int v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
};
}
Same exhaustiveness guarantee (compiler enforces you cover every permits case), a fraction of the code, no accept/visit ping-pong. This is why Visitor is dead. Brian Goetz’s data-oriented programming articles argue this transition explicitly.
⚠️ What Most People Get Wrong¶
They memorize patterns as goals. Then every problem looks like a pattern-shaped nail and you get AbstractSingletonProxyFactoryBean-style hell. Patterns are vocabulary for post-hoc description, not blueprints for greenfield code. Write the simplest thing that works. If it starts to smell (many switch on type, duplicated construction logic, rigid coupling), reach for a pattern with intent. Never open a file and think “I will apply Decorator here.”
Also common: not noticing that idiomatic Java 21 code is a pattern-heavy pipeline. Every stream().filter().map().collect() chain is Iterator + Decorator + Strategy + Adapter working together. You are already fluent in patterns; you just have not named them.
What to Practice This Week¶
Take any codebase with 5+ Visitor implementations. Sketch the sealed + switch replacement. Note the line count delta.
Find a Singleton in legacy code. Replace with
enumsingleton or@Component. Note whether tests get easier.Write one Strategy interface with 3 implementations, and one lambda-based version of the same. Compare readability at the call site.
Skim Head First Design Patterns’ chapters on Strategy, Observer, Decorator, Factory, Command. Ignore the rest until you meet the problem.
Return to README.md · Previous: 02_solid_in_practice.md · Next: 04_records_sealed_pattern_matching.md