Software Design & Architecture53 min read·By Liyabona Saki··

Java Design Patterns — A Field Guide for Working Backend Engineers

Which Gang-of-Four pattern to reach for, when, and when to skip patterns entirely. A practical reference for Java backend services.

The Over-Abstraction Trap: Patterns as Chains

The most common failure mode in modern Java backend engineering isn't a lack of design patterns; it's the recursive application of them until the intent is buried under five layers of indirection. We’ve all seen the AbstractTransactionalBaseProxyFactory that exists solely to instantiate a single service.

In a distributed system, every abstraction adds a cognitive tax. If a developer has to open four files to understand how a single POST request is processed, the architecture has failed. We use patterns to manage complexity, not to signal that we’ve read a textbook. When p99 latency spikes because of a bottleneck in a reflection-heavy DynamicProxy, no amount of "clean code" justification matters.

The Strategy Pattern vs. The Branching Hellscape

Consider a payment processing gateway. The naive approach—and the one that inevitably leads to a 2,000-line service class—is the nested if-else or switch block.

java
// THE WRONG WAY: The God-Method Switch
public void processPayment(PaymentRequest req) {
    if (req.getGateway().equals("STRIPE")) {
        // 50 lines of Stripe-specific SDK logic
    } else if (req.getGateway().equals("ADYEN")) {
        // 50 lines of Adyen-specific logic
    } else if (req.getGateway().equals("PAYPAL")) {
        // 50 lines of PayPal-specific logic
    }
}

This violates the Open/Closed Principle. Adding a new provider requires modifying a core service, increasing the risk of regression in unrelated payment flows.

The Strategy Pattern is the antidote, but it’s often over-engineered with manual registries. In a modern Spring-based Java environment, we can leverage dependency injection to build a clean, scalable registry.

```java
// THE RIGHT WAY: Polymorphic Strategy Registry
public interface PaymentProvider {
    boolean supports(GatewayType type);
    PaymentResponse execute(PaymentRequest request);
}

@Service @RequiredArgsConstructor public class StripeProvider implements PaymentProvider { @Override public boolean supports(GatewayType type) { return type == GatewayType.STRIPE; }

@Override public PaymentResponse execute(PaymentRequest request) { // Isolated Stripe logic } }

@Service @RequiredArgsConstructor public class PaymentService { private final List<PaymentProvider> providers;

public PaymentResponse process(PaymentRequest request) { return providers.stream() .filter(p -> p.supports(request.getGateway())) .findFirst() .orElseThrow(() -> new UnsupportedOperationException("No gateway found")) .execute(request); } } ```

By using a List<PaymentProvider> injection, the PaymentService never needs to change. New providers are simply discovered at runtime. This reduced our deployment risk in one project from a "scary core change" to a "sidecar addition," dropping our QA cycle for new integrations from three days to four hours.

Decorator Patterns vs. The Interceptor Bloat

When engineers want to add logging, caching, or rate limiting to a service, they often default to Aspect-Oriented Programming (AOP) with custom annotations like @LogExecutionTime. While AOP is powerful, it creates "magic" behavior that is difficult to debug and invisible to static analysis.

The "wrong" way is hiding critical business side-effects in an @Around advice that developers forget exists. If the caching logic fails, the stack trace becomes a nightmare of Proxies and Interceptors.

Instead, use the Decorator Pattern to wrap core logic. This makes the execution chain explicit.

text
[ Controller ] 
      |
[ RateLimitingDecorator ]  <-- Check headers/IP
      |
[ CachingDecorator ]       <-- Check Redis
      |
[ ActualServiceImplementation ] <-- The "Real" Work

The implementation relies on the fact that the decorator implements the same interface as the service it wraps.

```java
public class CachingOrderService implements OrderService {
    private final OrderService delegate;
    private final CacheManager cache;

@Override public Order findById(Long id) { return cache.get(id, () -> delegate.findById(id)); } } ```

This is superior to AOP for one primary reason: Unit Testing. You can test the cache logic by passing a mock OrderService to the decorator without spinning up a full Spring Context or dealing with ByteBuddy-generated proxies.

The Builder Pattern: Beyond Lombok’s `@Builder`

We use the Builder pattern to avoid "Telescoping Constructors" (User(id), User(id, name), User(id, name, email)...). However, the common anti-pattern is using a Builder that allows the creation of inconsistent objects. If your Builder allows me to call .build() without a mandatory email field, the pattern is just a glorified setter-collection.

A truly robust Builder uses a Fluent Step Interface to enforce mandatory fields at compile time.

```java
// THE WRONG WAY: Permissive Builder
User u = User.builder().name("John").build(); // Missing required Email!

// THE RIGHT WAY: Static Type-Safe Stepper public class User { private final String name; private final String email;

private User(String name, String email) { this.name = name; this.email = email; }

public static NameStep builder() { return new Builder(); }

public interface NameStep { EmailStep withName(String name); } public interface EmailStep { User withEmail(String email); }

private static class Builder implements NameStep, EmailStep { private String name; @Override public EmailStep withName(String name) { this.name = name; return this; }

@Override public User withEmail(String email) { return new User(this.name, email); } } }

// Usage (Compiler enforced): User u = User.builder() .withName("John") .withEmail("john@example.com"); // build() isn't even selectable until email is provided ```

This prevents the "Incomplete Object" runtime errors that often plague large-scale Java batch processors. In a high-throughput system handling 10k events/sec, catching a missing field at compile time vs. seeing it bubble up as a NullPointerException inside a deep persistence layer saves hours of log diving.

The Singleton vs. The Scoped Component

The public static final Singleton INSTANCE is the most abused pattern in Java. In a multi-threaded backend, singletons often become contention points.

The anti-pattern is the "Global State Singleton." I once audited a system where a ConfigurationManager singleton held a HashMap of settings. Under heavy load (2,000 concurrent requests), the synchronized keyword on the get() method caused p99 latency to explode from 40ms to 900ms. The threads were simply waiting in line for a lock on a map that rarely changed.

In modern Java, you should almost never write your own Singleton. Let the IoC container (Spring/Guice) manage the lifecycle. If you need global state, use Immutable data structures or ConcurrentHashMap with computeIfAbsent to avoid lock contention.

Hard Lesson: Replacing a synchronized Singleton with a volatile reference to an immutable Configuration object dropped our lock wait time to zero and halved our CPU usage on the API gateway layer.

The Observer Pattern and the Eventual Consistency Lie

The Observer pattern is often implemented synchronously: when A happens, call B, C, and D in the same thread.

java
// THE WRONG WAY: Synchronous Observer
public void completeOrder(Order o) {
    db.save(o);
    emailService.send(o); // What if SMTP is slow?
    inventoryService.update(o); // What if this fails?
}

This transforms a simple database write into a distributed transaction nightmare. If inventoryService fails, do you roll back the DB? Does the user get an error for an order that actually saved?

The correct "Backend" version of the Observer pattern is Asynchronous Event Driven Architecture. In Java, this means using an ApplicationEventMulticaster or an external message broker like RabbitMQ or Kafka.

```java
// THE RIGHT WAY: Decoupled Events
@Transactional
public void completeOrder(Order o) {
    db.save(o);
    eventPublisher.publishEvent(new OrderCompletedEvent(o));
}

@Async @EventListener public void handleEmail(OrderCompletedEvent event) { // Retries can happen here without blocking the user } ```

The critical detail here is the @TransactionalEventListener. It ensures the observer only triggers if the database transaction actually commits. Running this asynchronously decoupled our order ingestion from our third-party integrations, ensuring that a 500ms lag from a transactional email provider didn't impact our order success rate.

State Pattern vs. Enum Flag Hell

If your object has a status field and your methods are littered with if (status == PENDING && action == CANCEL), you are living in "State Hell." It’s brittle and impossible to visualize.

The State Pattern moves the logic into state-specific classes. This is particularly vital for long-running workflows like insurance claims or CI/CD pipelines.

```java
public interface OrderState {
    void transitionToNext(OrderContext ctx);
}

public class PaidState implements OrderState { @Override public void transitionToNext(OrderContext ctx) { ctx.setState(new ShippedState()); // Trigger logistics logic } } ```

Instead of a monolithic OrderProcessor containing all logic for every possible state, each state class handles its own transitions. This isolation makes the code "locally readable"—which is the only kind of readability that matters in a codebase with 100k+ lines of code.

The Factory Pattern and the Reflexive Instantiation Bug

Factory patterns are often used to hide the complexity of object creation. The anti-pattern is the "String-based Dynamic Factory" using Class.forName().

java
// THE WRONG WAY: Reflection Factory
public Worker getWorker(String type) {
    return (Worker) Class.forName("com.app.workers." + type + "Worker").newInstance();
}

This is a security risk (injection), a performance hit (reflection), and it breaks ProGuard/GraalVM native image obfuscation.

In a modern Java backend, the Static Factory Method or a Type-Mapped Supplier Factory is the standard. Use an EnumMap<WorkerType, Supplier<Worker>> to map types to constructors. It is type-safe, allows for pre-instantiation or lazy loading, and is roughly 20x faster than reflection-based instancing in high-frequency loops.

Pruning the Pattern Index

Design patterns are not Lego bricks; they are more like surgical tools. You don't use a scalpel to open a cardboard box.

The biggest indicator of seniority in a backend engineer isn't knowing how to implement a Visitor pattern; it's knowing when a simple for-each loop is better. We reached a point in one of our core services where we deleted a complex Command pattern implementation and replaced it with a simple Map<String, Consumer<Request>>. The result? The code shrunk by 400 lines, the memory footprint decreased because we weren't creating thousands of short-lived Command objects, and the "On-boarding time" for new hires dropped because they didn't have to learn a custom DSL just to add a new API endpoint.

If your pattern requires a README to explain why it exists, it might be the wrong pattern. The best architectures use patterns that are so intuitive they feel like a natural extension of the language, not a layer of bureaucracy on top of it. Choose patterns that make the code self-documenting at the call site, not the implementation site.

