08-low-level-design

Common Design Patterns for Backend Java Services

Singleton, Factory, Builder, Strategy, Observer, and Decorator in Java with backend use cases: payments, coupons, notifications, and DTOs.

August 14, 2026
backend-engineerpatternsbuilderstrategyfactoryobserver

Common Design Patterns for Backend Java Services

Design patterns earn their keep when they solve a recurring shape of problem you can name — not when they're applied because a codebase "should have patterns." This guide covers the six patterns that show up constantly in backend Java services: Singleton, Factory, Builder, Strategy, Observer, and Decorator. Each section shows the problem it solves using this roadmap's capstone domain — order processing, coupons, inventory, payments, and notifications — along with the Java idiom that has mostly replaced the classic Gang-of-Four form, and the failure mode you hit when you reach for the pattern without needing it.

💡

This is a working-engineer's tour, not an exhaustive catalog. If you want deep dives on twenty-plus patterns each in isolation, this site's standalone content/lld/ library covers that ground pattern-by-pattern. Here, the goal is: know these six cold, know when each one is the right tool on a backend team, and know what a Java-idiomatic implementation looks like in 2026 (records, functional interfaces, dependency injection) rather than the 1994 textbook form.


1. Singleton

Intent: ensure a class has exactly one instance, and provide a single, well-known access point to it.

Where it's genuinely needed in backend systems

Singletons are usually wrong for domain objects but correct for a narrow set of infrastructure concerns: connection pools, thread pools, metrics registries, configuration holders — things that are expensive to create and where having two instances would be a bug, not just wasteful.

java
// Classic hand-rolled singleton (rarely needed if you use a DI framework —
// shown here because you WILL see it in legacy code and interview questions)
public class ConnectionPoolManager {
 
    private static volatile ConnectionPoolManager instance;
    private final HikariDataSource dataSource;
 
    private ConnectionPoolManager() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(System.getenv("DB_URL"));
        config.setMaximumPoolSize(20);
        this.dataSource = new HikariDataSource(config);
    }
 
    // Double-checked locking: avoids synchronizing on every call after init
    public static ConnectionPoolManager getInstance() {
        if (instance == null) {
            synchronized (ConnectionPoolManager.class) {
                if (instance == null) {
                    instance = new ConnectionPoolManager();
                }
            }
        }
        return instance;
    }
 
    public DataSource getDataSource() {
        return dataSource;
    }
}
⚠️

Double-checked locking only works because instance is volatile. Without volatile, another thread can observe a partially-constructed object due to instruction reordering — a subtle bug that only manifests under concurrent load in production, almost never in a single-threaded test.

The Java-idiomatic version

In real Spring Boot services, you almost never hand-write a singleton. The container manages singleton scope for you:

java
@Component  // Spring beans are singletons by default within the application context
public class MetricsRegistry {
    private final MeterRegistry meterRegistry;
 
    public MetricsRegistry(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }
 
    public void recordOrderPlaced(Money amount) {
        meterRegistry.counter("orders.placed").increment();
        meterRegistry.summary("orders.amount").record(amount.toDouble());
    }
}
 
// Or, for a pure-Java enum singleton (thread-safe by JLS guarantee, no double-checked locking needed)
public enum FeatureFlags {
    INSTANCE;
 
    private final Map<String, Boolean> flags = new ConcurrentHashMap<>();
 
    public boolean isEnabled(String key) {
        return flags.getOrDefault(key, false);
    }
}
🚨

Singleton is the most over-used and misused pattern in backend codebases. A "singleton" OrderCache that holds mutable state shared across every request is a hidden source of test pollution (tests interfere with each other via shared static state) and a concurrency hazard if it isn't carefully synchronized. Prefer Spring-managed beans (which are singletons scoped to the application context, not raw JVM-wide statics) — they're testable, replaceable, and don't leak state across test runs the way a static field does.

ApproachThread-safe?Testable?When to use
Static field, eager initYesPoor (global state)Rarely — legacy code
Double-checked lockingYes (with volatile)PoorNever in new code — use DI
Enum singletonYes (JLS guaranteed)PoorSimple, stateless global constants
Spring @Component/@BeanYes (container-managed)Good — injectable, mockableDefault choice for services in a Spring app

2. Factory

Intent: encapsulate object-creation logic, especially when the concrete type to instantiate depends on runtime input, and the caller shouldn't need to know the full set of concrete classes.

The problem it solves

