← 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.

the calling code never changes — what stands behind the interface does calling code interface one call Strategy: one of three implementations vip — minus 10% first order — minus 300 regular — no discount vip — minus 10% first order — minus 300 regular — no discount Decorator: a wrapper over a wrapper logs caches database every wrapper has the same interface logs caches database Composite: one call — everyone at once broadcast email sms push

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

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.
  • Of the 23 patterns, you regularly write ~7 by hand; the rest live in off-the-shelf tools.