What this guide consolidates

The classic Gang-of-Four patterns each had a short page. They have been merged into this single field guide so you can compare them — most real codebases use two or three of these together, rarely just one.

Singleton Pattern in Java — All Variants, Thread Safety, and Spring

Introduction

The Singleton pattern ensures a class has exactly one instance and provides a global access point to it. It's one of the original Gang of Four patterns, and also one of the most abused. Understanding when and how to use it correctly — and when to use Spring beans instead — is a mark of production experience.

The naive implementation breaks under concurrency. Some "fixes" introduce subtle ordering bugs. The cleanest implementation (enum) is unknown to many developers. And in Spring Boot applications, the framework handles singleton lifecycle management for you — making hand-rolled singletons largely unnecessary.

Key Takeaways

  • Never use the naive (unsynchronized) singleton in a multithreaded environment.
  • Double-Checked Locking is correct only with volatile.
  • The Enum singleton is the most robust: thread-safe, serialization-safe, and reflection-proof.
  • The Initialization-on-Demand Holder idiom is a clean lazy variant.
  • Spring beans are singletons by default — use them instead of rolling your own.
  • Hand-rolled singletons introduce global state and complicate testing.

Variant 1: Eager Initialization

```java
public class ConfigurationManager {
  // JVM guarantees class-loading is thread-safe
  private static final ConfigurationManager INSTANCE = new ConfigurationManager();

private final Map<String, String> config = new HashMap<>();

private ConfigurationManager() { // Load from file at construction time config.put("timeout", "30"); }

public static ConfigurationManager getInstance() { return INSTANCE; }

public String get(String key) { return config.getOrDefault(key, ""); } } ```

Thread safety: Yes — JVM guarantees static initializers run once. Lazy: No — instantiated when the class loads. Use when: Instance creation is cheap and guaranteed to be needed.

Variant 2: Naive Lazy (Broken in Multithreaded Code)

```java
public class Cache {
  private static Cache INSTANCE;

private Cache() { /* expensive init */ }

// BROKEN: two threads can both see INSTANCE == null public static Cache getInstance() { if (INSTANCE == null) { // Thread A reads null INSTANCE = new Cache(); // Thread B also reads null, creates another } return INSTANCE; } } ```

In a multithreaded environment, two threads can both pass the null check and create two instances. Never use this.

Variant 3: Synchronized (Correct but Slow)

```java
public class Cache {
  private static Cache INSTANCE;

private Cache() {}

public static synchronized Cache getInstance() { if (INSTANCE == null) INSTANCE = new Cache(); return INSTANCE; } } ```

Thread safety: Yes. Problem: Every call acquires a lock, even after initialization. This is a performance bottleneck on high-traffic paths.

Variant 4: Double-Checked Locking (Correct with volatile)

```java
public class Cache {
  // volatile is MANDATORY — without it, the JVM may publish
  // a partially-constructed object due to reordering
  private static volatile Cache INSTANCE;

private Cache() {}

public static Cache getInstance() { Cache local = INSTANCE; // local variable for performance (avoids two volatile reads) if (local == null) { synchronized (Cache.class) { local = INSTANCE; if (local == null) { INSTANCE = local = new Cache(); } } } return local; } } ```

Thread safety: Yes, with volatile. Lazy: Yes. Correct: Yes (Java 5+ memory model guarantees this with volatile). Downside: Verbose and error-prone to write correctly.

Variant 5: Initialization-on-Demand Holder (Recommended Lazy)

```java
public class Cache {
  private Cache() {}

private static class Holder { // Initialized when Holder is first accessed (lazy) // JVM guarantees this is thread-safe without synchronization static final Cache INSTANCE = new Cache(); }

public static Cache getInstance() { return Holder.INSTANCE; }

public void put(String key, Object value) { /* ... */ } public Object get(String key) { /* ... */ } } ```

Thread safety: Yes — class loading is inherently thread-safe. Lazy: Yes — Holder class is loaded only when getInstance() is called. No locks: No synchronization overhead after initialization. Recommended for hand-rolled singletons that need lazy loading.

Variant 6: Enum Singleton (Most Robust)

```java
public enum AppCache {
  INSTANCE;

private final Map<String, Object> store = new ConcurrentHashMap<>();

public void put(String key, Object value) { store.put(key, value); } public Object get(String key) { return store.get(key); } public void evict(String key) { store.remove(key); } }

// Usage AppCache.INSTANCE.put("user:42", user); Object cached = AppCache.INSTANCE.get("user:42"); ```

Thread safety: Yes — JVM guarantees exactly one enum constant. Serialization-safe: Yes — Java serialization cannot create a second instance of an enum. Reflection-proof: Yes — Constructor.newInstance() throws on enum types. Recommended when you truly need a hand-rolled singleton.

Thread Safety Analysis

| Variant | Thread-safe | Lazy | Performance | Notes | |---|---|---|---|---| | Eager | ✅ | ❌ | Fast | Simple | | Synchronized | ✅ | ✅ | Slow | Lock on every call | | DCL without volatile | ❌ | ✅ | Fast | Subtle JVM bug | | DCL with volatile | ✅ | ✅ | Fast | Verbose | | Holder idiom | ✅ | ✅ | Fast | Clean | | Enum | ✅ | ❌ | Fast | Serialization-safe |

Serialization Safety

A non-enum singleton can be broken by Java serialization:

java
// BROKEN: deserialization creates a new instance
ObjectInputStream in = new ObjectInputStream(new FileInputStream("cache.ser"));
Cache deserialized = (Cache) in.readObject(); // != Cache.getInstance()

Fix with readResolve:

```java
public class Cache implements Serializable {
  private static final long serialVersionUID = 1L;

// Replaces deserialized instance with the existing singleton protected Object readResolve() { return getInstance(); } } ```

Or use the enum variant — it handles this automatically.

Spring Singleton Beans

Spring beans are singletons by default. The IoC container manages the singleton lifecycle:

java
@Service
public class OrderService {
  // Instantiated once per ApplicationContext
  // Spring handles thread safety by using stateless services
}

Differences from the Singleton pattern: - Spring singletons are scoped to the ApplicationContext, not the JVM. Multiple contexts = multiple instances. - Spring manages the lifecycle (init, destroy hooks). - Spring singletons are injectable, overridable in tests, and not globally accessed via a static method.

java
@TestConfiguration
public class TestConfig {
  @Bean @Primary
  public OrderService testOrderService() {
    return new TestOrderService(); // replaces production singleton in tests
  }
}

Testing Singletons

Hand-rolled singletons (accessed via static getInstance()) are notoriously hard to test:

java
// Test pollution: state from one test leaks into the next
@Test void test1() { Cache.getInstance().put("key", "value1"); }
@Test void test2() { assertNull(Cache.getInstance().get("key")); } // FAILS: sees test1's data

Mitigation:

```java
// Add a reset method for tests only
@VisibleForTesting
static void resetForTest() { INSTANCE = null; }

@BeforeEach void setUp() { Cache.resetForTest(); } ```

Better approach: use dependency injection so the "singleton" is a Spring bean — testable, swappable, no reset needed.

When NOT to Use the Singleton Pattern

  • In Spring Boot applications: use @Service, @Component, or @Bean instead. Spring gives you singletons with DI, lifecycle management, and testability for free.
  • When the class holds mutable state shared across threads: this is either a design problem (the state should be somewhere else) or a need for explicit thread-safety everywhere the singleton is used.
  • As a service locator: ServiceLocator.getInstance().getService(OrderService.class) is an anti-pattern — it hides dependencies and breaks testability.
  • When it creates a bottleneck: a single-instance cache with a synchronized get method on a 1000 RPS endpoint.

Production Best Practices

  • In Spring Boot: use beans. Full stop. The @Singleton scope is the default.
  • If you must hand-roll one, use the enum or Holder idiom.
  • Document thread safety: which fields are read-only? Which are ConcurrentHashMap? Which use synchronization?
  • Write a test that proves the singleton survives deserialization correctly if your application uses Java serialization.

FAQ

Q: Is the Singleton pattern considered an anti-pattern? It depends on how it's used. As global mutable state accessed via static methods, it's an anti-pattern in modern code. As a Spring-managed singleton bean with DI, it's perfectly fine.

Q: Can I have multiple Spring contexts with the same bean type? Yes. Each ApplicationContext has its own singleton scope. Integration tests often create a fresh context per test class. The bean is a singleton within one context.

Q: What about Spring's @Scope("prototype")? A prototype-scoped bean creates a new instance every time it's requested. Use it for stateful objects that must not be shared between requests (e.g., a mutable builder).

Related Tutorials

Factory Pattern Explained with Real Spring Boot Examples

Introduction

The Factory pattern is a creational pattern that moves the decision of *which concrete class to instantiate* out of the client and into a dedicated creator. The client depends on an interface. The factory decides what implementation the interface is backed by. This is Dependency Inversion applied to object creation.

In Spring Boot, factories show up in two primary forms: explicit factory beans/methods you write, and Spring's built-in component list injection that acts as an automatic registry factory. Understanding both forms gives you a powerful tool for decoupling creation from use.

Key Takeaways

  • Simple Factory: one static method that selects a class by type.
  • Factory Method: subclasses override to decide what to create.
  • Abstract Factory: creates families of related objects.
  • Spring @Bean methods are factory methods for the IoC container.
  • Spring component list injection is a self-registering factory.
  • Factory pattern is a prerequisite for OCP at creation points.

The Problem: Scattered Creation Logic

java
// Client is coupled to every concrete type
Notification n;
switch (type) {
  case EMAIL -> n = new EmailNotification(smtpHost, smtpPort, from);
  case SMS   -> n = new SmsNotification(twilioAccountSid, twilioToken, fromNumber);
  case PUSH  -> n = new PushNotification(fcmApiKey, fcmProjectId);
  default    -> throw new IllegalArgumentException("Unknown type: " + type);
}
n.send(user, message);