java
// Without a factory: this branching logic gets duplicated everywhere
// a payment gateway needs to be selected.
public PaymentGateway pickGateway(PaymentMethod method) {
    if (method == PaymentMethod.CREDIT_CARD) {
        return new StripeGateway(stripeApiKey);
    } else if (method == PaymentMethod.UPI) {
        return new RazorpayGateway(razorpayKey, razorpaySecret);
    } else if (method == PaymentMethod.WALLET) {
        return new PaytmGateway(paytmMerchantId);
    }
    throw new IllegalArgumentException("Unsupported: " + method);
}

Factory implemented cleanly

java
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
    PaymentMethod supportedMethod();
}
 
@Component
public class PaymentGatewayFactory {
    private final Map<PaymentMethod, PaymentGateway> gatewaysByMethod;
 
    // Spring injects every PaymentGateway bean; the factory just indexes them.
    // Adding a new gateway means adding a new @Component — this file never changes.
    public PaymentGatewayFactory(List<PaymentGateway> gateways) {
        this.gatewaysByMethod = gateways.stream()
            .collect(Collectors.toUnmodifiableMap(PaymentGateway::supportedMethod, Function.identity()));
    }
 
    public PaymentGateway forMethod(PaymentMethod method) {
        PaymentGateway gateway = gatewaysByMethod.get(method);
        if (gateway == null) {
            throw new UnsupportedPaymentMethodException(method);
        }
        return gateway;
    }
}
 
@Component
public class StripeGateway implements PaymentGateway {
    @Override public PaymentResult charge(PaymentRequest request) { /* ... */ return null; }
    @Override public PaymentMethod supportedMethod() { return PaymentMethod.CREDIT_CARD; }
}

Factory method vs. abstract factory

VariantWhat it producesBackend example
Simple factoryOne product, selected by a parameterPaymentGatewayFactory.forMethod(method)
Factory methodSubclasses override which concrete type gets createdNotificationJob.createChannel() overridden per job subtype
Abstract factoryA family of related objects created togetherRegionalComplianceFactory producing a matched {TaxCalculator, InvoiceFormatter, CurrencyConverter} set for "IN" vs "EU"
java
// Abstract factory: a family of related objects that must stay consistent with each other
public interface RegionalComplianceFactory {
    TaxCalculator createTaxCalculator();
    InvoiceFormatter createInvoiceFormatter();
}
 
public class IndiaComplianceFactory implements RegionalComplianceFactory {
    @Override public TaxCalculator createTaxCalculator() { return new GstCalculator(); }
    @Override public InvoiceFormatter createInvoiceFormatter() { return new GstInvoiceFormatter(); }
}
 
public class EuComplianceFactory implements RegionalComplianceFactory {
    @Override public TaxCalculator createTaxCalculator() { return new VatCalculator(); }
    @Override public InvoiceFormatter createInvoiceFormatter() { return new VatInvoiceFormatter(); }
}
// Guarantees you never accidentally pair a GstCalculator with a VatInvoiceFormatter

In a Spring codebase, the "factory" is often just Spring itself. Injecting List<PaymentGateway> and indexing by a discriminator (as above) gets you the Factory pattern's benefit — open for extension, closed for modification — without writing a switch statement anywhere. Reach for a hand-written factory class mainly when the creation logic itself is nontrivial (multi-step configuration, conditional wiring) rather than pure lookup.


3. Builder

Intent: construct complex objects step by step, especially ones with many optional fields, without a telescoping constructor or an error-prone all-args constructor call.

The problem it solves

java
// BAD: telescoping constructors or a giant all-args constructor.
// Callers can't tell which positional argument is which, and adding
// a field means an ABI-breaking change to every call site.
public class OrderRequest {
    public OrderRequest(String customerId, List<LineItem> items, String couponCode,
                         String shippingAddress, String billingAddress, boolean giftWrap,
                         String giftMessage, DeliverySpeed deliverySpeed, String notes) {
        // 9 positional parameters — a call site is unreadable
        // and every optional field must be passed, even as null
    }
}
 
// Call site — which boolean is which? which string is billing vs shipping?
new OrderRequest("cust-1", items, null, "221B Baker St", "221B Baker St",
                  false, null, DeliverySpeed.STANDARD, null);

Builder implemented cleanly

java
public final class OrderRequest {
    private final String customerId;
    private final List<LineItem> items;
    private final String couponCode;          // optional
    private final String shippingAddress;
    private final String billingAddress;
    private final boolean giftWrap;            // optional, defaults false
    private final String giftMessage;          // optional
    private final DeliverySpeed deliverySpeed; // optional, defaults STANDARD
 
