Die klassischen GoF-Design-Patterns haben über lange Zeit dabei geholfen, wartbare objektorientierte Software zu entwickeln. In den letzten Jahren haben sich Programmiersprachen wie Java jedoch stark verändert. Sie haben sich weiterentwickelt. Was früher ein Design Pattern war, ist heute oft ein direktes Sprach-Feature. Dieser Artikel analysiert, welche Patterns weiterhin relevant sind und welche durch moderne Java-Sprach-Feature obsolet geworden sind.
Table of Contents
Es war einmal …
Objekt-Orientierte Programmierung
Die objektorientierte Programmierung entstand mit der Entwicklung von Simula 67. Ole-Johan Dahl und Kristen Nygaard entwickelten Simula 67 am Norgwegian Computing Center in Oslo, um Simulationen durchzuführen. Einige Jahre später erfanden Alan Kay et al. Smalltalk als rein objektorientierte Programmiersprache. In den 1980er-Jahren brachte C++ den Durchbruch der objektorientierten Programmierung in den Mainstream, und die ersten großen objektorientierten Softwaresysteme entstanden. In den 1990er-Jahren war die objektorientierte Programmierung das dominierende Paradigma.
GoF-Design-Patterns
1994 veröffentlichten Erich Gamma, Richard Helm, Ralph Johnson und John Vlissides, bekannt als die Gang of Four (GoF), das Buch „Design Patterns: Elements of Reusable Object-Oriented Software“. Die darin beschriebenen 23 Design Patterns wurden äußerst populär und prägten nachhaltig das Verständnis von objektorientiertem Design. Über viele Jahre hinweg halfen sie unzähligen Entwicklerinnen und Entwicklern, wartbare Software zu bauen.
Java
Mitte der 1990er-Jahre geschah jedoch noch etwas anderes: 1995 erschien die erste Version von Java. Java war als einfache, aber robuste objektorientierte Programmiersprache geplant, angelehnt an C++. Java ist klassenbasiert, das heißt, das Verhalten eines Objekts wird durch seine Klasse beschrieben. Jedes Objekt kann einen individuellen Zustand haben, aber das Verhalten aller Objekte derselben Klasse ist gleich.
Java unterstützt Vererbung zur Bildung von Hierarchien und zum Erweitern oder Überschreiben des Verhaltens von Superklassen. Zur Entkopplung von Methodensignaturen und der dazugehörigen Implementierungen bietet Java das Konzept der Interfaces. Ein Interface definiert Methodensignaturen ohne Implementierung. Alle implementierenden Klassen müssen diese Methoden bereitstellen. Zusätzlich können in Java-Klassen statische Methoden, Variablen und Konstanten definiert werden, um klassenweites Verhalten und Zustände abzubilden. Mit diesen Sprach-Features lassen sich alle GoF-Patterns umsetzen.
30 Jahre Später
In den letzten 30 Jahren hat sich jedoch vieles verändert: Weitere Programmierparadigmen wurden populär, neue Frameworks und Bibliotheken entstanden, und Java entwickelte sich durch zahlreiche neue Sprach-Features weiter. Daher ist es an der Zeit, die GoF-Patterns neu zu betrachten und zu bewerten, welche noch notwendig sind und welche durch moderne Sprach-Features ersetzt wurden.
Die GoF Design Patterns
Die 23 GoF-Design-Patterns adressieren unterschiedliche Aspekte des objektorientierten Designs. Um sie besser verständlich und anwendbar zu machen, werden sie üblicherweise in drei Kategorien eingeteilt: Erzeugungsmuster (Creational Patterns), Strukturmuster (Structural Patterns) und Verhaltensmuster (Behavioral Patterns). Diese Einteilung hilft dabei, für ein gegebenes Problem das passende Pattern zu finden.
Erzeugungsmuster beschäftigen sich mit verschiedenen Arten der Objekterzeugung. Strukturmuster beschreiben, wie Klassen und Objekte zu größeren Strukturen zusammengesetzt werden. Verhaltensmuster definieren, wie Objekte miteinander interagieren und wie Verantwortlichkeiten verteilt werden, oft mit der Möglichkeit, Verhalten zur Laufzeit zu variieren.
Da die GoF-Design-Patterns allgemein bekannt sind, werden sie in diesem Artikel nicht im Detail beschrieben. Für weiterführende Informationen sei auf das Buch von Gamma et al., refactoring.guru/design-patterns oder ähnliche Ressourcen verwiesen.
Wie sich Java weiterentwickelt hat
Java hat sich über die Jahre kontinuierlich weiterentwickelt, stets mit dem Ziel, moderne Sprach-Features einzuführen und gleichzeitig abwärtskompatibel zu bleiben. Einen entscheidenden Wendepunkt stellte Java 8 mit der Einführung von Lambdas und Method References dar. Diese Features ermöglichen einen stärker funktionalen Programmierstil, bei dem Verhalten als Daten übergeben werden kann, und reduzierten den notwendigen Boilerplate-Code erheblich.
Eng damit verbunden wurde auch die Stream API eingeführt, die eine deklarative Verarbeitung von Collections erlaubt und Parallelisierung unterstützt. In Java 24 wurde die Stream API weiter ausgebaut: Mit dem Gatherer-Interface lassen sich nun auch komplexe Zwischenoperationen während der Stream-Verarbeitung definieren.
Neue Java-Versionen konzentrierten sich weiterhin auf Ausdrucksstärke und Wartbarkeit. Records, eingeführt in Java 16, bieten eine kompakte Möglichkeit, unveränderliche Datenstrukturen zu modellieren, und reduzieren den Bedarf an Konstruktoren, Gettern sowie equals- und hashCode-Implementierungen. Mit Java 17 wurden Sealed Interfaces eingeführt, die es erlauben, genau festzulegen, welche Klassen ein Interface implementieren dürfen. In Kombination mit Pattern Matching, ebenfalls ab Java 17 verfügbar, wird so eine präzisere und sicherere Domänenmodellierung möglich.
Insgesamt hat sich Java von einer rein objektorientierten Sprache zu einer multiparadigmatischen Sprache entwickelt, mit Fokus auf Klarheit, Sicherheit und langfristige Wartbarkeit. Neuere Sprachfeatures setzen diesen Weg fort, haben jedoch keinen so großen Einfluss auf die Relevanz von Design Patterns und werden daher in diesem Artikel nicht weiter betrachtet.
Der Einfluss von Lambdas, Method References und Functional Interfaces
Lambdas, Method References und Functional Interfaces haben die Anwendung vieler klassischer Design Patterns stark verändert, ohne sie vollständig obsolet zu machen.
Factory-Method-Pattern
Das Factory-Method-Pattern trennt die Objekterzeugung von der Nutzung, indem die Erzeugungslogik in eine Factory-Methode ausgelagert wird. Mit Lambdas und Method References sind explizite Factory-Klassen oft nicht mehr notwendig, da Objekterzeugung nun direkt über Konstruktorreferenzen oder Lambdas ausgedrückt werden kann.
Command-Pattern
Das Command-Pattern kapselt eine Anfrage als Objekt, sodass Anfragen parametrisiert, geloggt oder in Warteschlangen abgelegt werden können und Undo-/Redo-Funktionalität unterstützt wird. In modernem Java lassen sich einfache Commands häufig als Runnable, Consumer oder andere Functional Interfaces implementieren, was den Boilerplate-Code drastisch reduziert. Dies gilt jedoch nicht für komplexere Commands mit mehreren Methoden, wie etwa einer zusätzlichen undo-Methode.
Observer-Pattern
Das Observer-Pattern definiert eine One-to-many-Abhängigkeit zwischen Objekten, sodass bei Zustandsänderungen eines Objekts alle abhängigen Objekte automatisch benachrichtigt werden. Lambdas erlauben es, Observer oder Listener als Inline-Callbacks zu formulieren, ohne separate Klassen definieren zu müssen.
Strategy-Pattern
Das Strategy-Pattern kapselt eine Familie von Algorithmen und macht sie austauschbar, sodass sich das Verhalten unabhängig vom Client ändern lässt. Mithilfe funktionaler Interfaces können Strategien heute häufig direkt als Lambdas übergeben werden, was die Lesbarkeit verbessert. Dies empfiehlt sich jedoch nur für einfache Strategien. Komplexe Strategien mit weiteren Abhängigkeiten sollten weiterhin als eigene Klassen implementiert werden.
Adapter-Pattern
Das Adapter-Pattern ermöglicht die Zusammenarbeit inkompatibler Interfaces. Es bleibt relevant, insbesondere bei der Integration von bestehendem oder externem Code. Ein spezieller Anwendungsfall hat sich jedoch verändert: Wenn eine statische Methode gegeben ist, aber ein Objekt erwartet wird, ist kein expliziter Adapter mehr notwendig. Stattdessen kann eine Method Reference auf die statische Methode als Adapter dienen.
Im Gegensatz zum Factory-Method-, Command-, Observer- und Strategy-Pattern sind die Auswirkungen auf das Adapter-Pattern weniger bekannt und bedürfen daher eines Beispiels:
Die Klasse java.time.LocalDateTime bietet die statische Methode now an, die das aktuelle Datum und die lokale Uhrzeit zurückgibt. Um eine Klasse, die diese Methode verwendet, zu Unit-testen, kann es notwendig sein, den Rückgabewert zu stubben. Weil statische Methode nicht so einfach durch Test-Doubles ersetzt werden können und static Mocking oft nicht gewollt ist, kann ein Adapter verwendet werden, um eine nicht-statische Methode bereitzustellen, die das Datum und die lokale Uhrzeit zurückgibt.
interface LocalDateTimeAdapter {
LocalDateTime now();
}
class LocalDateTimeAdapterImpl implements LocalDateTimeAdapter {
@Override
public LocalDateTime now() {
return LocalDateTime.now();
}
}
Der Adapter kann nun per Konstructor-Injection in eine andere Klasse reingereicht werden:
class Controller {
private final AService aService;
private final LocalDateTimeAdapter localDateTimeAdapter;
Controller(AService aService) {
this(aService, new LocalDateTimeAdapterImpl());
}
Controller(AService aService, LocalDateTimeAdapter localDateTimeAdapter) {
this.aService = aService;
this.localDateTimeAdapter = localDateTimeAdapter;
}
// ...
}
Statt ein neues Interface zu definieren, kann das Interface Supplier<T> aus java.util.function verwendet werden. Und statt das Interface in einer Adapter-Klasse zu implementieren, kann eine Methodenreferenz auf die statische Methode im sekundären Konstruktor des Controller verwendet werden:
import java.util.function.Supplier;
class Controller {
private final AService aService;
private final Supplier<LocalDateTime> now;
Controller(AService aService) {
this(aService, LocalDateTime::now);
}
Controller(AService aService) {
this.aService = aService;
this.now = now;
}
// ...
}
Der Einfluss von Streams
Das Iterator-Pattern bietet eine Möglichkeit, Elemente einer Collection sequentiell zu durchlaufen, ohne deren interne Struktur offenzulegen. In modernem Java wird diese Aufgabe größtenteils von der Stream API übernommen. Streams ermöglichen es, deklarativ zu beschreiben, was mit den Elementen passieren soll, ohne sich mit der Iterationslogik zu befassen.
Das Collector-Interface dient als Erweiterungspunkt für terminale Operationen, also solche, die einen Stream abschließen und ein Ergebnis aggregieren. Terminale Operationen ersetzen viele manuelle Iterationen, etwa zum Aufbau von Listen oder zur Berechnung von Summen.
Die mit Java 24 eingeführten Stream Gatherer unterscheiden sich von Collectors dadurch, dass sie für Zwischenoperationen gedacht sind. Sie erlauben es, während der Stream-Verarbeitung Zwischenergebnisse zu sammeln oder zu transformieren. Zusammen mit Collectors ermöglichen Gatherer einen Großteil der klassischen Iterator-Logik direkt in der Stream-Abstraktion.
Das Iterator-Pattern kann beispielsweise dafür genutzt werden, über eine Liste von Integer zu iterieren und dabei in jeder Iteration die laufende Summe zu berechnen:
record NumberAndRunningSum(int number, int runningSum) { }
class RunningSumIterator implements Iterator<NumberAndRunningSum> {
private final List<Integer> numbers;
private int index = 0;
private int runningSum = 0;
RunningSumIterator(List<Integer> numbers) {
this.numbers = numbers;
}
@Override
public boolean hasNext() {
return index < numbers.size();
}
@Override
public NumberAndRunningSum next() {
if(!hasNext()) {
throw new NoSuchElementException();
}
int number = numbers.get(index++);
runningSum += number;
return new NumberAndRunningSum(number, runningSum);
}
}
Dieser Iterator kann dazu verwendet werden, eine Liste von Integer zu durchlaufen und jeweils den Integer und die entsprechende laufende Summe auszugeben:
void main() {
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
RunningSumIterator iterator = new RunningSumIterator(numbers);
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
Diese Funktionalität kann auch mithilfe eines Stream-Gatherers implementiert werden:
void main() {
var runningSum = Gatherer.ofSequential(
() -> new int[]{0},
(state, number, downstream) -> {
state[0] += number;
downstream.push(new NumberAndRunningSum(number, state[0]));
return true; // continue
}
);
Stream.of(1, 2, 3, 4, 5)
.gather(runningSum)
.forEach(System.out::println);
}
Der Einfluss von Records und Sealed Interfaces
Das Visitor-Pattern trennt einen Algorithmus von der Objektstruktur, auf der er arbeitet. Dadurch lassen sich neue Operationen hinzufügen, ohne bestehende Klassen zu ändern. Der Nachteil ist jedoch, dass Änderungen an der Objektstruktur Anpassungen an allen Visitor-Implementierungen erfordern.
Ein Beispiel für den Einsatz des Visitor-Patterns ist die Suche in einem Binärbaum. Der Binärbaum kann wie folgt implementiert werden:
interface Node<K, V> {
void accept(NodeVisitor<K, V> visitor);
K key();
V value();
}
class BinaryTree<K, V> implements Node<K, V> {
private final K key;
private final V value;
private final Node<K, V> left;
private final Node<K, V> right;
BinaryTree(K key, V value, Node<K, V> left, Node<K, V> right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
@Override
public void accept(NodeVisitor<K, V> visitor) {
visitor.visit(this);
}
@Override
public K key() { return key; }
@Override
public V value() { return value; }
Node<K, V> left() { return left; }
Node<K, V> right() { return right; }
}
class Leaf<K, V> implements Node<K, V> {
private final K key;
private final V value;
Leaf(K key, V value) {
this.key = key;
this.value = value;
}
@Override
public void accept(NodeVisitor<K, V> visitor) {
visitor.visit(this);
}
@Override
public K key() { return key; }
@Override
public V value() { return value; }
}
class EmptyNode<K, V> implements Node<K, V> {
@Override
public void accept(NodeVisitor<K, V> visitor) {
visitor.visit(this);
}
@Override
public K key() {
throw new UnsupportedOperationException();
}
@Override
public V value() {
throw new UnsupportedOperationException();
}
}
Um den Binärbaum zu durchsuchen, wird ein Visitor benötigt:
interface NodeVisitor<K, V> {
void visit(BinaryTree<K, V> binaryTree);
void visit(Leaf<K, V> leaf);
void visit(EmptyNode<K, V> emptyNode);
Optional<V> result();
}
class SearchInDictionary implements NodeVisitor<String, String> {
private final String searched;
private String result;
SearchInDictionary(String searched) {
this.searched = searched;
}
@Override
public void visit(BinaryTree<String, String> binaryTree) {
int comparisonResult = searched.compareTo(binaryTree.key());
if(comparisonResult == 0) {
result = binaryTree.value();
} else if (comparisonResult < 0) {
binaryTree.left().accept(this);
} else {
binaryTree.right().accept(this);
}
}
@Override
public void visit(Leaf<String, String> leaf) {
if(searched.equals(leaf.key()) {
result = leaf.value();
}
}
@Override
public void visit(EmptyNode<String, String> emptyNode) { }
@Override
public Optional<String> result() {
return Optional.ofNullable(result);
}
}
Weil jeder Visitor eine visit-Methode pro Implementierung von Node bereitstellen muss, muss jeder Visitor angepasst werden, wenn sich die Datenstruktur ändert. In modernem Java können Records verwendet werden, um einfache Datenstrukturen wie diese abzubilden:
record Leaf<K, V> (K key, V value) implements Node<K, V> { }
record BinaryTree<K, V> (
K key,
V value,
Node<K, V> left,
Node<K, V> right
) implements Node <K, V> { }
class EmptyNode<K, V> implements Node<K, V> {
@Override
public K key() {
throw new UnsupportedOperationException();
}
@Override
public V value() {
throw new UnsupportedOperationException();
}
}
Um sicherzustellen, dass diese Records die einzigen Implementierungen von Node sind, kann das Interface versiegelt (engl. sealed) werden:
sealed interface Node<K, V> permits Leaf, BinaryTree, EmptyNode {
K key();
V value();
}
Der Binärbaum kann mit Hilfe von switch-Statements und Pattern Matching durchlaufen werden:
class SearchInDictionary {
Optional<String> result(
Node<String, String> dictionary,
String searched
) {
return switch(dictionary) {
case BinaryTree<String, String> binaryTree ->
resultFromBinaryTree(binaryTree, searched);
case Leaf<String, String> leaf -> resultFromLeaf(leaf, searched);
case EmptyNode<String, String> _ -> Optional.empty();
};
}
private Optional<String> resultFromBinaryTree(
BinaryTree<String, String> binaryTree,
String searched
) {
int comparisonResult = searched.compareTo(binaryTree.key());
if (comparisonResult == 0) {
return Optional.of(binaryTree.value());
} else if (comparisonResult < 0) {
return result(binaryTree.left(), searched);
} else {
return result(binaryTree.right(), searched);
}
}
private Optional<String> resultFromLeaf(
Leaf<String, String> leaf,
String searched
) {
return searched.equals(leaf.key()) ?
Optional.of(leaf.value()) :
Optional.empty();
}
}
Weil das Interface versiegelt ist, wird kein Default-Fall im Switch-Statement benötigt. Der Compiler stellt sicher, dass alle möglichen Fälle behandelt werden, wodurch ein zusätzliches Sicherheitsnetz entsteht.
Fazit
Die Analyse zeigt, dass die klassischen GoF-Design-Patterns weder vollständig obsolet noch in ihrer ursprünglichen Form universell anwendbar sind, wenn mit modernem Java gearbeitet wird. Ihre Rolle hat sich vielmehr verschoben. Viele Patterns entstanden als strukturierte Lösungen für Einschränkungen früher objektorientierter Sprachen. Mit der Weiterentwicklung von Java, insbesondere durch Lambdas, Functional Interfaces, Streams, Records, Sealed Interfaces und Pattern Matching, wurden viele dieser Einschränkungen direkt auf Sprachebene adressiert.
Modernes Java unterstützt vermehrt funktionale Programmierkonzepte. Dadurch können Patterns, die primär dazu dienen, Verhalten von Daten zu trennen, häufig mit erheblich weniger Boilerplate-Code ausgedrückt werden. Das bedeutet nicht, dass diese Patterns heute „falsch“ wären, sondern dass ihre Intention oft besser durch Sprach-Features als durch Pattern-Strukturen umgesetzt wird. Eine unreflektierte Anwendung klassischer, objektorientierter Pattern-Implementierungen kann der Klarheit und Einfachheit des Codes sogar schaden.
Ausblick
Diese Erkenntnis deckt sich mit früheren Untersuchungen. Peter Norvig zeigte, dass Lisp oder Dylan bereits Sprach-Features mitbrachten, die einen Großteil der GoF-Patterns vereinfachten oder überflüssig machten. Hannemann und Kiczales wiederum demonstrierten, dass aspektorientierte Programmierung viele Patterns durch die Kapselung von Querschnittsbelangen vereinfachen kann. Java verfolgt zwar keinen AOP-Ansatz auf Sprachebene, bewegt sich jedoch ebenfalls klar in Richtung einer Multi-Paradigmen-Sprache.
Ein Blick in die Zukunft zeigt, dass weitere Sprachfeatures die Relevanz klassischer Patterns weiter verändern könnten. Echte First-Class Functions oder eine eingebaute Delegation könnten zusätzliche Patterns vereinfachen oder vollständig ersetzen. Auch wenn derzeit keine konkreten Pläne für solche Erweiterungen existieren, deutet die bisherige Evolution von Java darauf hin, dass Ausdrucksstärke, Sicherheit und Reduktion von Boilerplate-Code weiterhin zentrale Ziele bleiben werden.
Zusammenfassend lässt sich sagen: Die GoF-Design-Patterns sind nach wie vor wertvoll, vor allem als gemeinsame Sprache und Denkmodell für Designentscheidungen, nicht aber als Blaupausen zur dogmatischen Anwendung. In modernem Java lautet die entscheidende Frage nicht mehr „Welches Pattern sollte ich implementieren?“, sondern „Ist ein Pattern hier wirklich die beste Lösung, oder bietet die Sprache bereits eine klarere und einfachere Abstraktion?“. Wer sowohl klassische Design Patterns als auch moderne Java-Sprach-Features versteht, ist am besten gerüstet, um wartbare, verständliche und zukunftssichere Software zu entwickeln.