Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Design Patterns for Java Backends

The GoF patterns as Spring actually implements them, the ones worth writing yourself, and modern Java alternatives using records, sealed types and pattern matching.

Intermediate5 min readUpdated
On this page

Spring is built out of these patterns, so recognising them makes the framework predictable rather than magical — and knowing which the language now handles better keeps you from writing ceremony that a record or a lambda would express.

Key Takeaways

  • Proxy underlies @Transactional, @Cacheable, @Async — and explains self-invocation.
  • Strategy is the most useful pattern to write yourself; Spring injects the implementations.
  • Template Method is JdbcTemplate, RestClient and every *Template in the framework.
  • Sealed interfaces plus pattern matching replace Visitor with far less code.
  • Records make Value Object and Builder nearly free.

Patterns Spring implements for you

PatternWhere it appears
SingletonDefault bean scope
Factory Method@Bean methods
Abstract FactoryFactoryBean<T>
Proxy@Transactional, @Cacheable, @Async
DecoratorBeanPostProcessor wrapping beans
Template MethodJdbcTemplate, RestClient, TransactionTemplate
Chain of ResponsibilityThe security filter chain
ObserverApplicationEventPublisher and @EventListener
AdapterHandlerMethodArgumentResolver, message converters

The proxy is the one to internalise, because it explains the framework's most confusing behaviour:

ProxyBehaviour.java
@Service
public class OrderService {
 
    public void placeAll(List<Order> orders) {
        // Internal call: goes straight to the target. The @Transactional on
        // place() does NOT apply. No warning, no error, no transaction.
        orders.forEach(this::place);
    }
 
    @Transactional
    public void place(Order order) { }
}

Three ways out of that, in descending order of how well they age. Move place() into a separate bean, so the call crosses a proxy boundary and the design reflects the transaction boundary. Inject the bean into itself, which works and reads like a workaround. Or use TransactionTemplate and drop the annotation entirely, which is the honest option when the boundary is genuinely dynamic. Switching to AdviceMode.ASPECTJ also fixes it, at the cost of weaving in the build for every developer.

The same mechanism explains a subtler failure: a @Transactional method that catches its own exception commits, because the proxy only rolls back on an exception it actually sees. And a private or final method cannot be proxied at all, so an annotation on one is silently inert.

Strategy

The pattern most worth writing yourself, because Spring does most of the wiring:

PaymentStrategy.java
public interface PaymentStrategy {
    PaymentMethod supports();
    Receipt charge(Order order, PaymentDetails details);
}
 
@Component
public class CardPaymentStrategy implements PaymentStrategy {
    @Override public PaymentMethod supports() { return PaymentMethod.CARD; }
    @Override public Receipt charge(Order order, PaymentDetails details) { }
}
 
@Component
public class SepaPaymentStrategy implements PaymentStrategy {
    @Override public PaymentMethod supports() { return PaymentMethod.SEPA; }
    @Override public Receipt charge(Order order, PaymentDetails details) { }
}
 
@Service
public class PaymentRouter {
 
    private final Map<PaymentMethod, PaymentStrategy> strategies;
 
    // Spring injects every implementation. Adding a payment method means
    // adding one class — no edit here, no switch to forget.
    public PaymentRouter(List<PaymentStrategy> available) {
        this.strategies = available.stream()
                .collect(toMap(PaymentStrategy::supports, identity()));
    }
 
    public Receipt charge(Order order, PaymentDetails details) {
        var strategy = strategies.get(details.method());
        if (strategy == null) throw new UnsupportedPaymentMethodException(details.method());
        return strategy.charge(order, details);
    }
}

Injecting List<T> or Map<String, T> is the Spring idiom that makes this work with no registry code. It is the right shape when implementations arrive over time or from different modules.

For a genuinely closed set that all lives in one file, a switch is clearer, and modern Java makes it exhaustive:

SealedStrategy.java
public sealed interface Discount permits Percentage, FixedAmount, None { }
public record Percentage(int percent) implements Discount { }
public record FixedAmount(Money amount) implements Discount { }
public record None() implements Discount { }
 
Money apply(Money subtotal, Discount discount) {
    // No default branch. Adding a permitted subtype makes this a COMPILE ERROR
    // everywhere it is matched — which is exactly what you want.
    return switch (discount) {
        case Percentage p  -> subtotal.minus(subtotal.percent(p.percent()));
        case FixedAmount f -> subtotal.minus(f.amount());
        case None ignored  -> subtotal;
    };
}

That exhaustiveness check is what replaces the Visitor pattern. Visitor existed to get compile-time safety when adding an operation over a fixed type hierarchy; sealed types plus pattern matching give the same guarantee without the double-dispatch ceremony.

Template Method

TemplateMethod.java
// The framework owns the algorithm; you supply the varying step.
List<Order> orders = jdbcTemplate.query(
        "SELECT * FROM orders WHERE status = ?",
        (rs, rowNum) -> new Order(rs.getString("id"), rs.getString("status")),
        "PLACED");

JdbcTemplate handles connection acquisition, statement preparation, exception translation and resource cleanup. You provide the row mapping. That division — framework owns the invariant steps, caller supplies the variable one — is Template Method, and it is why every *Template in Spring feels the same.

Write your own when you have a repeated procedure with one varying step and getting the surrounding steps wrong is costly — a retry-and-audit wrapper, a resource-cleanup sequence.

Observer

Events.java
@Service
public class OrderService {
    @Transactional
    public void place(PlaceOrderCommand command) {
        Order order = repository.save(Order.place(command));
        events.publishEvent(new OrderPlaced(order.id()));
    }
}
 
@Component
public class Handlers {
    // Fires only after a successful commit — no email for a rolled-back order.
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendConfirmation(OrderPlaced event) { }
}

In-process events decouple within a service the way a broker does across services. The caveat is traceability: a reader of place() cannot see what happens next without searching for listeners. Use events for genuinely independent reactions, and a direct call when the step is part of the operation.

Patterns modern Java made cheap

Value Object is a record with validation in its compact constructor — three lines instead of fifty.

Builder is worth writing only when a record has many optional fields; Lombok's @Builder generates it, and for four or five parameters a static factory reads better than either.

Null Object is largely replaced by Optional, which makes absence explicit in the type rather than requiring a silent do-nothing implementation.

Iterator is in the JDK. Singleton is @Component. Writing either by hand today is a sign of pattern-matching the book rather than the problem.

What to take away

Learn the proxy mechanism, because it explains most of Spring's surprising behaviour. Use Strategy with injected collections when implementations grow over time, and sealed types with exhaustive switches when the set is closed. Let records and Optional handle the patterns the language absorbed, and reach for the rest only when the problem they solve is one you actually have.

Frequently Asked Questions

Are the GoF patterns still relevant?
The problems are; several of the solutions have been absorbed into languages and frameworks. Singleton is a bean scope, Iterator is in the JDK, and Strategy is often a lambda. The value now is in the shared vocabulary — saying "this is a strategy" communicates a design in three words.
When should I write a strategy instead of a switch?
When implementations are added by different people over time, come from different modules, or each need their own dependencies. A switch over a closed set of three cases in one file is clearer than three classes plus a registry. Sealed interfaces with pattern matching give you exhaustiveness for the closed case.
Why is my @Transactional or @Cacheable not applying?
Both work via proxies, and a proxy only intercepts calls arriving from outside the object. An internal call uses this directly and bypasses it. This one mechanism explains most surprising Spring behaviour.

Related tutorials