    private OrderRequest(Builder b) {
        this.customerId = Objects.requireNonNull(b.customerId, "customerId required");
        this.items = List.copyOf(Objects.requireNonNull(b.items, "items required"));
        if (this.items.isEmpty()) {
            throw new IllegalArgumentException("items must not be empty");
        }
        this.couponCode = b.couponCode;
        this.shippingAddress = Objects.requireNonNull(b.shippingAddress, "shippingAddress required");
        this.billingAddress = b.billingAddress != null ? b.billingAddress : b.shippingAddress;
        this.giftWrap = b.giftWrap;
        this.giftMessage = b.giftMessage;
        this.deliverySpeed = b.deliverySpeed != null ? b.deliverySpeed : DeliverySpeed.STANDARD;
    }
 
    public static Builder builder() { return new Builder(); }
 
    public static final class Builder {
        private String customerId;
        private List<LineItem> items;
        private String couponCode;
        private String shippingAddress;
        private String billingAddress;
        private boolean giftWrap;
        private String giftMessage;
        private DeliverySpeed deliverySpeed;
 
        public Builder customerId(String customerId) { this.customerId = customerId; return this; }
        public Builder items(List<LineItem> items) { this.items = items; return this; }
        public Builder couponCode(String couponCode) { this.couponCode = couponCode; return this; }
        public Builder shippingAddress(String address) { this.shippingAddress = address; return this; }
        public Builder billingAddress(String address) { this.billingAddress = address; return this; }
        public Builder giftWrap(boolean giftWrap) { this.giftWrap = giftWrap; return this; }
        public Builder giftMessage(String message) { this.giftMessage = message; return this; }
        public Builder deliverySpeed(DeliverySpeed speed) { this.deliverySpeed = speed; return this; }
 
        public OrderRequest build() { return new OrderRequest(this); }
    }
}
java
// Call site — self-documenting, optional fields are obviously optional,
// and validation runs once, centrally, in the private constructor.
OrderRequest request = OrderRequest.builder()
    .customerId("cust-1")
    .items(cartItems)
    .shippingAddress("221B Baker St")
    .giftWrap(true)
    .giftMessage("Happy Birthday!")
    .deliverySpeed(DeliverySpeed.EXPRESS)
    .build();
💡

Records reduce the need for Builder on simple DTOs. If every field is required and there are only 2-4 of them, a Java record (record LineItem(String sku, int quantity, Money unitPrice) {}) is simpler than a builder and gets you equals()/hashCode()/toString() for free. Reach for Builder specifically when you have several optional fields, need validation that spans multiple fields, or want a fluent, readable construction call site — records don't solve the "9 constructor parameters" readability problem on their own.

Builder vs. record vs. all-args constructor

ApproachBest forWeakness
All-args constructor1-3 required fieldsUnreadable beyond ~4 params, no optional field support
Java recordImmutable data carriers, all fields requiredNo fluent optional-field ergonomics
BuilderMany fields, several optional, cross-field validationMore boilerplate to hand-write (or use Lombok @Builder)
Builder + record (record with a static builder())Immutable result with fluent constructionSlight duplication between builder fields and record components

4. Strategy

Intent: define a family of interchangeable algorithms, encapsulate each one, and make them swappable at runtime — the client depends only on the common interface.

Backend use case: payment gateway selection

java
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
}
 
public class StripeGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(PaymentRequest request) {
        // Stripe-specific HTTP call and response mapping
        return PaymentResult.success("stripe_txn_id");
    }
}
 
public class RazorpayGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(PaymentRequest request) {
        // Razorpay-specific HTTP call and response mapping
        return PaymentResult.success("razorpay_txn_id");
    }
}
 
// CheckoutService is entirely decoupled from which gateway actually runs —
// this is Strategy and Dependency Inversion working together.
public class CheckoutService {
    private final PaymentGateway paymentGateway;
 
    public CheckoutService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
 
    public Receipt checkout(Cart cart) {
        PaymentResult result = paymentGateway.charge(cart.toPaymentRequest());
        return new Receipt(cart, result);
    }
}

Strategy with lambdas — no class hierarchy needed for stateless algorithms

Modern Java lets you skip the ceremony of a full class per strategy when the algorithm is a pure function:

java
@FunctionalInterface
public interface PricingStrategy {
    Money compute(Order order);
}
 
public class PricingEngine {
    private final Map<CustomerTier, PricingStrategy> strategies = Map.of(
        CustomerTier.STANDARD, order -> order.getSubtotal(),
        CustomerTier.PREMIUM,  order -> order.getSubtotal().multiply(0.95), // 5% off
        CustomerTier.VIP,      order -> order.getSubtotal().multiply(0.90)  // 10% off
    );
 
    public Money price(Order order, CustomerTier tier) {
        return strategies.getOrDefault(tier, Order::getSubtotal).compute(order);
    }
}