The client must know about configuration keys, constructors, and all concrete types. Adding WhatsApp notifications requires editing this switch in every location it appears.

Variant 1: Simple Factory

```java
public class NotificationFactory {
  private final SmtpConfig smtp;
  private final TwilioConfig twilio;
  private final FcmConfig fcm;

public NotificationFactory(SmtpConfig smtp, TwilioConfig twilio, FcmConfig fcm) { this.smtp = smtp; this.twilio = twilio; this.fcm = fcm; }

public Notification create(NotificationType type) { return switch (type) { case EMAIL -> new EmailNotification(smtp.host(), smtp.port(), smtp.from()); case SMS -> new SmsNotification(twilio.accountSid(), twilio.token(), twilio.from()); case PUSH -> new PushNotification(fcm.apiKey(), fcm.projectId()); }; } }

// Client is clean Notification n = factory.create(NotificationType.EMAIL); n.send(user, message); ```

Benefit: Configuration wiring is in one place. Limitation: Adding a new type still requires editing the factory.

Variant 2: Factory Method (Subclass Decides)

```java
public abstract class NotificationSender {
  // Template method: algorithm defined here
  public final void send(User user, String message) {
    Notification n = createNotification(); // subclass provides the object
    n.prepare(user, message);
    n.dispatch();
    n.log();
  }

// Factory method: subclasses override to choose the type protected abstract Notification createNotification(); }

public class EmailNotificationSender extends NotificationSender { private final SmtpConfig config; public EmailNotificationSender(SmtpConfig config) { this.config = config; }

@Override protected Notification createNotification() { return new EmailNotification(config.host(), config.port()); } }

public class SmsNotificationSender extends NotificationSender { private final TwilioConfig config; public SmsNotificationSender(TwilioConfig config) { this.config = config; }

@Override protected Notification createNotification() { return new SmsNotification(config.accountSid(), config.token()); } } ```

The algorithm lives in the parent. The object creation lives in the subclass. Adding a new type = new subclass.

Variant 3: Abstract Factory (Families of Objects)

Abstract Factory creates families of related objects that must be used together:

```java
// Abstract factory for notification infrastructure
public interface NotificationInfrastructure {
  MessageRenderer renderer();
  MessageSender    sender();
  DeliveryTracker  tracker();
}

@Component @ConditionalOnProperty("notification.channel", havingValue = "email") public class EmailInfrastructure implements NotificationInfrastructure { public MessageRenderer renderer() { return new HtmlRenderer(); } public MessageSender sender() { return new SmtpSender(smtpConfig); } public DeliveryTracker tracker() { return new SmtpDeliveryTracker(); } }

@Component @ConditionalOnProperty("notification.channel", havingValue = "sms") public class SmsInfrastructure implements NotificationInfrastructure { public MessageRenderer renderer() { return new PlainTextRenderer(); } public MessageSender sender() { return new TwilioSender(twilioConfig); } public DeliveryTracker tracker() { return new TwilioDeliveryTracker(); } }

// Client: uses the abstract factory, never knows which channel @Service @RequiredArgsConstructor public class NotificationService { private final NotificationInfrastructure infra;

public void notify(User user, String message) { String rendered = infra.renderer().render(user, message); infra.sender().send(user.contact(), rendered); infra.tracker().record(user.id(), message); } } ```

Switch the entire notification family with one property.

Spring @Bean Methods as Factory Methods

Every @Bean method is a factory method:

```java
@Configuration
public class NotificationConfig {

@Bean public Notification emailNotification(SmtpConfig smtp) { // This is a factory method — creates and configures an EmailNotification EmailNotification n = new EmailNotification(); n.setHost(smtp.host()); n.setPort(smtp.port()); n.setFrom(smtp.from()); return n; }

@Bean @Profile("production") public MessageSender smtpSender(SmtpConfig smtp) { return new SmtpMessageSender(smtp); }

@Bean @Profile("test") public MessageSender capturingSender() { return new CapturingMessageSender(); // stores sent messages for assertions } } ```

Spring Component List Injection as Self-Registering Factory

This is one of the most powerful Spring patterns, and it's essentially a zero-configuration factory:

```java
public interface NotificationSenderStrategy {
  void send(User user, String message);
  NotificationType supports();
}

@Component public class EmailStrategy implements NotificationSenderStrategy { public void send(User u, String msg) { /* SMTP */ } public NotificationType supports() { return NotificationType.EMAIL; } }

@Component public class SmsStrategy implements NotificationSenderStrategy { public void send(User u, String msg) { /* Twilio */ } public NotificationType supports() { return NotificationType.SMS; } }

// The factory: Spring injects ALL implementations automatically @Service public class NotificationDispatcher { private final Map<NotificationType, NotificationSenderStrategy> strategies;

// Spring injects a List of ALL NotificationSenderStrategy beans public NotificationDispatcher(List<NotificationSenderStrategy> all) { this.strategies = all.stream() .collect(Collectors.toMap(NotificationSenderStrategy::supports, s -> s)); }

public void dispatch(User user, String message, NotificationType type) { NotificationSenderStrategy strategy = strategies.get(type); if (strategy == null) throw new UnsupportedOperationException("No sender for: " + type); strategy.send(user, message); } } ```

Adding WhatsApp: one new @Component. Zero edits to NotificationDispatcher.

Real Use Cases

Payment Gateway Factory

```java
public interface PaymentGateway {
  PaymentResult charge(PaymentRequest request);
  String gatewayId();
}

@Component class StripeGateway implements PaymentGateway { public String gatewayId(){return "STRIPE";} /* ... */ } @Component class PaypalGateway implements PaymentGateway { public String gatewayId(){return "PAYPAL";} /* ... */ } @Component class BraintreeGateway implements PaymentGateway { public String gatewayId(){return "BRAINTREE";}/* ... */ }

@Service public class PaymentService { private final Map<String, PaymentGateway> gateways; public PaymentService(List<PaymentGateway> all) { this.gateways = all.stream().collect(Collectors.toMap(PaymentGateway::gatewayId, g -> g)); } public PaymentResult process(PaymentRequest req) { return Optional.ofNullable(gateways.get(req.gatewayId())) .orElseThrow(() -> new IllegalArgumentException("Unknown gateway: " + req.gatewayId())) .charge(req); } } ```

Report Exporter Factory

```java
public interface ReportExporter {
  byte[] export(ReportData data);
  String format();
}

@Component class PdfExporter implements ReportExporter { public String format(){return "PDF"; } /* ... */ } @Component class CsvExporter implements ReportExporter { public String format(){return "CSV"; } /* ... */ } @Component class XlsxExporter implements ReportExporter { public String format(){return "XLSX";} /* ... */ } ```

Testing the Factory Pattern

```java
// Testing a strategy in isolation
@Test
void emailStrategyCallsMailSender() {
  JavaMailSender sender = mock(JavaMailSender.class);
  EmailStrategy strategy = new EmailStrategy(sender);
  strategy.send(new User("user@example.com"), "Hello");
  verify(sender).send(any(SimpleMailMessage.class));
}

// Testing the dispatcher routes correctly @Test void dispatcherUsesCorrectStrategy() { NotificationSenderStrategy emailStrat = mock(NotificationSenderStrategy.class); NotificationSenderStrategy smsStrat = mock(NotificationSenderStrategy.class); when(emailStrat.supports()).thenReturn(NotificationType.EMAIL); when(smsStrat.supports()).thenReturn(NotificationType.SMS);

var dispatcher = new NotificationDispatcher(List.of(emailStrat, smsStrat)); dispatcher.dispatch(user, "Hi", NotificationType.EMAIL);

verify(emailStrat).send(any(), eq("Hi")); verify(smsStrat, never()).send(any(), any()); }

// Testing unknown type handling @Test void throwsForUnknownType() { var dispatcher = new NotificationDispatcher(List.of()); assertThatThrownBy(() -> dispatcher.dispatch(user, "Hi", NotificationType.PUSH)) .isInstanceOf(UnsupportedOperationException.class); } ```

Factory vs Builder vs Abstract Factory

| Pattern | Use case | |---|---| | Simple Factory | Choose which concrete class based on a type/key | | Factory Method | Subclasses decide what to create; algorithm in parent | | Abstract Factory | Create families of objects that must be compatible | | Builder | Construct one complex object step by step with many optional parts |

Production Best Practices

  • Test that all expected strategies are registered: assertThat(dispatcher.registeredTypes()).contains(EMAIL, SMS, PUSH).
  • Use @Order on strategies when priority matters (first-match wins).
  • Validate at startup (in @PostConstruct) that required strategies are present — don't wait for a runtime failure.
  • Log which strategy is selected at DEBUG level for production traceability.

FAQ

Q: When should I use a factory vs just Spring injection? Use a factory when the concrete type depends on *runtime data* (a type string from a request). Spring injection handles *configuration-time* selection (profiles, properties). Factories bridge the gap.

Q: Is the service locator pattern the same as a factory? No. A service locator is called from anywhere (global state). A factory is injected and used locally. Factories are testable; service locators are not.

Q: What's wrong with new inside a service? It welds the service to the concrete type, making it impossible to swap implementations (especially in tests). Factories and Spring injection solve this.

Related Tutorials

Builder Pattern for Clean Object Creation

Introduction

The Builder pattern solves one of the most common readability problems in Java: constructors with too many parameters. When a class has 5, 8 or 12 fields — many of them optional — the constructor call becomes unreadable and error-prone. Callers pass arguments in the wrong order, supply null for fields they don't care about, and reviewers have no idea what new HttpRequest("GET", url, null, true, false, 30, null) actually means.

Builder separates the *construction* of a complex object from its *representation*. You configure an intermediate builder object step by step, then call build() to produce the final immutable result. The call site reads like English, the object is validated before it's created, and the resulting instance cannot be partially constructed.

In production Spring Boot systems, Builder appears constantly: HttpHeaders, ResponseEntity, Spring Security's SecurityFilterChain, Testcontainers GenericContainer, and virtually every REST client DSL you'll encounter are all built on this pattern.

