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.
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,RestClientand every*Templatein 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
| Pattern | Where it appears |
|---|---|
| Singleton | Default bean scope |
| Factory Method | @Bean methods |
| Abstract Factory | FactoryBean<T> |
| Proxy | @Transactional, @Cacheable, @Async |
| Decorator | BeanPostProcessor wrapping beans |
| Template Method | JdbcTemplate, RestClient, TransactionTemplate |
| Chain of Responsibility | The security filter chain |
| Observer | ApplicationEventPublisher and @EventListener |
| Adapter | HandlerMethodArgumentResolver, message converters |
The proxy is the one to internalise, because it explains the framework's most confusing behaviour:
@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:
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:
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
// 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
@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?
When should I write a strategy instead of a switch?
Why is my @Transactional or @Cacheable not applying?
Related tutorials
- CQRS & Event SourcingSeparating reads from writes: CQRS without event sourcing, event stores and aggregate replay, building projections, snapshots, and an honest account of when not to use either.
- Domain-Driven Design in PracticeDDD applied rather than described: choosing aggregate boundaries, value objects that enforce invariants, repositories, hexagonal architecture, and running an event storming session.
- Transaction Management Deep DiveTransactions beyond the annotation: every propagation mode and when it applies, isolation levels and the anomalies they prevent, transaction-bound events, and why XA lost to sagas.
- API Security & the OWASP API Top 10The API-specific vulnerability classes and their Spring fixes: broken object-level authorization, mass assignment, unrestricted consumption, SSRF, and API inventory management.