Use a lambda-based Strategy for stateless, single-method algorithms; use a class-based Strategy when the algorithm needs its own dependencies or configuration (like StripeGateway needing an API key and an HttpClient). Both are the same pattern — the choice is purely about how much machinery each implementation needs.


5. Observer

Intent: define a one-to-many dependency so that when one object's state changes, all dependents are notified automatically — without the subject knowing concrete details about its observers.

Backend use case: notification fan-out on order events

java
public interface OrderEventListener {
    void onOrderPlaced(OrderPlacedEvent event);
}
 
public class EmailNotificationListener implements OrderEventListener {
    private final EmailSender emailSender;
    public EmailNotificationListener(EmailSender emailSender) { this.emailSender = emailSender; }
 
    @Override
    public void onOrderPlaced(OrderPlacedEvent event) {
        emailSender.send(event.getCustomerEmail(), "Order Confirmed", renderEmail(event));
    }
}
 
public class InventoryReservationListener implements OrderEventListener {
    private final InventoryRepository inventoryRepository;
    public InventoryReservationListener(InventoryRepository repo) { this.inventoryRepository = repo; }
 
    @Override
    public void onOrderPlaced(OrderPlacedEvent event) {
        for (LineItem item : event.getItems()) {
            inventoryRepository.reserve(item.getSku(), item.getQuantity());
        }
    }
}
 
public class AnalyticsListener implements OrderEventListener {
    @Override
    public void onOrderPlaced(OrderPlacedEvent event) {
        // Fire-and-forget metrics/analytics event
    }
}
 
// Subject — knows nothing about email, inventory, or analytics specifically.
// New listeners plug in without OrderEventPublisher (or OrderService) changing.
public class OrderEventPublisher {
    private final List<OrderEventListener> listeners = new CopyOnWriteArrayList<>();
 
    public void subscribe(OrderEventListener listener) {
        listeners.add(listener);
    }
 
    public void publishOrderPlaced(OrderPlacedEvent event) {
        for (OrderEventListener listener : listeners) {
            listener.onOrderPlaced(event);
        }
    }
}

Spring's built-in Observer: ApplicationEventPublisher

In a real Spring Boot service, you rarely hand-roll the publisher — the framework provides one:

java
public record OrderPlacedEvent(String orderId, String customerEmail, List<LineItem> items) {}
 
@Service
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;
 
    public OrderService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }
 
    public Order placeOrder(OrderRequest request) {
        Order order = /* create and persist order */ null;
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId(), request.getCustomerEmail(), request.getItems()));
        return order;
    }
}
 
@Component
public class InventoryReservationListener {
    // @Async runs this on a separate thread pool — a slow inventory call
    // never blocks the HTTP response for order placement.
    @Async
    @EventListener
    public void handle(OrderPlacedEvent event) {
        // reserve stock
    }
}
⚠️

In-process Observer (including Spring's @EventListener) is not durable. If the JVM crashes between publishing an event and a listener processing it, that notification is lost forever — there's no retry, no persistence. For anything that must survive a crash (payment confirmations, order state transitions customers are relying on), publish to a durable message broker (Kafka, SQS, RabbitMQ) instead of — or in addition to — an in-process event bus.


6. Decorator

Intent: attach additional responsibilities to an object dynamically, without modifying its class or affecting other instances of the same class. Decorators implement the same interface as the object they wrap, so they compose transparently.

Backend use case: layering cross-cutting concerns onto a payment gateway

java
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
}
 
public class StripeGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(PaymentRequest request) {
        // real Stripe call
        return PaymentResult.success("txn_123");
    }
}
 
// Decorator #1: adds retry behavior around any PaymentGateway
public class RetryingPaymentGateway implements PaymentGateway {
    private final PaymentGateway delegate;
    private final int maxAttempts;
 
    public RetryingPaymentGateway(PaymentGateway delegate, int maxAttempts) {
        this.delegate = delegate;
        this.maxAttempts = maxAttempts;
    }
 
    @Override
    public PaymentResult charge(PaymentRequest request) {
        PaymentException lastFailure = null;
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return delegate.charge(request);
            } catch (TransientPaymentException e) {
                lastFailure = e;
                sleepWithBackoff(attempt);
            }
        }
        throw new PaymentException("All " + maxAttempts + " attempts failed", lastFailure);
    }
 
    private void sleepWithBackoff(int attempt) { /* exponential backoff */ }
}
 
// Decorator #2: adds metrics around any PaymentGateway
public class InstrumentedPaymentGateway implements PaymentGateway {
    private final PaymentGateway delegate;
    private final MeterRegistry metrics;
 