The problem — telescoping constructors

Imagine an HttpRequest class that models outbound API calls:

```java
public class HttpRequest {
    private final String method;
    private final String url;
    private final Map<String, String> headers;
    private final String body;
    private final boolean followRedirects;
    private final int timeoutSeconds;
    private final String authToken;
    private final boolean retryOnFailure;
    private final int maxRetries;

// Constructor 1 — minimal public HttpRequest(String method, String url) { ... } // Constructor 2 — with headers public HttpRequest(String method, String url, Map<String, String> headers) { ... } // Constructor 3 — with auth public HttpRequest(String method, String url, String authToken) { ... } // ... six more constructors ... // Constructor 9 — everything public HttpRequest(String method, String url, Map<String,String> headers, String body, boolean followRedirects, int timeoutSeconds, String authToken, boolean retryOnFailure, int maxRetries) { ... } } ```

This is the telescoping constructor anti-pattern. Problems: - Callers can't tell what the booleans mean without reading the JavaDoc. - Adding a tenth field forces a new constructor or breaks existing callers. - You can pass null for any reference field — no validation at the call site. - new HttpRequest("GET", url, null, null, true, 0, null, false, 0) is legal Java but makes no sense.

The Builder solution

```java
public final class HttpRequest {
    private final String method;
    private final String url;
    private final Map<String, String> headers;
    private final String body;
    private final boolean followRedirects;
    private final int timeoutSeconds;
    private final String authToken;
    private final boolean retryOnFailure;
    private final int maxRetries;

private HttpRequest(Builder b) { this.method = b.method; this.url = b.url; this.headers = Map.copyOf(b.headers); this.body = b.body; this.followRedirects = b.followRedirects; this.timeoutSeconds = b.timeoutSeconds; this.authToken = b.authToken; this.retryOnFailure = b.retryOnFailure; this.maxRetries = b.maxRetries; }

// getters only — no setters, fully immutable public String getMethod() { return method; } public String getUrl() { return url; } // ...

public static Builder builder(String method, String url) { return new Builder(method, url); }

public static final class Builder { // required private final String method; private final String url; // optional — sensible defaults private Map<String, String> headers = new HashMap<>(); private String body = null; private boolean followRedirects = true; private int timeoutSeconds = 30; private String authToken = null; private boolean retryOnFailure = false; private int maxRetries = 3;

private Builder(String method, String url) { Objects.requireNonNull(method, "method is required"); Objects.requireNonNull(url, "url is required"); this.method = method; this.url = url; }

public Builder header(String name, String value) { this.headers.put(name, value); return this; } public Builder body(String body) { this.body = body; return this; } public Builder followRedirects(boolean follow) { this.followRedirects = follow; return this; } public Builder timeoutSeconds(int secs) { this.timeoutSeconds = secs; return this; } public Builder bearerToken(String token) { this.authToken = "Bearer " + token; return this; } public Builder retryOnFailure(int maxRetries) { this.retryOnFailure = true; this.maxRetries = maxRetries; return this; }

public HttpRequest build() { if (timeoutSeconds <= 0) throw new IllegalStateException("timeoutSeconds must be positive"); return new HttpRequest(this); } } } ```

Now the call site is self-documenting:

java
HttpRequest request = HttpRequest.builder("POST", "https://api.example.com/orders")
    .header("Content-Type", "application/json")
    .bearerToken(jwtToken)
    .body(orderJson)
    .timeoutSeconds(10)
    .retryOnFailure(3)
    .build();

Every field has a name. Optional fields have defaults. Required fields are in the factory method. The build() method validates before construction.

Builder with Lombok in Spring Boot

In most Spring Boot codebases, Lombok eliminates the boilerplate:

java
@Value
@Builder(toBuilder = true)
public class CreateOrderRequest {
    @NonNull String customerId;
    @NonNull List<OrderLine> lines;
    String couponCode;           // optional
    String deliveryNotes;        // optional
    @Builder.Default
    boolean expressDelivery = false;
}

@Value makes the class immutable (all fields final, no setters). @Builder generates the full builder. @Builder.Default sets the default for optional fields. @NonNull adds null checks to the builder.

Usage:

```java
CreateOrderRequest req = CreateOrderRequest.builder()
    .customerId("CUST-001")
    .lines(cartItems)
    .couponCode("SAVE10")
    .build();

// toBuilder = true lets you clone and modify CreateOrderRequest express = req.toBuilder() .expressDelivery(true) .build(); ```

Spring Boot real-world example — ResponseEntity Builder

Spring's own ResponseEntity uses this pattern throughout its API:

```java
@RestController
@RequestMapping("/orders")
public class OrderController {

@PostMapping public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req) { Order order = orderService.create(req); URI location = URI.create("/orders/" + order.getId());

return ResponseEntity .created(location) .header("X-Order-Id", order.getId()) .body(new OrderResponse(order)); }

@GetMapping("/{id}") public ResponseEntity<OrderResponse> get(@PathVariable String id) { return orderService.find(id) .map(order -> ResponseEntity.ok(new OrderResponse(order))) .orElse(ResponseEntity.notFound().build()); } } ```

ResponseEntity.created(location).header(...).body(...) is a Builder chain. You configure the response step by step — status, headers, body — and the final build() (implicit in body()) assembles the response.

Step-by-step implementation guide

Step 1 — Identify the candidate. A class with 4+ constructor parameters where many are optional is the primary signal. Also look for constructor calls with multiple null arguments.

Step 2 — Separate required from optional. Required fields go in the Builder's constructor. Optional fields go as builder methods with sensible defaults.

Step 3 — Make the outer class constructor private. Only the Builder can call it. This forces all construction through the builder.

Step 4 — Copy defensive mutable state. In the private constructor, copy mutable collections (Map.copyOf, List.copyOf) so the builder can't mutate the built object.

Step 5 — Add validation to build(). Validate cross-field rules (e.g. retryOnFailure requires maxRetries > 0) in build(), not in the field setters.

Step 6 — Consider toBuilder(). If callers frequently need to produce variants of an existing object, add toBuilder() to copy the current state into a new builder.

Common mistakes

Using Builder when a simple constructor is fine. A class with two required, non-optional fields doesn't need a Builder. new Point(x, y) is clearer than Point.builder().x(3).y(4).build().

Forgetting to validate in build(). Required cross-field rules validated only in setters can be bypassed or can produce confusing errors deep in the constructor. Put invariant checks in build().

Mutable builders shared across threads. Builders are not thread-safe. Each thread should use its own builder instance.

Not copying mutable fields. Storing the builder's List or Map directly lets callers mutate the built object after construction. Always use List.copyOf() and Map.copyOf() in the constructor.

Generating setters on the built object. If the built class has setters, it isn't truly immutable and the Builder's value proposition weakens. Pair Builder with immutability.

How to test Builder-constructed objects

```java
class CreateOrderRequestTest {

@Test void required_fields_cannot_be_null() { assertThatThrownBy(() -> CreateOrderRequest.builder() .customerId(null) .lines(List.of()) .build() ).isInstanceOf(NullPointerException.class) .hasMessageContaining("customerId"); }

@Test void express_delivery_defaults_to_false() { CreateOrderRequest req = CreateOrderRequest.builder() .customerId("C1") .lines(List.of(new OrderLine("SKU-1", 1))) .build();

assertThat(req.isExpressDelivery()).isFalse(); }

@Test void toBuilder_produces_independent_copy() { CreateOrderRequest original = CreateOrderRequest.builder() .customerId("C1").lines(List.of()).build();

CreateOrderRequest express = original.toBuilder().expressDelivery(true).build();

assertThat(original.isExpressDelivery()).isFalse(); assertThat(express.isExpressDelivery()).isTrue(); } } ```

Interview questions

Q: What problem does the Builder pattern solve? A: It solves telescoping constructors — classes with many parameters, especially optional ones. Builder makes construction readable (named fluent methods), safe (validation in build()), and immutable (private constructor, final fields).

Q: How is Builder different from Factory? A: Factory creates objects in one step, hiding which concrete class is instantiated. Builder creates a complex object in multiple steps, allowing fine-grained configuration before construction. They're complementary — a Factory can use a Builder internally.

Q: When would you NOT use the Builder pattern? A: When a class has 2–3 required fields with no optionals. A straightforward constructor is clearer. Also avoid Builder in performance-critical hot paths where object allocation matters — the intermediate Builder object has a cost.

Q: How does Lombok's @Builder differ from a hand-rolled Builder? A: Lombok generates the same structure automatically, reducing boilerplate. The main differences are that Lombok's builder doesn't enforce required fields (all fields are optional by default) and doesn't add validation — you need to add @NonNull for null checks and override build() for cross-field validation.

Key takeaways

  • Builder separates object construction from representation — configure step by step, produce in one build() call.
  • Required fields belong in the Builder constructor; optional fields are builder methods with defaults.
  • The built object should be immutable — private constructor, final fields, defensive copies of mutable state.
  • Validation belongs in build(), not in individual setters.
  • Lombok's @Builder + @Value is the idiomatic Spring Boot approach for request/response DTOs.
  • Don't over-apply it — simple classes with 1–3 required fields don't need a Builder.

Related tutorials

Strategy Pattern for Flexible Business Logic

Introduction

The Strategy pattern is one of the most practically useful Gang-of-Four patterns for backend Java engineers. It solves a concrete, recurring problem: business logic that has multiple variants, where the variant to use is determined at runtime. Without Strategy, that logic ends up in if-else chains or switch statements that grow with every new requirement.

In production Spring Boot services, Strategy appears in payment processing (multiple payment gateways), pricing engines (different discount rules per customer tier), export formats (PDF, CSV, Excel), notification channels (email, SMS, push), and shipping cost calculations. Every time you see a switch on a type or a growing list of if (type.equals(...)) blocks, Strategy is the cure.

