Design Patterns in Java
Gang of Four patterns name recurring solutions—not frameworks to apply everywhere. In Java, the JDK and Spring already embody many of them: I/O decorators, servlet filters, JDBC templates, AOP proxies. This chapter shows idiomatic Java implementations, when each pattern earns its complexity, and how Spring maps to the same ideas.
How to use patterns in Java
Patterns describe forces and trade-offs: what problem, what variation points, what cost in complexity. Prefer composition, interfaces, and dependency injection before custom pattern machinery.
| Category | Focus | Java / Spring examples |
|---|---|---|
| Creational | Object creation & composition | Builders, factories, Spring @Bean |
| Structural | Composition of classes/objects | I/O streams, adapters, AOP proxies |
| Behavioral | Algorithms & responsibility flow | Strategies, events, filter chains |
Pattern obsession produces unnecessary interfaces and factories. If only one implementation exists and no variation is planned, a concrete class and constructor injection are simpler.
Creational patterns
Control who creates objects and how families of related objects are composed—without scattering new across business logic.
Singleton
Ensures one instance per class loader (per JVM for static singletons). Useful for stateless coordinators or expensive shared resources— dangerous for mutable global state, hidden dependencies, and tests that cannot substitute fakes.
Eager static init is simplest and thread-safe: private static final X INSTANCE = new X();—pays construction cost at class load even if never used.
Lazy initialization delays creation until first use. Synchronized method on getInstance() is correct but serializes every access; double-checked locking (DCL) optimizes the hot path by checking null outside the lock, then synchronizing only on first creation.
DCL only works if the instance field is volatile: without it, another thread can see a partially constructed object (JMM reordering). This was a famous pre-Java 5 bug; with volatile, publication is safe. In practice, prefer enum singleton or holder idiom over hand-written DCL.
// Double-checked locking — volatile required
class DatabasePool {
private static volatile DatabasePool instance;
private DatabasePool() { /* expensive */ }
static DatabasePool getInstance() {
DatabasePool local = instance;
if (local == null) {
synchronized (DatabasePool.class) {
local = instance;
if (local == null) {
instance = local = new DatabasePool();
}
}
}
return local;
}
}
// Holder idiom — lazy, class loading initializes holder once
class ConfigHolder {
private ConfigHolder() {}
private static class Holder { static final Config INSTANCE = new Config(); }
static Config get() { return Holder.INSTANCE; }
}
// Enum singleton — Joshua Bloch: serialization-safe, reflection-resistant
public enum MetricsRegistry {
INSTANCE;
private final MeterRegistry delegate = new SimpleMeterRegistry();
public MeterRegistry registry() { return delegate; }
}
Double-checked locking ties to the JMM—see Concurrency: synchronization for volatile and happens-before.
| Approach | Hand-rolled singleton | Spring @Singleton bean |
|---|---|---|
| Lifecycle | You manage init/shutdown | Container: @PostConstruct, @PreDestroy |
| Testing | Hard to replace global | Mock beans, @TestConfiguration |
| Scope | One per class loader | One per IoC container (prototype/request scopes differ) |
| When to use | Libraries, true globals | All application services in Spring apps |
Calling getInstance() from Spring code is an anti-pattern—inject dependencies instead. Spring’s default scope is already singleton; the framework is your factory.
Builder
Solves the telescoping constructor problem: as optional fields grow, constructor overloads explode—
Server(int port), Server(int port, String host), Server(int port, String host, boolean tls), …
Callers must pass null or sentinel values for unused parameters, and parameter order becomes error-prone.
// Anti-pattern — telescoping constructors
class ServerConfig {
ServerConfig(int port) { this(port, "localhost", false); }
ServerConfig(int port, String host) { this(port, host, false); }
ServerConfig(int port, String host, boolean tls) { /* ... */ }
}
A fluent builder sets only what you need, validates invariants once in build(), and can enforce immutability on the product object.
ServerConfig cfg = ServerConfig.builder()
.port(8443)
.host("api.internal")
.tls(true)
.build();
// JDK — HttpRequest.Builder
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com"))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
// Lombok @Builder — compile-time generation for DTOs
// @Builder @Value class EmailJob { String to; String subject; String body; }
Fluent builders return this from each setter. For inheritance hierarchies, use the recursive generic trick (Builder<T extends Builder<T>>) so subclass builders type-check. Records with all-required fields often need no builder—see value objects below.
Factory Method vs Abstract Factory
| Pattern | Intent | Java shape |
|---|---|---|
| Factory Method | Subclass or implementor decides which concrete type to create | Connection createConnection() in abstract base |
| Abstract Factory | Family of related products (UI kit, DB dialect) | Interface with multiple factory methods |
// Factory Method — one product, variation point
abstract class PaymentProcessor {
abstract PaymentGateway gateway();
void charge(Money amount) { gateway().charge(amount); }
}
// Abstract Factory — related products
interface DataAccessFactory {
UserRepository users();
OrderRepository orders();
}
Factory Method defers the concrete class to a subclass or strategy: DocumentParser.createParser() returns PDF vs Word parser.
Abstract Factory creates families that must stay consistent—Postgres Connection + Dialect + Sequence from one factory, not mixing vendors.
Spring @Configuration + @Bean methods are factory methods at container level. Profile-specific configuration (@Profile("prod")) or conditional beans act like abstract factories for environment-specific object sets.
Prototype
Clone an existing object instead of reconstructing from scratch—useful for expensive templates (report layouts, game entities) where only a few fields differ per copy.
Java’s Cloneable + Object.clone() is a cautionary tale:
- Shallow copy by default—mutable fields are shared between original and clone
- No constructor runs—invariants enforced in constructors can be bypassed
- Checked
CloneNotSupportedException—awkward API; easy to forgetsuper.clone() - Marker interface—does not document cloneability on the type clients see
Prefer copy constructors, static of/copyOf factories with explicit field copying, or mapping libraries for deep graphs. Records copy via constructor; for nested mutable state, copy collections defensively.
public final class QueryTemplate {
private final String sql;
private final Map<String, Object> params;
public QueryTemplate(QueryTemplate other) {
this.sql = other.sql;
this.params = new HashMap<>(other.params); // defensive copy
}
}
Structural patterns
Compose objects and classes into larger structures while keeping interfaces understandable—wrap, adapt, or tree-group components.
| Pattern | Same interface? | Purpose |
|---|---|---|
| Adapter | No — translates | Legacy / third-party fit |
| Decorator | Yes — stacks behavior | Add responsibilities |
| Proxy | Yes — controls access | Lazy load, security, AOP |
Adapter
Translates one interface into another a client expects—bridges legacy or third-party APIs without polluting domain code. Class adapter extends the legacy class and implements your interface (only when legacy is a class, not final, and single inheritance allows it). Object adapter holds a legacy delegate and implements your interface—favor this in Java (composition over inheritance).
Legacy integration example: a monolith still exposes a SOAP client returning proprietary DTOs; your new microservice expects CustomerLookup with domain Customer. The adapter is the only place that knows SOAP field names.
// Your domain expects:
interface CustomerLookup {
Optional<Customer> findByEmail(String email);
}
// Legacy SOAP client returns different types
class LegacyCustomerAdapter implements CustomerLookup {
private final LegacySoapClient legacy;
LegacyCustomerAdapter(LegacySoapClient legacy) { this.legacy = legacy; }
@Override
public Optional<Customer> findByEmail(String email) {
LegacyRecord r = legacy.fetchByEmail(email);
return r == null ? Optional.empty() : Optional.of(map(r));
}
}
Decorator
Wraps an object to add behavior while preserving the same interface—stackable layers you compose at runtime.
The JDK I/O stream stack is the textbook case: FileInputStream reads bytes; BufferedInputStream batches reads;
GZIPInputStream decompresses—all implement InputStream, so callers depend on the abstraction, not concrete layers.
Each decorator holds a delegate InputStream and overrides read() to add its concern. Order matters: buffering before decompression vs after changes behavior and performance.
InputStream in = new BufferedInputStream(
new GZIPInputStream(
new FileInputStream("data.gz")));
// Application-level decorator
class AuditingRepository implements UserRepository {
private final UserRepository delegate;
@Override
public User save(User u) {
log.info("save {}", u.id());
return delegate.save(u);
}
}
Decorators differ from proxies (control/ interception) and adapters (interface translation)—decorators extend behavior on the same interface.
Proxy — JDK, CGLIB, and Spring AOP
A proxy stands in for a real object and controls access—lazy loading (Hibernate lazy associations), security checks, logging, transactions. Unlike a decorator (which adds features transparently), proxies often decide whether to forward the call at all.
JDK dynamic proxy requires at least one interface. At runtime the JVM generates a class implementing those interfaces; your InvocationHandler receives every method call and forwards to the target.
CGLIB subclasses a concrete class at runtime (bytecode generation). Works without interfaces but cannot override final classes or methods. Spring uses CGLIB when the bean has no interface or when configured to prefer subclass proxies.
How Spring AOP works: Spring wraps beans in a proxy at container startup (or on first use for some cases). When you inject OrderService, you often receive a proxy subclass or interface implementation. Calls from outside the bean hit the proxy first; advice (@Transactional, @Cacheable, custom aspects) runs around MethodInvocation.proceed(). The target bean remains your plain class—the proxy is the interception shell.
// JDK dynamic proxy
PaymentService proxy = (PaymentService) Proxy.newProxyInstance(
PaymentService.class.getClassLoader(),
new Class<?>[] { PaymentService.class },
(obj, method, args) -> {
log.debug("→ {}", method.getName());
return method.invoke(target, args);
});
// Spring — @Transactional on public methods called through proxy
@Service
class OrderService {
@Transactional
public void place(Order o) { /* ... */ }
}
Self-invocation bypasses AOP: this.place(order) from another method on the same class does not go through the proxy—@Transactional will not apply. Fix with self-injection, separate bean, or TransactionTemplate. Same issue for @Async and @Cacheable.
Composite
Treat individual objects and compositions uniformly—tree structures.
File system: File and Directory both implement Node with long size() that sums children.
UI component trees and org charts follow the same shape.
interface FileNode {
String name();
long sizeBytes();
}
record FileLeaf(String name, long sizeBytes) implements FileNode { }
record Directory(String name, List<FileNode> children) implements FileNode {
@Override
public long sizeBytes() {
return children.stream().mapToLong(FileNode::sizeBytes).sum();
}
}
Facade
Provides a simplified API over a complex subsystem—clients depend on the facade, not ten internal classes.
Your service layer is often a facade: OrderService.placeOrder orchestrates repository, payment gateway, inventory reservation, and domain events in one cohesive operation while hiding JDBC, HTTP, and messaging details.
Facades reduce coupling to subsystem types; they do not replace domain logic—keep business rules in domain services or aggregates, and use the facade only for orchestration and transaction boundaries.
Do not confuse facade with adapter (changes interface to match client) or god-service (a facade that encodes every rule violates bounded contexts).
Spring Data JPA repositories are thin facades over Hibernate; RestTemplate/WebClient facade HTTP details. Keep facades thin—push logic to domain services when rules grow.
Behavioral patterns
Assign algorithms and responsibilities between objects—reduce conditionals, decouple senders from receivers, and chain processing steps.
Strategy
Encapsulate interchangeable algorithms behind a common interface—the context delegates to a strategy instead of a giant if/else or switch on type codes.
Replacing conditionals is the main win: new pricing rules become new classes, not another branch in a 400-line method.
// Before — conditional explosion
Money total(Order o) {
if (o.customer().isVip()) return vipPrice(o);
if (o.promo() != null) return promoPrice(o);
return listPrice(o);
}
// After — strategy + dependency injection
interface PricingStrategy { Money price(Order order); }
@Service
class CheckoutService {
private final PricingStrategy pricing;
CheckoutService(PricingStrategy pricing) { this.pricing = pricing; }
Money total(Order o) { return pricing.price(o); }
}
Spring injects the implementation: use @Qualifier, @Primary, @Profile, or Map<String, PricingStrategy> for plugin-style selection. Sealed interfaces + exhaustive switch (Java 17+) can replace strategy when variants are fixed—see Modern Java: sealed types.
Observer
One-to-many dependency: when subject state changes, observers are notified without the subject knowing concrete listener types.
The JDK uses this everywhere: EventListener hierarchies (ActionListener, PropertyChangeListener), JMX notifications, and servlet lifecycle callbacks.
java.util.Observable / Observer are deprecated—prefer explicit listener interfaces or reactive APIs (Flow, Project Reactor) where backpressure matters.
Spring application events: ApplicationEventPublisher.publishEvent broadcasts within the container (sync by default). Listeners use @EventListener; @TransactionalEventListener(phase = AFTER_COMMIT) runs only after successful commit—ideal for “send email when order saved” without coupling the aggregate to SMTP.
// Spring application event
public record OrderPlaced(UUID orderId) { }
@Service
class OrderService {
private final ApplicationEventPublisher events;
void place(Order o) {
// persist...
events.publishEvent(new OrderPlaced(o.id()));
}
}
@Component
class SendReceiptListener {
@EventListener
void on(OrderPlaced e) { /* email */ }
}
Template Method
An abstract class defines the skeleton of an algorithm in a final template method; subclasses override hooks for specific steps without reimplementing boilerplate.
Spring’s JdbcTemplate is the canonical example: it owns connection acquisition, PreparedStatement creation, exception translation, and resource cleanup in finally—you supply SQL and a RowMapper or callback.
RestTemplate (and WebClient in reactive stacks) similarly fix the HTTP choreography—URI, headers, error handling—while you plug in response types or handlers. The “template” lives in the framework base class; your code fills the variable steps.
// Spring JdbcTemplate — framework owns the template method
List<User> users = jdbc.query(
"SELECT id, email FROM users WHERE active = ?",
(rs, rowNum) -> new User(rs.getLong("id"), rs.getString("email")),
true);
abstract class DataImportJob {
final void run() {
openSource();
try {
while (hasMore()) {
Row row = readRow();
validate(row);
write(row);
}
} finally { closeSource(); }
}
protected abstract Row readRow();
protected void validate(Row row) { }
}
Prefer composition + strategy over deep template hierarchies when only one step varies—inheritance locks you to a single superclass and complicates testing.
Command
Encapsulate a request as an object—parameterize clients with operations, queue requests, log invocations, and support undo/redo.
Runnable and Callable are minimal commands with no undo. Rich commands store enough state to reverse side effects (editor buffers, financial transfers).
Request queuing: job systems enqueue commands (ExecutorService.submit, message payloads) instead of executing immediately—decouples producer from consumer and enables retry.
interface Command {
void execute();
void undo();
}
class TransferCommand implements Command {
private final Account from, to;
private final Money amount;
public void execute() { from.debit(amount); to.credit(amount); }
public void undo() { from.credit(amount); to.debit(amount); }
}
class CommandQueue {
private final Deque<Command> history = new ArrayDeque<>();
void run(Command c) { c.execute(); history.push(c); }
void undoLast() { if (!history.isEmpty()) history.pop().undo(); }
}
Chain of Responsibility
A request passes along a chain of handlers—each either handles it, transforms it, or forwards to the next link. Two flavors: terminal (first handler that can process wins) vs pipeline (every handler runs, like filters).
Servlet Filter chain: Container builds an ordered list. Each filter’s doFilter(request, response, chain) may inspect headers, attach MDC logging context, or reject with 401 before calling chain.doFilter to continue toward the servlet.
Spring Security: SecurityFilterChain is a composed chain—UsernamePasswordAuthenticationFilter, AuthorizationFilter, CSRF, session management—configured declaratively in Java or XML. Order is part of the security contract; reordering filters changes behavior.
// Servlet-style — pass along or stop
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
if (!isAuthenticated(req)) {
((HttpServletResponse) res).sendError(401);
return; // chain not invoked — short-circuit
}
chain.doFilter(req, res); // next filter or servlet
}
// Manual chain — link handlers
auth.linkWith(rateLimit).linkWith(validation);
auth.validate(request);
Name the pattern, the problem it solves, and a JDK or Spring example. Contrast decorator vs proxy vs adapter. Explain why enum singleton is preferred over double-checked locking for hand-written singletons.
Modern Java patterns
Beyond GoF: idioms that fit Java 8+ and domain-driven design—often replacing boilerplate patterns with language features and clear boundaries.
Functional composition
Treat behavior as values: compose small functions instead of subclass trees.
Function.andThen / compose, Predicate.and / or, and method references build pipelines readable top-to-bottom.
Use for pure transforms; keep Spring-injected strategy interfaces when behavior is policy-sized or needs multiple implementations in production config.
Function<String, String> normalize = String::trim;
Function<String, String> lower = String::toLowerCase;
Function<String, String> pipeline = normalize.andThen(lower);
Predicate<Order> active = o -> o.status() == Status.ACTIVE;
Predicate<Order> large = o -> o.total().isGreaterThan(Money.of(1000));
Predicate<Order> billable = active.and(large);
See Functional programming for streams and higher-order patterns that pair with composition.
Monad — Optional as the practical example
A monad (informally): wrap values, flat-map without nested boxes, provide empty/zero.
Optional.flatMap chains steps that may fail without null or nested if (present).
Not a reason to use Optional in fields—see Functional: Optional.
Optional<City> city = Optional.ofNullable(user)
.flatMap(User::address)
.flatMap(Address::city);
Null Object pattern
Replace null with a no-op implementation of an interface—eliminates null checks at call sites.
Example: Logger.NOOP, or a DiscountPolicy.NONE that returns zero discount instead of null policy reference.
interface Notifier {
void send(String message);
Notifier NOOP = msg -> { };
}
Value Object with Records
DDD value objects have no identity—equality by value (money, email, date range).
Java record is ideal: immutable, equals/hashCode, compact validation in canonical constructor.
See OOP: Records.
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.signum() < 0) throw new IllegalArgumentException("negative");
}
Money add(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException();
return new Money(amount.add(other.amount), currency);
}
}
Repository pattern (DDD)
Mediates between domain and persistence—collection-like API (findById, save) hiding SQL/JPA.
Domain stays persistence-ignorant; infrastructure implements the interface.
Spring Data JPA generates implementations from interface method names—repository is boundary, not business logic dump.
// Domain port
public interface OrderRepository {
Optional<Order> findById(OrderId id);
Order save(Order order);
}
// Infrastructure adapter — JPA
@Repository
class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpa;
// map Entity <-> domain Order
}
Hexagonal architecture maps cleanly: ports (interfaces) in domain, adapters (web, JPA, messaging) implement them. Patterns name the adapter/factory/strategy roles you were already building.