    public InstrumentedPaymentGateway(PaymentGateway delegate, MeterRegistry metrics) {
        this.delegate = delegate;
        this.metrics = metrics;
    }
 
    @Override
    public PaymentResult charge(PaymentRequest request) {
        Timer.Sample sample = Timer.start(metrics);
        try {
            PaymentResult result = delegate.charge(request);
            metrics.counter("payment.success").increment();
            return result;
        } catch (RuntimeException e) {
            metrics.counter("payment.failure").increment();
            throw e;
        } finally {
            sample.stop(metrics.timer("payment.duration"));
        }
    }
}
java
// Composition at wiring time — each decorator is independently testable,
// and you can stack, reorder, or omit them without touching StripeGateway.
PaymentGateway gateway = new InstrumentedPaymentGateway(
    new RetryingPaymentGateway(
        new StripeGateway(),
        3
    ),
    meterRegistry
);
 
gateway.charge(request);
// Call order: Instrumented -> Retrying -> Stripe
// Metrics wrap the ENTIRE retry loop, including all retry attempts

Decorator order matters and is a real design decision. Wrapping retry inside instrumentation (as above) means one metrics event per logical charge() call, covering every retry attempt. Wrapping it the other way — instrumentation inside retry — would emit one metrics event per individual attempt. Neither is "wrong," but they measure different things; be deliberate about which one your dashboards need.

Decorator vs. Strategy — commonly confused

AspectStrategyDecorator
PurposeChoose which algorithm runsAdd behavior around an existing implementation
Relationship to wrapped objectReplaces it entirelyWraps and delegates to it
ComposabilityUsually pick one strategyStack multiple decorators
Backend exampleStripeGateway vs RazorpayGateway — pick oneRetrying(Instrumented(StripeGateway)) — stack both

7. Choosing the Right Pattern

🚨

The biggest anti-pattern is pattern-first design. Don't start a feature by asking "which pattern should I use?" Start by writing the simplest code that works, and reach for a pattern when you feel the specific pain it solves — a switch statement that keeps growing (Strategy/Factory), a class with nine constructor parameters (Builder), the same cross-cutting logic copy-pasted into three classes (Decorator). Patterns applied to problems you don't have yet are pure complexity tax.

Key takeaways

  • Singleton is mostly a solved problem in Spring codebases — @Component gives you container-scoped singletons that are testable, unlike raw static fields.
  • Factory shines when Spring already gives you a List<Interface> of implementations to index — you often don't need to write factory boilerplate at all.
  • Builder solves the "many optional fields" readability problem that records alone don't — reach for it once you have more than 2-3 optional parameters or cross-field validation.
  • Strategy and Dependency Inversion are two sides of the same coin: a PaymentGateway interface is Strategy from the algorithm-selection angle and DIP from the dependency-direction angle.
  • Observer decouples publishers from subscribers, but in-process implementations (including Spring's @EventListener) are not durable — use a message broker for anything that must survive a crash.
  • Decorator composes cross-cutting concerns (retry, metrics, caching, auth) around a core implementation without touching its code — and the order you stack decorators changes what they measure or enforce.
  • Every pattern in this guide has a cost: an extra interface, an extra layer of indirection, an extra file to navigate. Apply each one when its specific pain shows up in your codebase, not preemptively.

Interview Questions

  • Why is a hand-rolled Singleton with double-checked locking rarely needed in a Spring Boot codebase? What replaces it?
  • What's the difference between a Factory Method and an Abstract Factory? Give a backend example of each.
  • When would you choose a Builder over a Java record for a DTO? When would a record alone be enough?
  • Implement a Strategy pattern for selecting a payment gateway at runtime based on a PaymentMethod enum.
  • What's the difference between a lambda-based Strategy and a class-based Strategy? When would you use each?
  • How does Spring's ApplicationEventPublisher implement the Observer pattern? What are its limitations for critical business events?
  • Why is Observer generally unsuitable for events that must survive a process crash? What would you use instead?
  • Explain how Decorator differs from Strategy, given that both implement the same interface as what they replace or wrap.
  • If you stack a retry decorator and a metrics decorator around a payment gateway, does the order matter? Why?
  • What problem does the Builder pattern solve that a large all-args constructor does not?
  • Why might indexing a List<PaymentGateway> injected by Spring be a better "factory" than writing a switch statement?
  • Describe a real scenario where applying a design pattern would have been over-engineering. What did you do instead?
  • How would you unit test a class that depends on a Strategy interface, versus a class that instantiates its algorithm directly?
  • What thread-safety guarantee does an enum singleton provide that a naive static field with lazy initialization does not?