Beyond eliminating branching, Strategy aligns directly with the Open/Closed Principle — new behavior is added by writing a new class, not by editing an existing one. Your existing strategies stay untouched, your existing tests keep passing, and the new variant is isolated in its own class.

The problem — growing switch statements

Here is a payment processing method that has been extended three times in six months:

```java
@Service
public class PaymentService {

public PaymentResult process(Order order, String paymentMethod) { switch (paymentMethod) { case "CREDIT_CARD": // validate card number with Luhn // call Stripe API // handle 3DS challenge // map Stripe response to PaymentResult return processStripe(order); case "PAYPAL": // create PayPal order // redirect to PayPal approval URL // capture after approval return processPayPal(order); case "CRYPTO": // generate wallet address // wait for blockchain confirmation // handle network fees return processCrypto(order); case "BANK_TRANSFER": // generate IBAN reference // schedule reconciliation job return processBankTransfer(order); default: throw new IllegalArgumentException("Unknown payment method: " + paymentMethod); } }

// Each private method is 30-50 lines... private PaymentResult processStripe(Order order) { ... } private PaymentResult processPayPal(Order order) { ... } private PaymentResult processCrypto(Order order) { ... } private PaymentResult processBankTransfer(Order order) { ... } } ```

Problems: - This file is edited by 4 different teams (Stripe team, PayPal integration, crypto feature, finance). - Every new payment method adds ~50 lines to a class that already has 200. - Testing Stripe logic requires instantiating PaymentService with all its dependencies, even if none of the others are needed. - Adding Apple Pay means editing this file — violating OCP.

The Strategy solution

Extract each payment method into its own strategy class:

java
// The strategy interface
public interface PaymentStrategy {
    String getMethodCode();
    boolean supports(String methodCode);
    PaymentResult process(Order order);
}
```java
@Component
public class StripePaymentStrategy implements PaymentStrategy {
    private final StripeClient stripe;

public StripePaymentStrategy(StripeClient stripe) { this.stripe = stripe; }

@Override public String getMethodCode() { return "CREDIT_CARD"; }

@Override public boolean supports(String code) { return "CREDIT_CARD".equals(code); }

@Override public PaymentResult process(Order order) { // All Stripe-specific logic lives here StripeCharge charge = stripe.createCharge( order.getTotalCents(), order.getCurrency(), order.getCardToken() ); return PaymentResult.of(charge.getId(), charge.getStatus()); } }

@Component public class PayPalPaymentStrategy implements PaymentStrategy { @Override public String getMethodCode() { return "PAYPAL"; } @Override public boolean supports(String code) { return "PAYPAL".equals(code); } @Override public PaymentResult process(Order order) { /* PayPal logic */ } }

@Component public class CryptoPaymentStrategy implements PaymentStrategy { @Override public String getMethodCode() { return "CRYPTO"; } @Override public boolean supports(String code) { return "CRYPTO".equals(code); } @Override public PaymentResult process(Order order) { /* crypto logic */ } } ```

Now the PaymentService becomes a thin dispatcher:

```java
@Service
public class PaymentService {
    private final Map<String, PaymentStrategy> strategies;

// Spring injects ALL PaymentStrategy beans as a list public PaymentService(List<PaymentStrategy> strategies) { this.strategies = strategies.stream() .collect(Collectors.toMap(PaymentStrategy::getMethodCode, s -> s)); }

public PaymentResult process(Order order, String methodCode) { PaymentStrategy strategy = strategies.get(methodCode); if (strategy == null) throw new IllegalArgumentException("Unsupported payment method: " + methodCode); return strategy.process(order); } } ```

Adding Apple Pay is now a single new class. PaymentService doesn't change. Existing tests keep passing.

Spring Boot real-world example — discount engine

A pricing service that applies different discount rules per customer tier:

```java
public interface DiscountStrategy {
    CustomerTier getTier();
    Money apply(Money price, Order order);
}

@Component public class PremiumDiscountStrategy implements DiscountStrategy { @Override public CustomerTier getTier() { return CustomerTier.PREMIUM; } @Override public Money apply(Money price, Order order) { // 20% off + free shipping over $50 Money discounted = price.multiply(0.80); if (order.getSubtotal().isGreaterThan(Money.of(50))) return discounted.subtract(order.getShippingCost()); return discounted; } }

@Component public class StandardDiscountStrategy implements DiscountStrategy { @Override public CustomerTier getTier() { return CustomerTier.STANDARD; } @Override public Money apply(Money price, Order order) { return price; // no discount } }

@Service public class PricingService { private final Map<CustomerTier, DiscountStrategy> strategies;

public PricingService(List<DiscountStrategy> all) { this.strategies = all.stream() .collect(Collectors.toMap(DiscountStrategy::getTier, s -> s)); }

public Money finalPrice(Money basePrice, Order order, Customer customer) { return strategies .getOrDefault(customer.getTier(), new StandardDiscountStrategy()) .apply(basePrice, order); } } ```

Finance can add EnterpriseDiscountStrategy without touching PricingService.

Step-by-step implementation guide

Step 1 — Identify the variation point. Look for switch on type, if-else chains based on a string/enum, or repeated instanceof checks. Each branch is a candidate strategy.

Step 2 — Define the strategy interface. Include a discriminator method (getMethodCode(), supports(), getTier()) that identifies which variant this is, plus the primary operation method.

Step 3 — Extract each branch into its own class. Move the branch body into a class implementing the interface. Mark each with @Component so Spring discovers it.

Step 4 — Build the dispatcher. Inject List<YourStrategy> in the service constructor. Collect into a Map<Key, Strategy> indexed by the discriminator.

Step 5 — Handle unknown variants gracefully. Return a null-object strategy or throw a clear exception with the unrecognized key in the message.

Strategy vs State vs Command

  • Strategy — choose *how* to do something. The algorithm varies; the context stays the same.
  • State — behavior changes based on the object's internal state. The context mutates its strategy as state transitions.
  • Command — encapsulate a request as an object for queuing, logging or undo.

In practice: Strategy is stateless and swappable by the client. State is owned by the context and transitions automatically.

Common mistakes

Passing the strategy as a string instead of an object. processPayment(order, "STRIPE") is the smell; processPayment(order, stripeStrategy) is the fix. Let the caller select the strategy, not the service do the lookup internally.

Strategies that share mutable state. Strategies injected as Spring singletons must be stateless. Any per-request state belongs in the method parameters or a dedicated context object, not in fields.

Not registering new strategies. When a developer adds a new PaymentStrategy bean but forgets to handle it in the dispatcher, it silently falls through to the default case. Use EnumMap or exhaustive matching to make omissions compile-time errors.

Over-using Strategy for one-off logic. If a variation will only ever have one implementation, a simple method or a lambda is cleaner than a full strategy interface.

How to test Strategy-based code

```java
class StripePaymentStrategyTest {
    StripeClient stripeClient = mock(StripeClient.class);
    StripePaymentStrategy strategy = new StripePaymentStrategy(stripeClient);

@Test void processes_successful_charge() { Order order = OrderFixtures.withCard("tok_visa", 5000, "USD"); when(stripeClient.createCharge(5000, "USD", "tok_visa")) .thenReturn(new StripeCharge("ch_123", "succeeded"));

PaymentResult result = strategy.process(order);

assertThat(result.getTransactionId()).isEqualTo("ch_123"); assertThat(result.getStatus()).isEqualTo(PaymentStatus.SUCCESS); }

@Test void strategy_code_is_CREDIT_CARD() { assertThat(strategy.getMethodCode()).isEqualTo("CREDIT_CARD"); } } ```

Each strategy is independently testable with one focused mock. PaymentService tests verify only dispatch logic:

```java
class PaymentServiceTest {
    PaymentStrategy stripe = mock(PaymentStrategy.class);
    PaymentService service;

@BeforeEach void setup() { when(stripe.getMethodCode()).thenReturn("CREDIT_CARD"); service = new PaymentService(List.of(stripe)); }

@Test void delegates_to_correct_strategy() { Order order = new Order(); service.process(order, "CREDIT_CARD"); verify(stripe).process(order); }

@Test void throws_for_unknown_method() { assertThatThrownBy(() -> service.process(new Order(), "BITCOIN")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("BITCOIN"); } } ```

Interview questions

Q: What problem does the Strategy pattern solve? A: It replaces conditional logic (switch/if-else on type or variant) with polymorphism. Each variant is encapsulated in its own class, making it easy to add new variants without modifying existing code — which is the Open/Closed Principle.

Q: How does Spring's dependency injection make Strategy easier to implement? A: Spring can inject all implementations of an interface as a List<Strategy>. This means the dispatcher never needs to know the full set of strategies — it discovers them at startup from the application context. Adding a new strategy is just annotating a new class with @Component.

Q: How is Strategy different from polymorphism? A: Regular polymorphism is typically resolved at construction time via inheritance. Strategy is more flexible — the algorithm can be swapped at runtime, injected externally, or selected from a registry. It externalizes the "choice" from the object that uses it.

Q: When would you NOT use Strategy? A: When there is truly only one variant and you're just anticipating future ones. YAGNI applies — don't introduce the pattern speculatively. Also avoid it when the variation is trivial enough to express as a lambda or method reference passed directly.

Key takeaways

  • Strategy replaces switch/if-else branches with polymorphism — each variant is its own class.
  • Spring's List<Interface> injection makes strategy registration automatic.
  • Strategies must be stateless when injected as singletons.
  • Each strategy is independently unit-testable with minimal mocks.
  • Strategy directly enables the Open/Closed Principle — new variants don't touch existing code.
  • Don't apply speculatively — only when you have multiple real variants today.

Related tutorials

Observer Pattern in Event-Driven Systems

Introduction

The Observer pattern is the foundation of event-driven architecture at the class level. It defines a one-to-many dependency: when one object changes state, all registered dependents are notified and updated automatically. In backend Java systems, this translates directly to decoupled side effects — when an order is placed, confirmation emails are sent, inventory is reserved, analytics are tracked, and fraud detection runs, all without the order-placement code knowing any of that exists.

