The classic GoF design patterns helped developers build maintainable object-oriented software for a long time. In the past years the languages, like Java, have changed a lot; they evolved! What was a design pattern back then became a language feature today. This article analyses which patterns are still needed and which became obsolete.
Once upon a time…
Object-Oriented Programming
Object-oriented programming emerged with the invention of Simula 67. Ole-Johan Dahl and Kristen Nygaard invented Simula 67 while working at the Norwegian Computing Center in Oslo to run simulations. A few years later Alan Kay et al. invented Smalltalk as a purely object-oriented programming language. In the 80s, C++ brought the breakthrough in the mainstream for object-oriented programming. The first large-scale object-oriented systems were developed. In the 90s, object-oriented programming was the dominating programming paradigm.
Gof Design Patterns
In 1994 Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, often referred to as the Gang of Four (GoF), published “Design Patterns: Elements of Reusable Object-Oriented Software”. The 23 design patterns described in this book became very popular and shaped the common interpretation of object-oriented design. Over the years they helped loads of developers to build maintainable software.
Java
In the mid-90s something else happened: the first version of Java was released in 1995. Java was planned to be a simple yet robust object-oriented programming language which is similar to C++. Java is class-based which means that the behavior of an object is described via its class. Each object can have its own state but all objects of the same class behave in the same way.
Java allows inheritance to create hierarchies and extend or override the behavior of a superclass. To loosely couple the method signature and its implementation(s) Java offers the concept of interfaces. An interface defines method signatures without method bodies. All implementing classes of an interface have to provide an implementation for each defined method signature. Additionally, it is possible to define static methods, variables and constants to define class-level behavior and state. With all these language features combined it is possible to implement every GoF design pattern.
30 Years Later
Over the past 30 years a lot has changed: other programming paradigms became popular, new frameworks and libraries arose and Java evolved by adding some new language features. Therefore it’s time to have a look at the GoF design patterns and evaluate if they are still needed or have become obsolete.
The GoF Design Patterns
The 23 GoF design patterns focus on different aspects of object-oriented design. To make them easier to understand and apply, they are commonly grouped into three categories: creational patterns, structural patterns, and behavioral patterns. This classification helps in identifying a suitable pattern for a given problem.
As the name suggests, creational patterns deal with different ways of creating objects. Structural patterns focus on how classes and objects are composed to form larger structures. Behavioral patterns describe how objects interact and how responsibilities are distributed among them, often allowing behavior to vary at runtime.
Since the GoF design patterns are commonly known, they won’t be described in this article. For detailed information, consult the design patterns book by Gamma et al., visit refactoring.guru/design-patterns, or similar resources.
The Evolution of Java
Java has evolved steadily over the years, balancing backward compatibility with modern language features. A major turning point came with Java 8, which introduced Lambdas and Method References. These features enabled a more functional programming style, allowing behavior to be passed as data and significantly reducing boilerplate code.
Closely related, the Stream API also arrived in Java 8. It provides a powerful and expressive way to process collections declaratively, with built-in support for parallelism. In Java 24 the Stream API has been extended to provide more flexibility. The Gatherer interface allows a wide range of intermediate operations to be performed during stream processing.
New releases continued to focus on improving expressiveness and maintainability. Records were introduced in Java 16. They offer a concise way to model immutable data carriers, making intent explicit and reducing the need for verbose boilerplate such as constructors, getters, and equals/hashCode. With Java 17, Sealed Interfaces further strengthened the type system by allowing developers to precisely control which classes or interfaces may implement or extend them. This enables more robust domain modeling and better support for exhaustive checks. Records and sealed interfaces work great in combination with pattern matching, which was also introduced in Java 17.
Together, these features reflect Java’s evolution from a purely object-oriented language toward multi-paradigmatism focused on clarity, safety, and long-term maintainability. This path continued in the latest releases. The newest features don’t have such a big impact on the relevance of design patterns, therefore they won’t be looked at in this article.
The Impact of Lambdas, Method References and Functional Interfaces
Lambdas, method references and functional interfaces, have significantly influenced how several classic design patterns are applied, without making them obsolete.
Factory Method
The Factory Method pattern is used to separate object creation from its usage by encapsulating the creation logic in a dedicated factory method. With lambdas and method references, object creation can now be expressed concisely using constructor references or lambdas passed directly to the client. Therefore explicit factory classes are often unnecessary.
Command Pattern
The Command pattern encapsulates a request as an object, enabling requests to be parameterized, queued, or logged, and also supporting undo/redo functionality. In modern Java, simple commands can often be implemented as a Runnable, Consumer, or other functional interface, dramatically reducing boilerplate and making them easier to compose. This does not work with commands that have more than one method, like an additional undo method.
Oberserver Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified automatically. Lambdas allow observers or listeners to be expressed concisely as inline callbacks, avoiding the need for full observer classes.
Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable so that the algorithm can vary independently from the client. With functional interfaces, strategies can now often be passed directly as lambdas, simplifying the code and improving readability. This is only recommended for simple strategies fitting in a simple lambda expression. Complex strategies, especially with further dependencies, should still be implemented as classes.
Adapter Pattern
The Adapter pattern allows incompatible interfaces to work together by translating one interface into another. It is still relevant when integrating existing or third-party code, but one particular scenario has changed: if a static method exists but an object is wanted, there is no longer a need to implement an adapter object. Instead, a method reference to the static method can serve as the adapter object.
In contrast to the Factory Method, Command, Observer and Strategy pattern, the implications for the Adapter pattern may not be well known and may need an example:
The class java.time.LocalDateTime provides a static method now that returns the current local date and time. To unit-test a class that uses this method it can be necessary to stub the returned value. Because static methods are difficult to replace with test doubles and static mocking is often unwanted an adapter can be used to provide a non-static function that returns the current local date and time:
<code>interface LocalDateTimeAdapter {<br> LocalDateTime now();<br>}</code>class LocalDateTimeAdapterImpl implements LocalDateTimeAdapter { @Override
public LocalDateTime now() {
return LocalDateTime.<em>now</em>();
}
}The adapter can now be injected into the constructor of another class, for example a controller:
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;
}
// ...
}Instead of creating a new interface, the interface Supplier<T> from java.util.function can be used. And instead of implementing the interface in an adapter class a method reference directly to the static method can be used in the secondary constructor of the controller:
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;
}
// ...
}The Impact of Streams
The Iterator pattern provides a way to access elements of a collection sequentially without exposing its internal representation. In modern Java, the Stream API largely replaces explicit iteration. Streams allow developers to declaratively express what should be done with elements, such as filtering, mapping, or transforming, without worrying about the mechanics of traversal.
Collectors are extension points for terminal operations, which mark the end of a stream and aggregate elements into a list, map, sum, or other results. They replace many manual iterations, such as building a list or summing values.
Introduced in Java 24, Stream Gatherers differ from collectors in that they are designed for intermediate operations. Gatherers allow intermediate results to be collected or transformed during processing before the stream reaches its final aggregation. Together with collectors, gatherers extend the Stream API to handle both intermediate and terminal processing declaratively, effectively absorbing much of the traditional iterator logic into the stream abstraction.
The Iterator pattern could be used to iterate a list of integers while calculating the running sum for each iteration:
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);
}
}This Iterator can be used to iterate a list of integers and print out every number and the corresponding running sum:
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());
}The same logic can be expressed using a stream gatherer:
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);
}The Impact of Records and Sealed Interfaces
The Visitor pattern separates an algorithm from the object structure it operates on. It allows new operations to be added without modifying the existing object classes, at the cost of making the object structure harder to change.
An example of using the Visitor pattern is to search in a binary tree. The binary tree can be implemented like this:
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();
}
}To search the binary tree a visitor is needed:
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);
}
}Since every visitor needs to implement a visit-method for every implementation of Node, every visitor must be edited if the data structure changes. In modern Java records can be used to represent simple data structures like this:
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();
}
}In order to make sure that these records are the only implementations of Node, the interface can be sealed:
sealed interface Node<K, V> permits Leaf, BinaryTree, EmptyNode {
K key();
V value();
}The binary tree can be iterated using switch expressions and pattern matching:
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();
}
}Because the interface is sealed, there is no need for a default-case in the switch-expression. The compiler assures that every possible case is covered. This adds some extra safety net compared to the classic Visitor pattern.
Conclusion
The analysis shows that the classic GoF design patterns are neither obsolete nor universally applicable in their original form when working with modern Java. Instead, their role has shifted. Many patterns emerged as structured solutions to limitations of early object-oriented languages. With the evolution of Java, especially through lambdas, functional interfaces, streams, records, sealed interfaces, and pattern matching, some of these limitations have been addressed directly at the language level.
Modern Java strongly supports functional programming concepts. As a consequence, patterns that primarily exist to separate behavior from data, such as Strategy, Command, Visitor, or parts of Factory Method, can often be expressed more directly and with far less boilerplate. This does not mean these patterns are “wrong” today, but rather that their intent is frequently better served by language features instead of explicit pattern implementations. In idiomatic modern Java, overusing classic object-heavy pattern structures can even work against clarity and simplicity.
Lookout
This observation aligns with earlier research. Peter Norvig showed that languages like Lisp or Dylan already provided features that simplified or eliminated the need for a large portion of the GoF design patterns. Similarly, Hannemann and Kiczales demonstrated that aspect-oriented programming could simplify or replace many patterns by modularizing cross-cutting concerns. Java has not adopted AOP at the language level, but its steady move toward multi-paradigm programming confirms the same general direction: patterns migrate into the language over time.
Looking ahead, future language features could further change the relevance of classic patterns. True first-class functions beyond today’s functional interfaces, or built-in delegation support, could make additional patterns redundant or significantly simpler. While there are currently no concrete plans in Java to introduce such features, the language’s evolution over the last decades suggests that expressiveness, safety, and reduction of boilerplate will remain key goals.
In conclusion, the GoF design patterns are still valuable. They work primarily as a vocabulary and a way of thinking about design trade-offs, not as templates to be applied mechanically. In modern Java, the question is no longer “Which pattern should I implement?” but rather “Is a pattern still the best solution, or does the language already provide a simpler, clearer abstraction?” Developers who understand both classic patterns and modern Java features are best equipped to make that decision and to design software that is not only correct, but also readable, maintainable, and future-proof.
This article is part of the JAVAPRO magazine issue:
From AI as a Feature tu AI as Infrastructure
Move beyond AI experimentation and into AI engineering.
Explore the architectures, platforms, and operational practices required to build trustworthy AI systems at scale. From governance and observability to modern Java infrastructure, this edition examines the foundations of production-ready AI.
**Discover the edition **→





