The Gang of Four's "Design Patterns" book is more than thirty years old, but you no longer need to read it as a ready-made catalog of recipes — half the patterns have long since dissolved into the language and frameworks. So why learn them? Because Spring, Hibernate, and every HTTP framework speak exactly this vocabulary. HandlerAdapter, CompositeHealthContributor, DelegatingPasswordEncoder — these aren't random names, they're GoF patterns embedded in class names. Without knowing the pattern, it's hard to understand why a class is built the way it is and not some other way.
Below are all 23 patterns in their classic groups. For each one: the gist in a single sentence, where it already lives in off-the-shelf tools, and whether you need to write it yourself.
The three most common patterns are one and the same move: the calling code holds on to an interface, and behind that interface sits either one implementation out of several, or a chain of wrappers, or several recipients at once.
Creational Patterns
This group answers one question: how do you create objects the right way?
Singleton
Gist: one instance for the whole application.
If you create an object everywhere it's needed, you end up with several unrelated copies with different state. Singleton solves this: the object exists as a single instance, and everyone talks to the same one.
In Spring, a singleton is implemented through the container — each @Service bean is created as a single instance by default. This is better than the classic GoF variant with static getInstance(): a container-managed singleton is injected through the constructor and is easily swapped out in tests.
The key consequence: one instance serves all requests in parallel, so singleton beans must not carry mutable state — otherwise you get a thread race.
@Service
public class PricingService {
// one instance for the entire application context
}
@Test
void calculatesPrice() {
var service = new PricingService(); // in a test we create it via new — simple
}
Prototype
Gist: a new instance on every request.
The opposite of Singleton: sometimes you need a fresh object for each operation, not a shared one. In Spring this is scope = prototype — each getBean() yields a new object.
The trap: if you inject a prototype bean into a singleton bean the usual way, the injection happens once, when the singleton is created — and the "freshness" is lost. The solution is ObjectProvider:
@Service
@RequiredArgsConstructor
public class ReportService {
private final ObjectProvider<ReportBuilder> reportBuilders;
public ReportDto create(ReportRequest request) {
var builder = reportBuilders.getObject(); // a new instance every time
return builder.with(request).build();
}
}
Factory Method
Gist: object creation is delegated to a method that hides the concrete class.
Instead of writing new ConcreteClass(...) everywhere, the calling code works with an interface, and the factory method decides which implementation to create.
In Spring, every @Bean method is a factory method: the calling code knows the interface, the method decides which implementation to configure:
@Configuration
public class ClockConfig {
@Bean
@Profile("!integration-test")
Clock clock() {
return Clock.systemUTC();
}
@Bean
@Profile("integration-test")
Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-01-15T10:00:00Z"), ZoneOffset.UTC);
}
}
In application code, static factory methods are a good way to create objects with validation:
live example
import java.util.List;
public class FactoryMethodDemo {
record Order(String id, List<String> lines) {
static Order create(String id, List<String> lines) {
if (lines.isEmpty()) {
throw new IllegalArgumentException("an order without lines cannot exist");
}
return new Order(id, lines);
}
}
public static void main(String[] args) {
System.out.println(Order.create("A-1", List.of("laptop", "mouse")));
try {
Order.create("A-2", List.of());
} catch (IllegalArgumentException e) {
System.out.println("not created: " + e.getMessage());
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Abstract Factory
Gist: a factory that creates a family of related objects.
Where Factory Method creates a single object, Abstract Factory creates a whole group of objects that must "fit together."
In Spring, the role of the abstract factory is played by BeanFactory and ApplicationContext: they hand out ready objects by type, hiding the concrete implementation. Whether the PlatformTransactionManager will be a JpaTransactionManager or a DataSourceTransactionManager is decided by configuration, not by the calling code.
In application code, Abstract Factory is rarely written by hand: profile-based configuration (@Profile/@Conditional) covers the task more simply.
Builder
Gist: step-by-step assembly of a complex object with readable code.
A constructor with eight parameters is a source of bugs: it's easy to mix up the order, and it's unclear what does what. Builder gives named methods for each field.
In Spring, Builder is used in HttpSecurity (security configuration) and in all HTTP clients. Lombok generates a Builder automatically via @Builder:
@Builder
public record OrderSearchQuery(
@Nullable CustomerId customerId,
@Nullable OrderStatus status,
int page,
int size
) {}
var query = OrderSearchQuery.builder()
.status(OrderStatus.PAID)
.page(0)
.size(20)
.build();
Structural Patterns
This group answers the question: how do you build the connections between objects the right way?
Adapter
Gist: converts one interface into another that the client expects.
Imagine two plugs of different shapes: your code expects one interface, but an external library offers another. Adapter is the connector between them.
In Spring, DispatcherServlet doesn't know how a handler is written — as a @RequestMapping method or something else. It works with a single HandlerAdapter, which brings any style to a common contract.
In application code, Adapter is the foundation for working with external dependencies: the adapter translates a domain interface into the language of a specific SDK:
@Component
@RequiredArgsConstructor
public class S3DocumentStorageAdapter implements DocumentStoragePort {
private final S3Client s3Client;
@Override
public DocumentRef store(Document document) {
var key = document.id().value().toString();
s3Client.putObject(b -> b.bucket("documents").key(key),
RequestBody.fromBytes(document.content()));
return new DocumentRef(key);
}
}
Bridge
Gist: the abstraction and the implementation evolve independently.
The classic example: Resource and ResourceLoader in Spring — one abstraction "resource," independent implementations for classpath:, file:, https:. The code that reads a resource doesn't change when the source changes.
In application code, Bridge in its pure form is almost never seen — it's replaced by the "interface + dependency injection" combination.
Composite
Gist: a group of objects is used the same way as a single object.
You need to send a notification over email and SMS at the same time, but the calling code shouldn't know the details. Composite lets you "wrap" several objects into one that implements the same interface.
In Spring, it's recognized by the Composite prefix: CompositePropertySource, CompositeCacheManager, CompositeHealthContributor in Actuator — several sources look like one.
live example
import java.util.List;
public class CompositeDemo {
interface Notifications {
void orderCancelled(String orderId);
}
static class CompositeNotifications implements Notifications {
private final List<Notifications> channels;
CompositeNotifications(List<Notifications> channels) {
this.channels = channels;
}
@Override
public void orderCancelled(String orderId) {
channels.forEach(channel -> channel.orderCancelled(orderId));
}
}
public static void main(String[] args) {
Notifications all = new CompositeNotifications(List.of(
orderId -> System.out.println("email: order " + orderId + " cancelled"),
orderId -> System.out.println("sms: order " + orderId + " cancelled")));
all.orderCancelled("A-1");
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Decorator
Gist: an object is wrapped in a wrapper with the same interface that adds behavior.
You need to add caching to a repository, but you can't (or don't want to) change its class. Decorator creates a wrapper with the same interface that intercepts calls and adds the desired behavior.
In Spring, this is ContentCachingRequestWrapper, TransactionAwareDataSourceProxy, DelegatingSecurityContextExecutor. The AOP proxy for @Transactional is also a Decorator in essence.
live example
import java.util.HashMap;
import java.util.Map;
public class DecoratorDemo {
interface Products {
String nameOf(long id);
}
static class DatabaseProducts implements Products {
@Override
public String nameOf(long id) {
System.out.println(" hitting the database for " + id);
return "product-" + id;
}
}
static class CachingProducts implements Products {
private final Products delegate;
private final Map<Long, String> cache = new HashMap<>();
CachingProducts(Products delegate) {
this.delegate = delegate;
}
@Override
public String nameOf(long id) {
return cache.computeIfAbsent(id, delegate::nameOf);
}
}
public static void main(String[] args) {
Products products = new CachingProducts(new DatabaseProducts());
System.out.println(products.nameOf(7));
System.out.println(products.nameOf(7));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Two calls, one "hitting the database" line: the wrapper intercepted the second one.
Facade
Gist: a simple interface over a complex subsystem.
Working with JDBC directly requires: open a connection, create a PreparedStatement, execute it, process the ResultSet, close everything in the right order. JdbcTemplate hides all this complexity behind a single call.
In Spring, all the *Template and *Client classes are facades: JdbcTemplate, KafkaTemplate, RestClient. In application code, a facade over an external SDK is a normal form of adapter: a single method can hide three third-party API calls, retries, and error translation.
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {
private final PaymentSdkClient sdkClient;
@Override
public PaymentResult charge(Order order, PaymentMethod method) {
var request = sdkClient.newRequest()
.amount(order.total().amount())
.currency(order.total().currency().code())
.method(method.token())
.build();
var response = sdkClient.submit(request);
return PaymentResult.of(response.transactionId(), response.status());
}
}
Flyweight
Gist: shared immutable objects instead of thousands of identical copies.
If you create one object per word in a text, memory runs out fast. Flyweight shares objects with identical content — one instance per value.
In Java, this is the Integer.valueOf(-128..127) cache and string interning. In frameworks — internal caches of annotation and type metadata.
In application code, Flyweight is almost never written by hand. Its idea is carried by immutable value objects and constants: Currency.RUB is one for the whole application precisely because it's immutable.
Proxy
Gist: a stand-in object controls access to the real object.
The proxy intercepts calls and does something before or after: opens a transaction, checks permissions, caches the result.
This is the number-one pattern across all of Spring. JDK dynamic proxy and CGLIB are the mechanism that powers @Transactional, @Cacheable, @Async, @PreAuthorize. An annotation on a method is an instruction to the container: "wrap this bean in a proxy."
Hence the classic traps: calling a method from within the same class (this.method()) goes past the proxy and the annotations don't fire — covered in detail in the article on AOP. The mechanics are visible in plain Java: java.lang.reflect.Proxy substitutes the object with a stand-in that has the same interface.
live example
import java.lang.reflect.Proxy;
public class ProxyDemo {
interface Payments {
void charge(String orderId);
void chargeTwice(String orderId);
}
static class RealPayments implements Payments {
@Override
public void charge(String orderId) {
System.out.println(" charged " + orderId);
}
@Override
public void chargeTwice(String orderId) {
charge(orderId);
charge(orderId);
}
}
public static void main(String[] args) {
var real = new RealPayments();
var payments = (Payments) Proxy.newProxyInstance(
Payments.class.getClassLoader(),
new Class<?>[]{Payments.class},
(proxy, method, arguments) -> {
System.out.println("proxy: opened a transaction for " + method.getName());
return method.invoke(real, arguments);
});
payments.charge("A-1");
payments.chargeTwice("A-2");
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The output has two transaction lines but three charges: the two inner charge calls went past the stand-in.
Behavioral Patterns
This group answers the question: how do you organize the interaction between objects?
Chain of Responsibility
Gist: a request travels down a chain of handlers until someone handles it.
An HTTP request needs to be checked first for authentication, then for CSRF, then for authorization, and each step can stop processing. Instead of one huge method — a chain of independent handlers.
In Spring, this is SecurityFilterChain — the reference implementation of the pattern. The same mechanics apply to MVC interceptors and exception handler chains.
@Component
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
MDC.put("traceId", resolveTraceId(request));
try {
chain.doFilter(request, response); // pass it further down the chain
} finally {
MDC.remove("traceId");
}
}
}
Command
Gist: an operation is packaged into an object — it can be passed around, deferred, queued.
Normally a method call is instant and anonymous. Command turns an operation into an object with data — it can be passed to another thread, deferred, logged, undone.
In Java, this is Runnable/Callable going into a thread pool. In Spring — @Scheduled tasks and @Async methods.
In application code, Command is the structural foundation for separating "what to do" from "how to do it": one object carries the operation's data, another executes it.
public record CancelOrderCommand(OrderId orderId, CancelReason reason) {}
@Component
@RequiredArgsConstructor
public class CancelOrderHandler {
private final OrderRepository orderRepository;
@Transactional
public void handle(CancelOrderCommand command) {
var order = orderRepository.findById(command.orderId()).orElseThrow();
order.cancel(command.reason());
orderRepository.save(order);
}
}
Interpreter
Gist: a language with a grammar and an interpreter for expressions written in it.
In Spring, this is SpEL (Spring Expression Language): expressions in @PreAuthorize("hasRole('ADMIN')"), @Cacheable(key = "#id"), @Value("#{systemProperties['user.home']}").
In your own code, creating mini-languages isn't worth it: string expressions aren't checked by the compiler, they break during refactoring, and they complicate debugging.
Iterator
Gist: sequential access to elements without exposing the internal structure.
The pattern has long since dissolved into the language: Iterable/Iterator, for-each, Stream. Spring Data adds Page and Slice for paged fetches.
You never have to implement Iterator by hand. The one close case is returning immutable views from an aggregate's collections.
Mediator
Gist: objects communicate through a mediator, without knowing about each other.
If one service calls another directly, they become tightly coupled: changing one breaks the other. Mediator removes the direct dependency — objects publish events, and whoever wants to subscribes.
In Spring, this is ApplicationEventPublisher: a bean publishes an event, listeners react, and nobody is directly coupled to anyone. DispatcherServlet is also an MVC mediator.
@Component
@RequiredArgsConstructor
public class CancelOrderHandler {
private final ApplicationEventPublisher events;
public void handle(CancelOrderCommand command) {
// ... cancellation logic ...
events.publishEvent(new OrderCancelled(command.orderId()));
}
}
Memento
Gist: a snapshot of an object's state for a later rollback.
A savepoint in transactions is a pure Memento: TransactionStatus.createSavepoint() records a point, rollbackToSavepoint() rolls back to it.
In application code it's almost never needed: rolling back state is the job of a database transaction, and change history is a separate audit table or event sourcing.
Observer
Gist: subscribers get notified when the publisher's state changes.
You need to send an email when an order is cancelled. You could call emailService.send(...) right inside the business logic — but then the business logic knows about the email service. Observer separates them: the business logic publishes an event, the email service subscribes.
In Spring, this is @EventListener and @TransactionalEventListener. An important nuance: if a listener with external effects (an email, an SMS) is attached without being tied to a transaction, the notification may go out for a rolled-back transaction. The right way is phase = TransactionPhase.AFTER_COMMIT.
@Component
@RequiredArgsConstructor
public class OrderNotificationListener {
private final NotificationPort notifications;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCancelled event) {
notifications.orderCancelled(event.orderId());
}
}
State
Gist: an object's behavior changes as its internal state changes.
An order in the CREATED status can be cancelled. An order in the DELIVERED status can't. The transition logic can be split into separate state classes, but for most tasks, checks inside the object's own methods are enough:
live example
public class StateDemo {
static class Order {
enum Status { CREATED, PAID, DELIVERED, CANCELLED }
private Status status;
Order(Status status) {
this.status = status;
}
void cancel() {
if (status != Status.CREATED && status != Status.PAID) {
throw new IllegalStateException("cannot cancel an order in status " + status);
}
status = Status.CANCELLED;
}
}
public static void main(String[] args) {
var paid = new Order(Order.Status.PAID);
paid.cancel();
System.out.println("from PAID: " + paid.status);
try {
new Order(Order.Status.DELIVERED).cancel();
} catch (IllegalStateException e) {
System.out.println("from DELIVERED: " + e.getMessage());
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The classic State with a separate class per state is justified for a very large state machine. For an ordinary object with a few statuses, an enum plus transition checks is enough.
Strategy
Gist: a family of algorithms behind a common interface, chosen depending on the situation.
Discounts for different customer categories: you can write a switch with conditions, or you can declare a DiscountPolicy interface and create one implementation per category. Adding a new category means a new class, not editing a switch.
In Spring, Strategy is everywhere: PasswordEncoder (picks the hashing algorithm), PlatformTransactionManager, AuthenticationProvider, ContentNegotiationStrategy.
live example
import java.util.List;
public class StrategyDemo {
interface DiscountPolicy {
boolean supports(String category);
int apply(int total);
}
record VipDiscount() implements DiscountPolicy {
public boolean supports(String category) { return category.equals("vip"); }
public int apply(int total) { return total / 10; }
}
record FirstOrderDiscount() implements DiscountPolicy {
public boolean supports(String category) { return category.equals("first order"); }
public int apply(int total) { return 300; }
}
public static void main(String[] args) {
List<DiscountPolicy> policies = List.of(new VipDiscount(), new FirstOrderDiscount());
for (String category : List.of("vip", "first order", "regular")) {
int discount = policies.stream()
.filter(policy -> policy.supports(category))
.mapToInt(policy -> policy.apply(5000))
.sum();
System.out.println(category + ": discount " + discount);
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Template Method
Gist: the skeleton of an algorithm in a base class, the changeable steps in subclasses.
A request filter must always run exactly once, even if it's invoked twice in the chain. The OncePerRequestFilter base class takes that guarantee on itself, leaving the subclass only the meaningful part. The same skeleton in plain Java: the base class fixes the order of steps, the subclass writes one step.
live example
public class TemplateMethodDemo {
abstract static class Importer {
final void run(String file) {
System.out.println("opened " + file);
parse(file);
System.out.println("closed " + file);
}
abstract void parse(String file);
}
public static void main(String[] args) {
Importer csv = new Importer() {
@Override
void parse(String file) {
System.out.println(" parsed the CSV rows");
}
};
csv.run("orders.csv");
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The modern variant is to pass the step as a function rather than creating a subclass. JdbcTemplate does exactly this: the skeleton (open connection → execute → close) is constant, and the variable step (the SQL query) is passed as a lambda.
Visitor
Gist: a new operation over a structure of objects without changing their classes.
There's a hierarchy of PaymentMethod types: Card, Sbp, Cash. You need to compute the fee differently for each type without adding a fee() method to every class. Visitor adds the operation from the outside.
In modern languages, Visitor has been displaced by pattern matching:
live example
import java.math.BigDecimal;
public class VisitorDemo {
sealed interface PaymentMethod permits Card, Sbp, Cash {}
record Card(BigDecimal amount) implements PaymentMethod {}
record Sbp(BigDecimal amount) implements PaymentMethod {}
record Cash(BigDecimal amount) implements PaymentMethod {}
static BigDecimal fee(PaymentMethod method) {
return switch (method) {
case Card card -> card.amount().multiply(new BigDecimal("0.02"));
case Sbp ignored -> BigDecimal.ZERO;
case Cash ignored -> new BigDecimal("50");
};
}
public static void main(String[] args) {
var amount = new BigDecimal("5000");
System.out.println("card: " + fee(new Card(amount)));
System.out.println("sbp: " + fee(new Sbp(amount)));
System.out.println("cash: " + fee(new Cash(amount)));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
sealed keeps the list of types closed, so the compiler itself checks that the switch covers all three.
All 23 Patterns: A Quick Summary
| Pattern | Where it shows up in off-the-shelf tools | Do you need it in your own code |
|---|---|---|
| Singleton | Default bean scope in Spring | Don't write by hand — it's the container's job |
| Prototype | prototype scope, ObjectProvider | Rarely; a local variable is usually enough |
| Factory Method | @Bean methods, FactoryBean<T> | Yes — static factory methods to create objects with validation |
| Abstract Factory | BeanFactory, ApplicationContext | Not needed — @Profile assembles the configuration |
| Builder | HTTP clients, HttpSecurity in Spring Security | Yes — via Lombok's @Builder or by hand |
| Adapter | HandlerAdapter, MessageListenerAdapter | Yes — adapters to external dependencies |
| Bridge | Resource/ResourceLoader, logging SPI | Almost never — "interface + DI" covers it |
| Composite | CompositePropertySource, CompositeHealthContributor | Yes — when you need several recipients behind one interface |
| Decorator | Request wrappers, TransactionAwareDataSourceProxy | Yes — wrappers over repositories; check built-in mechanisms first |
| Facade | JdbcTemplate, KafkaTemplate, RestClient | Yes — an adapter-facade over someone else's SDK |
| Flyweight | Metadata caches in frameworks, Integer.valueOf | Almost never — the idea is carried by immutable value objects |
| Proxy | AOP: @Transactional, @Cacheable, @Async, @PreAuthorize | Don't write — use the built-in AOP mechanisms |
| Chain of Responsibility | SecurityFilterChain, MVC interceptors | Rarely — the framework's ready-made chains are enough |
| Command | Runnable + thread pool, @Scheduled, @Async | Yes — separating "what" from "how" in operation handlers |
| Interpreter | SpEL: @PreAuthorize, @Cacheable(key=...), @Value | Don't invent your own expression languages |
| Iterator | Iterable, Stream, Page/Slice in Spring Data | Dissolved into the language |
| Mediator | ApplicationEventPublisher, DispatcherServlet | Yes — events instead of direct calls between modules |
| Memento | Savepoint in transactions | Almost never — the database transaction does the rollback |
| Observer | @EventListener, @TransactionalEventListener | Yes — domain events, constantly |
| State | Spring Statemachine for complex cases | Yes, in a lightweight form — enum + transition checks |
| Strategy | PasswordEncoder, PlatformTransactionManager | Yes — instead of sprawling switch statements |
| Template Method | OncePerRequestFilter, AbstractRoutingDataSource | The callback variant is preferable to inheritance |
| Visitor | ASM in component scanning | Displaced by pattern matching (Java 21+) |
Of the 23 patterns, you'll regularly write seven or eight in application code: Adapter, Strategy, Observer, Command, Decorator, Composite, Factory Method, and State. Another handful you use ready-made every day without noticing: Proxy, Singleton, Builder, Facade, Template Method, Chain of Responsibility. The rest are vocabulary for reading other people's code.
In Short
- GoF patterns aren't recipes to copy, they're a vocabulary: this is exactly what frameworks speak in their class names.
- Proxy is the number-one pattern in Spring:
@Transactional,@Cacheable,@Asyncall work through it. - Strategy is the main tool against sprawling
switchstatements. - Observer is the standard way to separate side effects (an email, a metric) from the business logic.
- Adapter is the foundation for working with external dependencies: the domain knows the interface, the adapter knows the concrete SDK.
- Of the 23 patterns, you regularly write ~7 by hand; the rest live in off-the-shelf tools.
What to Read Next
- SOLID by Example — the principles these patterns exist for.
- GRASP by Example — which class to give responsibility to before choosing a pattern.
- Spring AOP — how Proxy, Spring's number-one pattern, is built.
- DI/IoC, bean scopes — Singleton and Prototype as container scopes.