Without Observer, you get the dreaded service method that does twelve things:

java
public void placeOrder(Order order) {
    orderRepository.save(order);
    emailService.sendConfirmation(order);     // coupled
    inventoryService.reserve(order);          // coupled
    analyticsService.track(order);            // coupled
    fraudService.evaluate(order);             // coupled
    loyaltyService.awardPoints(order);        // coupled
}

Every new side effect adds a new dependency to OrderService. The class grows, tests get harder, and the class has to change every time marketing wants a new reaction to an order being placed.

Observer — and Spring's ApplicationEventPublisher — fixes this permanently.

Plain Java implementation

The pure Observer pattern before Spring:

```java
// Observer interface
public interface OrderEventListener {
    void onOrderPlaced(Order order);
}

// Subject (observable) public class OrderService { private final List<OrderEventListener> listeners = new ArrayList<>(); private final OrderRepository repository;

public OrderService(OrderRepository repository) { this.repository = repository; }

public void subscribe(OrderEventListener listener) { listeners.add(listener); }

public void placeOrder(Order order) { repository.save(order); // notify all observers for (OrderEventListener listener : listeners) { listener.onOrderPlaced(order); } } }

// Concrete observer public class EmailNotificationObserver implements OrderEventListener { private final EmailClient email;

public EmailNotificationObserver(EmailClient email) { this.email = email; }

@Override public void onOrderPlaced(Order order) { email.send(order.getCustomerEmail(), "Your order " + order.getId() + " is confirmed!"); } } ```

This works but requires manual registration:

java
OrderService orderService = new OrderService(repository);
orderService.subscribe(new EmailNotificationObserver(emailClient));
orderService.subscribe(new InventoryObserver(inventoryClient));

Spring's ApplicationEventPublisher — the production approach

Spring's event system is Observer with zero boilerplate wiring:

```java
// 1. Define the event
public record OrderPlacedEvent(Order order, Instant occurredAt) {}

// 2. Publish from the service @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository repository; private final ApplicationEventPublisher events;

@Transactional public Order placeOrder(CreateOrderRequest req) { Order order = Order.from(req); repository.save(order); // fire and forget — observers are discovered automatically events.publishEvent(new OrderPlacedEvent(order, Instant.now())); return order; } }

// 3. Each observer is its own @Component @Component public class OrderConfirmationEmailListener { private final EmailService email; public OrderConfirmationEmailListener(EmailService email) { this.email = email; }

@EventListener public void handle(OrderPlacedEvent event) { email.sendConfirmation(event.order()); } }

@Component public class InventoryReservationListener { private final InventoryService inventory; public InventoryReservationListener(InventoryService inventory) { this.inventory = inventory; }

@EventListener public void handle(OrderPlacedEvent event) { inventory.reserve(event.order().getLines()); } }

@Component public class FraudDetectionListener { private final FraudService fraud;

@EventListener public void handle(OrderPlacedEvent event) { fraud.evaluate(event.order()); } } ```

Adding a new side effect is a new @Component with @EventListener. OrderService never changes.

Async observers with @Async

By default, Spring event listeners run synchronously in the publisher's thread. For operations that shouldn't delay the HTTP response (sending emails, pushing analytics), make them async:

```java
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean
    public TaskExecutor eventTaskExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(4);
        exec.setMaxPoolSize(16);
        exec.setQueueCapacity(100);
        exec.setThreadNamePrefix("event-");
        exec.initialize();
        return exec;
    }
}

@Component public class AnalyticsListener { @Async("eventTaskExecutor") @EventListener public void handle(OrderPlacedEvent event) { // runs in a separate thread — doesn't block the HTTP response analyticsClient.track("order_placed", event.order().getId()); } } ```

Warning: async listeners run outside the original transaction. If the listener throws, the transaction has already committed. Design async listeners to be idempotent.

Transactional event listeners

For listeners that must run only after the database transaction commits:

```java
@Component
public class OutboxPublisher {

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(OrderPlacedEvent event) { // safe to call external APIs — the order is definitely persisted kafkaTemplate.send("orders", event.order().getId(), event); } } ```

@TransactionalEventListener phases: - AFTER_COMMIT — most common; runs after successful commit - AFTER_ROLLBACK — for compensation logic - BEFORE_COMMIT — for final in-transaction work - AFTER_COMPLETION — always, regardless of outcome

Step-by-step implementation guide

Step 1 — Define your event as an immutable record or class. Include all the data listeners need so they don't have to query the database.

Step 2 — Inject ApplicationEventPublisher into the publisher. Publish after the state change is complete.

Step 3 — Annotate listener methods with @EventListener. Spring matches events to listeners by method parameter type.

Step 4 — Decide sync vs async. Use @Async for side effects that shouldn't block the response. Use @TransactionalEventListener(AFTER_COMMIT) for anything that touches external systems.

Step 5 — Keep listeners focused. One listener, one side effect. Don't put if (event.getType() == X) branching inside a single listener.

Common mistakes

Publishing before the transaction commits. Listeners that call external APIs should use @TransactionalEventListener(AFTER_COMMIT) — otherwise they fire even when the DB write rolls back.

Putting business logic in listeners. Listeners are for side effects, not for business decisions. If the inventory reservation *must* succeed for the order to be valid, that's not an async event — that's synchronous logic in the order service itself.

Hidden control flow. A deeply nested @EventListener chain is hard to trace. Document event flows, and keep the chain shallow (max one level of events-from-listeners).

Not handling listener exceptions. An uncaught exception in a sync listener propagates to the publisher. Use @Async and proper exception handling, or wrap the listener body in a try-catch with logging.

How to test Observer-based code

Test the publisher without needing real listeners:

```java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock OrderRepository repository;
    @Mock ApplicationEventPublisher events;
    @InjectMocks OrderService orderService;

@Test void publishes_OrderPlacedEvent_after_save() { Order order = OrderFixtures.valid();

orderService.placeOrder(order);

// verify the event was published with the correct order ArgumentCaptor<OrderPlacedEvent> captor = ArgumentCaptor.forClass(OrderPlacedEvent.class); verify(events).publishEvent(captor.capture()); assertThat(captor.getValue().order()).isEqualTo(order); } } ```

Test each listener independently:

```java
class OrderConfirmationEmailListenerTest {
    EmailService email = mock(EmailService.class);
    OrderConfirmationEmailListener listener = new OrderConfirmationEmailListener(email);

@Test void sends_confirmation_email_to_customer() { Order order = OrderFixtures.withEmail("test@example.com"); listener.handle(new OrderPlacedEvent(order, Instant.now())); verify(email).sendConfirmation(order); } } ```

Interview questions

Q: What is the Observer pattern and when do you use it? A: Observer defines a one-to-many dependency so that when one object changes state, all its dependents are notified automatically. Use it when a state change in one object should trigger actions in others, but you don't want the object to know about those actions — keeping it decoupled.

Q: What is the difference between synchronous and asynchronous event listeners in Spring? A: Synchronous listeners (@EventListener without @Async) run in the publisher's thread and transaction. Asynchronous listeners run in a separate thread pool and are outside the transaction. Use sync for anything that must be atomic with the publisher; use async for side effects (email, analytics) that shouldn't delay the response.

Q: When should you use a message broker (Kafka, RabbitMQ) instead of Spring events? A: Use Spring events for in-process, within-JVM notifications. Use a message broker when you need durability (events survive restarts), cross-service communication, replay, or fan-out across multiple services. The Observer pattern is the in-process analogue of pub/sub messaging.

Q: What is @TransactionalEventListener and when do you need it? A: It binds event delivery to a specific phase of the surrounding transaction. AFTER_COMMIT is the most important — it ensures the listener only fires when the DB write has successfully committed. Without it, an event listener that calls an external API could fire and then the DB write rolls back, leaving external state inconsistent.

Key takeaways

  • Observer decouples publishers from the side effects they trigger — publishers don't know who is listening.
  • Spring's ApplicationEventPublisher + @EventListener is the production implementation of Observer in Spring Boot.
  • Use @Async for non-blocking listeners; use @TransactionalEventListener(AFTER_COMMIT) for external system calls.
  • Each listener should have one focused responsibility.
  • Test publishers by verifying event publication; test listeners by constructing them directly with mocks.
  • For cross-service events, migrate from Spring events to Kafka or RabbitMQ.

Related tutorials

Decorator Pattern Explained Simply

Introduction

The Decorator pattern lets you add behavior to an object at runtime without subclassing. It wraps the original object with a new class that implements the same interface, does some extra work, and then delegates to the wrapped object. Multiple decorators can be stacked in any order, composing features like building blocks.

In production Java backends, Decorator is everywhere — you just don't always see the name: - java.io.BufferedReader(new InputStreamReader(System.in)) — stacking IO decorators - Spring's @Transactional, @Cacheable, @Retryable — AOP proxies are Decorators - HTTP client interceptors for logging, retry, authentication - Caching wrappers around repository calls - Metrics and tracing instrumentation around service methods

Decorator is the answer when you need to add orthogonal concerns — caching, logging, timing, retrying — without modifying the original class and without creating an inheritance hierarchy that explodes with combinations.

The problem — modification or inheritance explosion

You have a UserRepository and need to add caching, metrics, and logging:

java
// Option 1: modify the original class
@Repository
public class UserRepositoryImpl implements UserRepository {
    public Optional<User> findById(String id) {
        log.info("Finding user {}", id);          // now mixed in
        timer.start();
        Optional<User> result = jdbc.query(...);
        timer.stop();
        cache.put(id, result);                     // also mixed in
        return result;
    }
}

Now UserRepositoryImpl has four responsibilities (data access, caching, logging, metrics) and changes for four different reasons.

java
// Option 2: inheritance explosion
class CachingUserRepository extends UserRepositoryImpl { ... }
class LoggingUserRepository extends CachingUserRepository { ... }
class MetricsUserRepository extends LoggingUserRepository { ... }
// And: LoggingCachingUserRepository? MetricsLoggingUserRepository?
// 2^n combinations with n concerns...

