← Back to the section

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.

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 ReportController {
    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:

public final class Order {

    public static Order create(CustomerId customerId, List<OrderLine> lines) {
        if (lines.isEmpty()) {
            throw new IllegalArgumentException("An order must contain at least one line");
        }
        return new Order(OrderId.generate(), customerId, lines, Status.CREATED);
    }
}

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.

@Component
@Primary
@RequiredArgsConstructor
public class CompositeNotificationAdapter implements NotificationPort {

    private final List<NotificationPort> channels;

    @Override
    public void orderCancelled(Order order) {
        channels.forEach(channel -> channel.orderCancelled(order));
    }
}

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.

@Component
@Primary
@RequiredArgsConstructor
public class CachingProductRepository implements ProductRepository {

    private final JpaProductRepository delegate;
    private final Cache cache;

    @Override
    public Optional<Product> findById(ProductId id) {
        return Optional.ofNullable(
            cache.get(id, () -> delegate.findById(id).orElse(null))
        );
    }
}

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()) bypasses the proxy and the annotations don't fire — covered in detail in the article on AOP.

@Service
public class TransferService {

    @Transactional // Spring creates a proxy that opens a transaction
    public void transfer(AccountId from, AccountId to, Money amount) {
        // the calling code gets the proxy, not this class directly
    }
}

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()));
    }
}

@Component
public class RefundListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderCancelled event) {
        // a different module, unaware of the cancellation handler
    }
}

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
public class OrderNotificationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderCancelled event) {
        notificationPort.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:

public class Order {

    public void cancel(CancelReason reason) {
        if (status != Status.PAID && status != Status.CREATED) {
            throw new IllegalOrderStateException(id, status, "cancel");
        }
        this.status = Status.CANCELLED;
        registerEvent(new OrderCancelled(id, reason));
    }
}

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.

public interface DiscountPolicy {
    boolean supports(Order order);
    Money apply(Order order);
}

@Component
@RequiredArgsConstructor
public class DiscountService {

    private final List<DiscountPolicy> policies;

    public Money calculateDiscount(Order order) {
        return policies.stream()
            .filter(p -> p.supports(order))
            .map(p -> p.apply(order))
            .reduce(Money.ZERO, Money::add);
    }
}

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 care of this, leaving the subclass only the meaningful part:

public class TraceIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(...) {
        // we write only the filter's business logic, the superclass guarantees "once"
    }
}

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:

// Java 21: sealed + pattern matching instead of Visitor
public sealed interface PaymentMethod permits Card, Sbp, Cash {}

public BigDecimal fee(PaymentMethod method) {
    return switch (method) {
        case Card card -> card.amount().multiply(new BigDecimal("0.02"));
        case Sbp sbp -> BigDecimal.ZERO;
        case Cash cash -> new BigDecimal("50");
    };
}

All 23 Patterns: A Quick Summary

PatternWhere it shows up in off-the-shelf toolsDo you need it in your own code
SingletonDefault bean scope in SpringDon't write by hand — it's the container's job
Prototypeprototype scope, ObjectProviderRarely; a local variable is usually enough
Factory Method@Bean methods, FactoryBean<T>Yes — static factory methods to create objects with validation
Abstract FactoryBeanFactory, ApplicationContextNot needed — @Profile assembles the configuration
BuilderHTTP clients, HttpSecurity in Spring SecurityYes — via Lombok's @Builder or by hand
AdapterHandlerAdapter, MessageListenerAdapterYes — adapters to external dependencies
BridgeResource/ResourceLoader, logging SPIAlmost never — "interface + DI" covers it
CompositeCompositePropertySource, CompositeHealthContributorYes — when you need several recipients behind one interface
DecoratorRequest wrappers, TransactionAwareDataSourceProxyYes — wrappers over repositories; check built-in mechanisms first
FacadeJdbcTemplate, KafkaTemplate, RestClientYes — an adapter-facade over someone else's SDK
FlyweightMetadata caches in frameworks, Integer.valueOfAlmost never — the idea is carried by immutable value objects
ProxyAOP: @Transactional, @Cacheable, @Async, @PreAuthorizeDon't write — use the built-in AOP mechanisms
Chain of ResponsibilitySecurityFilterChain, MVC interceptorsRarely — the framework's ready-made chains are enough
CommandRunnable + thread pool, @Scheduled, @AsyncYes — separating "what" from "how" in operation handlers
InterpreterSpEL: @PreAuthorize, @Cacheable(key=...), @ValueDon't invent your own expression languages
IteratorIterable, Stream, Page/Slice in Spring DataDissolved into the language
MediatorApplicationEventPublisher, DispatcherServletYes — events instead of direct calls between modules
MementoSavepoint in transactionsAlmost never — the database transaction does the rollback
Observer@EventListener, @TransactionalEventListenerYes — domain events, constantly
StateSpring Statemachine for complex casesYes, in a lightweight form — enum + transition checks
StrategyPasswordEncoder, PlatformTransactionManagerYes — instead of sprawling switch statements
Template MethodOncePerRequestFilter, AbstractRoutingDataSourceThe callback variant is preferable to inheritance
VisitorASM in component scanningDisplaced 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, @Async all work through it.
  • Strategy is the main tool against sprawling switch statements.
  • 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.
  • Singleton isn't written by hand with static getInstance() — that's the DI container's job.
  • Decorator adds behavior without inheritance — but first check whether there's an annotation in the framework.
  • Of the 23 patterns, you regularly write ~7 by hand; the rest live in off-the-shelf tools.
  • 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.
  • Spring Events — Observer and Mediator in action.