The Decorator solution

```java
// The interface all decorators and the original implement
public interface UserRepository {
    Optional<User> findById(String id);
    List<User> findAll();
    void save(User user);
}

// The real implementation — pure data access, no cross-cutting concerns @Repository public class JdbcUserRepository implements UserRepository { private final JdbcTemplate jdbc; public JdbcUserRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }

@Override public Optional<User> findById(String id) { return jdbc.query("SELECT * FROM users WHERE id = ?", new UserRowMapper(), id).stream().findFirst(); } // ... }

// Caching decorator public class CachingUserRepository implements UserRepository { private final UserRepository delegate; // the wrapped repository private final Cache<String, Optional<User>> cache;

public CachingUserRepository(UserRepository delegate, Cache<String, Optional<User>> cache) { this.delegate = delegate; this.cache = cache; }

@Override public Optional<User> findById(String id) { return cache.get(id, key -> delegate.findById(key)); }

@Override public void save(User user) { delegate.save(user); cache.invalidate(user.getId()); // evict stale entry }

@Override public List<User> findAll() { return delegate.findAll(); // not cached } }

// Metrics decorator public class MetricsUserRepository implements UserRepository { private final UserRepository delegate; private final MeterRegistry metrics;

public MetricsUserRepository(UserRepository delegate, MeterRegistry metrics) { this.delegate = delegate; this.metrics = metrics; }

@Override public Optional<User> findById(String id) { return Timer.builder("repository.user.findById") .register(metrics) .record(() -> delegate.findById(id)); }

@Override public void save(User user) { metrics.counter("repository.user.save").increment(); delegate.save(user); }

@Override public List<User> findAll() { return delegate.findAll(); } } ```

Wiring them together in Spring configuration:

```java
@Configuration
public class RepositoryConfig {

@Bean public UserRepository userRepository( JdbcTemplate jdbc, Cache<String, Optional<User>> cache, MeterRegistry metrics) { // Build the stack: metrics wraps caching wraps JDBC UserRepository base = new JdbcUserRepository(jdbc); UserRepository cached = new CachingUserRepository(base, cache); return new MetricsUserRepository(cached, metrics); } } ```

The rest of the application injects UserRepository and never knows about caching or metrics.

Spring Boot real-world example — retry decorator

A common production need: retry transient failures on external service calls:

```java
public interface PaymentClient {
    PaymentResult charge(String customerId, long amountCents);
}

// Real implementation calling Stripe @Component public class StripePaymentClient implements PaymentClient { @Override public PaymentResult charge(String customerId, long amountCents) { // call Stripe API } }

// Retry decorator public class RetryingPaymentClient implements PaymentClient { private final PaymentClient delegate; private final int maxAttempts; private final Duration backoff;

public RetryingPaymentClient(PaymentClient delegate, int maxAttempts, Duration backoff) { this.delegate = delegate; this.maxAttempts = maxAttempts; this.backoff = backoff; }

@Override public PaymentResult charge(String customerId, long amountCents) { TransientPaymentException lastException = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { return delegate.charge(customerId, amountCents); } catch (TransientPaymentException e) { lastException = e; if (attempt < maxAttempts) { log.warn("Payment attempt {}/{} failed, retrying in {}ms", attempt, maxAttempts, backoff.toMillis()); sleep(backoff.multipliedBy(attempt)); // exponential backoff } } // Non-transient exceptions propagate immediately (card declined = don't retry) } throw lastException; }

private void sleep(Duration d) { try { Thread.sleep(d.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } ```

In Spring, you'd likely use @Retryable from Spring Retry instead of writing this by hand — but @Retryable is itself implemented as a Decorator (AOP proxy).

Decorator vs Inheritance vs AOP

| Approach | When to use | |---|---| | Decorator (explicit) | Need fine-grained control, want to test the wrapper, need to stack in a specific order | | Spring AOP (@Cacheable, @Transactional) | Standard cross-cutting concerns already handled by Spring | | Inheritance | True is-a relationship with behavioral override (rare for cross-cutting concerns) |

Prefer Spring AOP for standard concerns (caching, transactions, retry, security). Write explicit Decorators when you need custom behavior, specific ordering, or testable wrappers.

Step-by-step implementation guide

Step 1 — Define the interface. Both the original and all decorators must implement the same interface.

Step 2 — Write the concrete implementation. This is the class that does the real work — pure, focused, no cross-cutting concerns.

Step 3 — Write each decorator. Implement the interface, hold the delegate as a final field set via the constructor. In each method, do your extra work and call delegate.method().

Step 4 — Stack in Spring @Configuration. Wire the decorators in the order you want them to execute — outermost decorator is the one Spring injects everywhere.

Step 5 — Test each decorator in isolation. Use a mocked delegate to verify the decorator's extra behavior (caching, metrics recording, retry logic).

Common mistakes

Forgetting to delegate. Every decorator method must call the delegate. Forgetting a method (especially on large interfaces) silently drops functionality.

Stateful decorators injected as singletons. A caching decorator with a mutable in-memory cache is fine as a singleton only if the cache itself is thread-safe. Otherwise, each thread needs its own.

Stacking order matters. Metrics wrapping caching means you measure cache hits. Caching wrapping metrics means you only measure cache misses (the real calls). Think through the order.

Using Decorator when Spring AOP already does it. @Cacheable, @Transactional, @Retryable, @Timed — if Spring already has an annotation for your concern, use it. Writing a hand-rolled caching decorator in a Spring Boot app is reinventing the wheel.

How to test Decorator code

```java
class CachingUserRepositoryTest {
    UserRepository delegate = mock(UserRepository.class);
    Cache<String, Optional<User>> cache = Caffeine.newBuilder().build();
    CachingUserRepository caching = new CachingUserRepository(delegate, cache);

@Test void returns_cached_result_on_second_call() { User user = new User("u1", "Alice"); when(delegate.findById("u1")).thenReturn(Optional.of(user));

caching.findById("u1"); caching.findById("u1");

// delegate called only once — second call served from cache verify(delegate, times(1)).findById("u1"); }

@Test void invalidates_cache_on_save() { User user = new User("u1", "Alice"); when(delegate.findById("u1")).thenReturn(Optional.of(user)); caching.findById("u1"); // populate cache

caching.save(new User("u1", "Alice Updated")); caching.findById("u1"); // should hit delegate again

verify(delegate, times(2)).findById("u1"); } } ```

Interview questions

Q: What is the Decorator pattern and how does it differ from inheritance? A: Decorator adds behavior to an object at runtime by wrapping it, keeping the same interface. Inheritance adds behavior at compile time through subclassing. Decorator is more flexible — you can combine multiple decorators in any order without an inheritance explosion. Inheritance is static; decoration is dynamic.

Q: How is Decorator used in the Java standard library? A: The java.io package is the classic example: new PrintWriter(new BufferedWriter(new FileWriter("file.txt"))). Each wrapper adds a layer of behavior (buffering, line-ending handling, character encoding) around the same Writer interface. Collections.unmodifiableList() and Collections.synchronizedList() are also Decorators.

Q: How does Spring AOP relate to the Decorator pattern? A: Spring AOP proxies are Decorators generated at runtime. When you annotate a method with @Transactional, Spring wraps your bean with a proxy that implements the same interface, adds transaction management before/after your method, and delegates to your real method. @Cacheable, @Retryable, and @Async work the same way — all are runtime Decorators.

Q: When should you write an explicit Decorator instead of using Spring AOP? A: When you need specific control over the decoration logic that annotations don't provide, when you need to test the decorator in isolation, when the behavior depends on runtime arguments (e.g. cache key derived from method arguments in a custom way), or when you're building a library that shouldn't depend on Spring.

Key takeaways

  • Decorator adds behavior by wrapping an object behind the same interface — same interface, added behavior.
  • Multiple decorators can be stacked in any order to compose features without inheritance.
  • In Spring Boot, prefer @Cacheable, @Transactional, @Retryable for standard concerns — they're AOP-based Decorators.
  • Write explicit Decorators when you need custom behavior, specific ordering, or testable wrapper classes.
  • Each decorator should delegate all methods — forgetting one silently drops functionality.
  • Test decorators in isolation with a mocked delegate, verifying the extra behavior specifically.

Related tutorials

Adapter Pattern in Spring Boot

Introduction

The Adapter pattern is one of the most frequently used structural patterns in real-world Spring Boot services — and most developers have applied it without knowing the name. Every time you wrap a third-party SDK or legacy library behind your own interface, you're writing an Adapter.

The pattern solves a specific problem: two classes that need to work together have incompatible interfaces. Rather than changing either class — which would break existing users or violate the Open/Closed Principle — you write an Adapter that translates between them.

In backend Java, Adapters appear constantly: - Wrapping Twilio behind a NotificationService interface - Bridging a legacy SOAP client to a modern REST interface - Translating between your domain objects and third-party DTOs - Making old persistence code look like a Spring Data repository - Integrating payment gateways (Stripe, PayPal) behind a common PaymentGateway interface

The problem — incompatible interfaces

Your application defines a clean payment interface:

java
// Your domain port — the interface YOUR code depends on
public interface PaymentGateway {
    PaymentResult charge(String customerId, long amountCents, String currency);
    RefundResult refund(String transactionId, long amountCents);
    PaymentStatus getStatus(String transactionId);
}

The Stripe SDK has a completely different API:

```java
// Third-party SDK — you cannot change this
public class StripeClient {
    public Charge createCharge(ChargeCreateParams params) throws StripeException { ... }
    public Refund createRefund(RefundCreateParams params) throws StripeException { ... }
    public Charge retrieveCharge(String chargeId) throws StripeException { ... }
}

public class ChargeCreateParams { public String getCustomer() { ... } public Long getAmount() { ... } public String getCurrency() { ... } // 20 more fields... } ```

Without Adapter, your service would directly import StripeClient, ChargeCreateParams, StripeException, and every other Stripe class. Your entire codebase becomes coupled to Stripe. Switching to PayPal means editing every file that calls the gateway.

The Adapter solution

```java
@Component
public class StripePaymentGatewayAdapter implements PaymentGateway {

private final StripeClient stripe;

public StripePaymentGatewayAdapter(StripeClient stripe) { this.stripe = stripe; }

@Override public PaymentResult charge(String customerId, long amountCents, String currency) { try { ChargeCreateParams params = ChargeCreateParams.builder() .setCustomer(customerId) .setAmount(amountCents) .setCurrency(currency) .build();

Charge charge = stripe.createCharge(params);

return PaymentResult.success(charge.getId(), amountCents); } catch (StripeException e) { // Translate SDK exception to YOUR exception type throw new PaymentException("Stripe charge failed: " + e.getMessage(), e); } }

@Override tml public RefundResult refund(String transactionId, long amountCents) { try { RefundCreateParams params = RefundCreateParams.builder() .setCharge(transactionId) .setAmount(amountCents) .build(); Refund refund = stripe.createRefund(params); return RefundResult.success(refund.getId()); } catch (StripeException e) { throw new PaymentException("Stripe refund failed: " + e.getMessage(), e); } }

@Override public PaymentStatus getStatus(String transactionId) { try { Charge charge = stripe.retrieveCharge(transactionId); return mapStatus(charge.getStatus()); } catch (StripeException e) { throw new PaymentException("Could not retrieve charge status", e); } }

private PaymentStatus mapStatus(String stripeStatus) { return switch (stripeStatus) { case "succeeded" -> PaymentStatus.SUCCESS; case "pending" -> PaymentStatus.PENDING; case "failed" -> PaymentStatus.FAILED; default -> PaymentStatus.UNKNOWN; }; } } ```

Now the rest of your application depends only on PaymentGateway. Stripe is quarantined inside this one class. Switching to PayPal means writing PayPalPaymentGatewayAdapter — nothing else changes.

Spring Boot real-world example — legacy SOAP to REST

Many enterprise teams have legacy SOAP services they must integrate with. The Adapter makes the integration invisible to modern code:

```java
// Your modern domain port
public interface CustomerRepository {
    Optional<Customer> findById(String customerId);
    List<Customer> findBySegment(CustomerSegment segment);
}

// Legacy SOAP client (auto-generated, cannot be changed) public class LegacyCustomerSoapClient { public CustomerSoapResponse getCustomerById(GetCustomerRequest req) { ... } public CustomerListSoapResponse searchCustomers(SearchCustomersRequest req) { ... } }

// The Adapter bridges them @Repository public class LegacyCustomerRepositoryAdapter implements CustomerRepository {

private final LegacyCustomerSoapClient soapClient; private final CustomerSoapMapper mapper;

public LegacyCustomerRepositoryAdapter( LegacyCustomerSoapClient soapClient, CustomerSoapMapper mapper) { this.soapClient = soapClient; this.mapper = mapper; }

@Override public Optional<Customer> findById(String customerId) { GetCustomerRequest req = new GetCustomerRequest(); req.setCustomerId(customerId); try { CustomerSoapResponse response = soapClient.getCustomerById(req); return Optional.of(mapper.toDomain(response)); } catch (CustomerNotFoundException e) { return Optional.empty(); } }

@Override public List<Customer> findBySegment(CustomerSegment segment) { SearchCustomersRequest req = new SearchCustomersRequest(); req.setSegmentCode(segment.getCode()); return soapClient.searchCustomers(req).getCustomers() .stream().map(mapper::toDomain).toList(); } } ```

The domain service injects CustomerRepository and never sees SOAP, XML, or legacy DTOs.

Adapter vs Decorator vs Facade

These three structural patterns look similar but serve different purposes:

  • Adapter — converts one interface to another. The adaptee's API is incompatible; the Adapter makes it compatible. Same behavior, different interface.
  • Decorator — keeps the same interface but adds behavior. Wraps an object to extend it without subclassing.
  • Facade — provides a simpler interface in front of a complex subsystem. Hides complexity rather than translating between interfaces.

Practical question: "Does the wrapped class already implement the interface I need?" - Yes → Decorator - No, needs translation → Adapter - The subsystem has many classes to simplify → Facade

Step-by-step implementation guide

Step 1 — Define your port (interface). Write the interface your domain needs, in your domain's vocabulary. Ignore what the third party looks like.

Step 2 — Identify the adaptee. This is the incompatible class (third-party SDK, legacy code) you need to bridge.

Step 3 — Create the Adapter class. Implement your port interface, hold the adaptee via constructor injection.

Step 4 — Implement each method. Translate your domain types to the adaptee's types, call the adaptee, translate the result back, and convert exceptions to your domain exception types.

Step 5 — Register as a Spring bean. Annotate with @Component, @Repository, or @Service as appropriate. The rest of the application injects the port interface — Spring wires the adapter.

Common mistakes

Letting adaptee types leak through. If your PaymentGateway.charge() returns a StripeCharge, you haven't adapted — you've just delegated. Map everything to your own types.

Catching too broadly. Don't catch Exception and wrap it in your domain exception. Catch the specific SDK exceptions and let unexpected ones propagate or wrap them with enough context to debug.

Not writing a separate mapper. Adapters that inline complex object translation become hard to read and test. Extract mapping logic to a dedicated @Component mapper.

Testing the adapter with integration tests only. Unit test the mapping and exception translation with a mocked adaptee. Integration tests can verify the real SDK works, but shouldn't be the only tests.

How to test Adapter code

```java
@ExtendWith(MockitoExtension.class)
class StripePaymentGatewayAdapterTest {

@Mock StripeClient stripe; StripePaymentGatewayAdapter adapter;

@BeforeEach void setup() { adapter = new StripePaymentGatewayAdapter(stripe); }

@Test void maps_successful_charge_to_PaymentResult() throws StripeException { Charge fakeCharge = mock(Charge.class); when(fakeCharge.getId()).thenReturn("ch_123"); when(stripe.createCharge(any())).thenReturn(fakeCharge);

PaymentResult result = adapter.charge("cus_abc", 5000L, "USD");

assertThat(result.getTransactionId()).isEqualTo("ch_123"); assertThat(result.isSuccess()).isTrue(); }

@Test void wraps_StripeException_in_PaymentException() throws StripeException { when(stripe.createCharge(any())).thenThrow(new CardException("card declined", null, null, null, null, null, null, null));

assertThatThrownBy(() -> adapter.charge("cus_abc", 5000L, "USD")) .isInstanceOf(PaymentException.class) .hasMessageContaining("card declined"); }

@Test void maps_stripe_status_succeeded_to_SUCCESS() throws StripeException { Charge fakeCharge = mock(Charge.class); when(fakeCharge.getStatus()).thenReturn("succeeded"); when(stripe.retrieveCharge("ch_123")).thenReturn(fakeCharge);

assertThat(adapter.getStatus("ch_123")).isEqualTo(PaymentStatus.SUCCESS); } } ```

Interview questions

Q: What is the Adapter pattern and what problem does it solve? A: Adapter converts the interface of a class into another interface that clients expect. It solves the incompatibility problem — when you need two classes to work together but their APIs don't match, rather than modifying either class you write an Adapter that translates between them.

Q: How does Adapter relate to the Dependency Inversion Principle? A: They work together naturally. DIP says high-level modules should depend on abstractions, not concretions. Adapter provides the mechanism: you define the abstraction (port interface) your domain needs, and the Adapter implements it by wrapping the concrete third-party or legacy class. Your domain code depends only on the interface.

Q: When would you use Facade instead of Adapter? A: Use Facade when you want to simplify access to a complex subsystem with many classes — you're hiding complexity. Use Adapter when you have one class with an incompatible interface that you need to make compatible — you're translating between interfaces. Facade reduces complexity; Adapter resolves incompatibility.

Q: How do you test an Adapter? A: Unit test with a mocked adaptee. Test that your method correctly builds the adaptee's request objects, maps responses to your domain types, and translates exceptions. Don't write only integration tests — exception mapping and response translation logic needs unit test coverage.

Key takeaways

  • Adapter converts an incompatible interface into the interface your code expects — same behavior, different shape.
  • The key benefit is isolation: third-party code, legacy code, and SDK-specific exceptions are quarantined in one class.
  • Define your port (interface) first in your domain vocabulary; then write the Adapter to bridge the real implementation.
  • All third-party types should be translated to your domain types inside the Adapter — nothing leaks through.
  • Unit test adapters by mocking the adaptee; focus tests on mapping and exception translation.
  • Adapter directly enables DIP and makes technology swaps straightforward.

Related tutorials

Patterns that hurt more than they help

Not every Gang-of-Four pattern earns its keep in a modern Java codebase. Three worth being suspicious of. Singleton: almost always a global variable in disguise, and it makes testing painful because the state persists across tests. Use dependency injection instead; if there really is one instance, let the DI container enforce it. Abstract Factory: solves a problem — swapping whole families of related objects — that few real codebases have. Applying it speculatively produces four interfaces and no benefit. Visitor: the only way to add operations to a fixed class hierarchy without modifying the classes, but it inverts the flow in a way most engineers find hard to read. Pattern matching (Java 21+) covers many of Visitor's use cases with less ceremony. The patterns worth using nearly every week are still Strategy, Factory Method, Builder, Adapter, Decorator and Observer — the rest deserve a specific justification before you reach for them.

Go deeper

Further reading

#Java#Design Patterns#Software Architecture#OOP#Spring Boot#Singleton#Concurrency#Factory#Builder#Immutability#Strategy#OCP#Observer#Events#Spring#Decorator#Adapter#Integration

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Software Design & Architecture

Essential Software Design Principles Every Developer Should Know

A practical overview of the design principles that separate junior and senior engineers — DRY, KISS, YAGNI, SOLID, separation of concerns and more.

Related tutorials