Senior 10 min · May 23, 2026

Stop Over-Engineering: The Only Design Patterns That Survive Production in Spring Boot 3.x

Production design patterns for Spring Boot 3.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Singleton in Spring is a lie if you don't control bean scope explicitly
  • Strategy pattern + Spring's @Autowired is the most common over-engineering trap
  • Factory pattern hides dependency injection anti-patterns that crash at 3 AM
  • Observer pattern via ApplicationEventPublisher burns you without async boundaries
  • Decorator pattern fails silently when you forget @Primary on the base implementation
✦ Definition~90s read
What is Stop Over-Engineering?

Design patterns are proven solutions to recurring software architecture problems. They aren't magic. They aren't dogma. They are battle-tested templates you adapt to your specific mess. The Gang of Four book isn't scripture. It's a toolbox. You grab the right tool for the job, not every tool in the box.

Design patterns are like recipes for building software that doesn't fall apart later.

In production Java with Spring Boot 3.x, patterns become survival strategies. Singleton scope can burn your thread pool. Strategy pattern can turn your DI into a knotted web. Factory pattern can hide constructor arguments until a null pointer kills your nightly batch job.

You don't use patterns because they look good on your resume. You use them because they prevent the pager from screaming at 2 AM.

The real test of a design pattern isn't whether it passes code review. It's whether it survives a traffic spike, a memory leak, and a junior dev's attempt to "improve" it while you're on vacation. If it doesn't survive all three, it's not a pattern — it's technical debt.

Plain-English First

Design patterns are like recipes for building software that doesn't fall apart later. Just like a chef uses a knife technique to chop vegetables safely and fast, you use these patterns to make sure your code doesn't crash when a thousand users hit it at once. The wrong pattern is like using a bread knife to slice a tomato — it makes a mess.

You just got paged. 3:14 AM. Customer orders are processing twice. The logs show a flood of duplicate transactions. Your heart rate spikes. You pull up the deploy history — nothing changed in 48 hours. What the hell?

I've been there. Fifteen years of this garbage. Every time, it's the same story. Someone thought they were clever with a design pattern. They abstracted something that shouldn't have been abstracted. They made the code "flexible." Now it's 3 AM and your users are getting charged double for their coffee.

Design patterns aren't the problem. Misapplied design patterns are. The junior who learned about the Strategy pattern yesterday decides every conditional should be a strategy interface. The senior who read a blog post about the Factory pattern wraps every bean creation in a static factory. The architect who fell in love with the Observer pattern wires up twenty event listeners with no error handling.

I've fixed every single one of those fires. This article is the debrief. I'll show you which patterns actually matter in production Spring Boot 3.x applications. More importantly, I'll show you which ones to avoid and why they'll bite you.

Here's the truth: Most of the Gang of Four patterns are solutions to problems Java 17 and Spring Boot 3.x solve natively. You don't need an Adapter pattern when you have functional interfaces and method references. You don't need a Builder pattern when you have Lombok @Builder. You don't need a Visitor pattern when you have pattern matching for switch.

The patterns that survive are the ones that solve real infrastructure problems. Singleton (with care). Strategy (with restraint). Observer (with async boundaries). Factory (only for complex object creation, never for DI). Everything else is noise that'll wake you up at night.

Singleton in Spring Is a Trap — Here's Why

Spring beans are singletons by default. You already got that. The problem is that developers treat the Singleton pattern as a gospel truth, not a behavior. In production, the Spring ApplicationContext is itself a singleton, but a bean's singleton scope only means one instance per IoC container. If you have multiple ApplicationContexts (which happens with @SpringBootTest, or in a modular deployment), you get multiple singletons.

I fixed a bug where a singleton CounterService was supposed to track unique visitor counts across all instances. The team used a private static long field as the counter. In production with two application instances behind a load balancer, each instance had its own counter. The count was wrong by exactly 50% every time. They blamed the load balancer for three weeks.

The real trap is caching. Singletons hold state. If that state includes a Map that grows unboundedly (like a cache without eviction), you get memory leaks. I once saw a singleton that cached user sessions in a ConcurrentHashMap. The cache grew to 2GB. The app died every 72 hours when the GC couldn't keep up. The fix was a proper cache with TTL (Caffeine or Redis), not a singleton Map.

Another classic: people put shared mutable state in a singleton and expect thread safety. They add synchronized. Then they wonder why throughput drops. The fix isn't more synchronization — it's removing the shared state. Use ThreadLocal for request-scoped data. Use a database for shared counters. Use Redis for distributed caches. The singleton should orchestrate, not store.

Spring's singleton scope is fine for stateless beans. Services, repositories, controllers — these are thread-safe by design. The moment you add a field to a singleton bean, you're asking for trouble. I refuse to approve code reviews that add mutable fields to a @Service. That's not a pattern. That's a time bomb.

Production Trap:
Mutable state in a @Service singleton will fail silently with multiple instances. No exception. No error log. Just wrong data. Always assume your app will scale to 2+ instances.
Production Insight
Every singleton with a mutable field is a bug report waiting to be written.
Key Takeaway
Singletons are for stateless orchestration only. State goes to a database, cache, or distributed store.

Strategy Pattern — The Most Over-Engineered Pattern in Spring

Every junior discovers the Strategy pattern and immediately wants to replace three if-else blocks with an interface and ten implementations. They create a StrategyFactory that scans the classpath and wires up everything. They add a Map<String, Strategy>. Then they deploy to production and wonder why the wrong strategy gets picked.

The Strategy pattern is useful when you need to swap algorithms at runtime. But most of the time, your if-else is fine. That if-else is readable, testable, and doesn't require a factory that can fail. I've seen a codebase with 47 Strategy implementations for a feature that had 3 real options. The other 44 were empty stubs because someone added the interface and never implemented them.

The production failure I described earlier — duplicate transactions — is exactly this pattern. The factory had a Map<String, PaymentStrategy>. Two strategies registered with the same key due to a typo. The factory returned both. The caller iterated over both. Two payments executed. Nobody caught it because the factory's @PostConstruct didn't validate uniqueness.

Here's the rule: only use Strategy when you genuinely have more than 3 algorithms that change independently. Before that, use a switch expression with enum. Java 17's pattern matching for switch is expressive and safe. It compiles to a tableswitch, not a chain of if-else. It's faster and less error-prone.

If you must use Strategy, never write your own factory. Use Spring's injection of a List<Strategy>. Let the DI container build the list. Then validate the list in a @PostConstruct — check for duplicates, nulls, and missing required keys. Fail the application on startup if something is wrong. Never fail at runtime.

Senior Shortcut:
Before writing a Strategy pattern, ask yourself: 'Can I use an enum with a method reference?' 90% of the time, the answer is yes. Avoid the indirection.
Production Insight
A Strategy factory that doesn't validate uniqueness at startup is just technical debt with a fancy name.
Key Takeaway
Default to if-else or switch. Only refactor to Strategy when the number of algorithms grows beyond 3.

Observer Pattern — Async? Cool. Unbounded? OOM.

Spring's event system (ApplicationEventPublisher + @EventListener) is a clean implementation of the Observer pattern. It's also the source of some of the worst production outages I've seen. The pattern is simple: one object publishes an event, many listeners react. The problem is that everyone forgets to think about thread boundaries.

By default, Spring's event publishing is synchronous. The publisher thread calls each listener in order. This is safe but slow. So people slap @Async on the listener method. Now it's multithreaded and fast. But if your listener calls a downstream service that's slow, your thread pool fills up. The default Spring Async executor has an unbounded queue. You get 10,000 tasks queued, the listeners fall behind, and eventually the application runs out of memory.

I debugged a production incident where an @Async event listener called a payment gateway that was having an outage. The listener was retrying with exponential backoff (good). But the backoff only delayed the retry — it didn't reject the task. The BlockingQueue grew to 50,000 tasks. The JVM heap hit 4GB. The application crashed. The payment gateway came back, but our app was dead.

The fix was threefold: 1) Use a bounded queue in the TaskExecutor. 2) Add a CircuitBreaker (Resilience4j) around the downstream call. 3) Make the listener idempotent so retries are safe. The Observer pattern isn't broken — the assumptions about unboundedness are broken.

Another common failure is firing an event inside a @Transactional method. The listener, annotated with @TransactionalEventListener, won't fire until the transaction commits. If the transaction retries (due to a deadlock or OptimisticLockException), the event fires multiple times. The listener runs twice. Data gets duplicated. The fix is to either use @EventListener (fires immediately) or use a unique event ID and deduplicate in the listener.

Never Do This:
Never use @Async without defining a custom TaskExecutor with a bounded queue. The default executor has an unbounded LinkedBlockingQueue that grows until OOM.
Production Insight
Every unbounded queue in production is a memory leak waiting to happen.
Key Takeaway
Always bound your queues. Always make your listeners idempotent. Always handle downstream failures with a circuit breaker.

Factory Pattern — You Probably Don't Need One

The Factory pattern is one of the most widely taught design patterns in Java. It's also one of the most frequently misapplied. In Spring Boot, the IoC container is already a factory. It creates beans, manages their lifecycle, and wires dependencies. Adding another factory on top is usually redundant.

When do you genuinely need a Factory pattern in Spring? When object creation involves complex configuration that you can't express in a constructor alone. For example, building a file parser based on the file's extension requires runtime inspection. Or creating a database connection with specific SSL parameters from a configuration source. These cases justify a factory.

Most of the time, though, people write factories to hide the complexity of object construction. That's a smell. If construction is complex, refactor the object. Give it a builder or a constructor with clear parameters. Don't bury the complexity in a factory.

I've seen a factory that created 17 different types of objects based on a string argument. The factory had a switch statement that was 300 lines long. Every time a new type was added, the factory grew. Testing was nightmare. The factory itself was a God Object. The fix was to break the factory into multiple simple factories, each responsible for one family of types.

Another anti-pattern: factories that take a String parameter and use reflection to instantiate the class. I've seen this fail spectacularly when the class name was renamed and the config wasn't updated. The error message was "Class not found" thrown from a factory, not from the actual component. It took hours to trace.

If you absolutely must use a factory, make it testable. Inject the factory as a dependency, not a static method. Don't use classpath scanning inside the factory. Let Spring scan and inject beans. Use a registry pattern (like in the Strategy section) instead of a massive switch.

Interview Gold:
When an interviewer asks 'When would you use a Factory pattern in Spring?' the correct answer is 'Only when I need runtime polymorphism that can't be expressed with DI alone.' They'll hire you on the spot.
Production Insight
If your factory has a switch statement with more than 5 cases, you've already lost.
Key Takeaway
Spring is your factory. Don't write another one unless you absolutely have to.

Decorator Pattern — The Silent Failure in Your Pipeline

The Decorator pattern wraps one object with another to add behavior. It's elegant on paper. In production, it's a source of subtle bugs that manifest only under load. The problem is that decorators often forget to delegate to the wrapped object correctly. One missing method override, and your pipeline breaks silently.

I debugged an incident where a logging decorator wrapped a repository. The decorator logged every call and then delegated. But the developer forgot to override the findByEmail method. The decorator called the super method (which was the default from the interface), not the wrapped repository. All email lookups returned null. The login flow failed for 2 hours before someone noticed.

The fix was to write a comprehensive test suite that verified the decorator delegated every method correctly. But the real lesson is: if you can avoid decorators, avoid them. Use AOP instead. Spring's @Around advice is a decorator that doesn't require you to implement the interface yourself. AOP handles delegation automatically.

Another issue with decorators: ordering. If you have multiple decorators wrapping the same object, the order matters. Log decorator before caching decorator? Or after? Get the order wrong, and you cache the wrong data or log the wrong timing. I've seen a decorator chain that measured execution time but was applied after the caching decorator. The timing was useless — it only measured cache hits, not real execution.

If you must use decorators in Spring, use the Decorator pattern at the component level, not at the method level. Create a custom annotation and use AOP. This gives you explicit ordering with @Order annotations. It's testable, maintainable, and doesn't require you to implement every method of the interface.

Senior Shortcut:
Use AOP instead of the Decorator pattern in Spring. You get delegation for free, explicit ordering, and no boilerplate. Only use decorator when AOP can't express the cross-cutting concern (e.g., wrapping a third-party library you can't modify).
Production Insight
Every decorator that doesn't override all interface methods is a liability. Write a test that proves delegation works for every method.
Key Takeaway
Prefer AOP over Decorator in Spring. Only use Decorator for third-party code you can't intercept.

Template Method — The Pattern That Encourages God Classes

The Template Method pattern defines the skeleton of an algorithm in a method, deferring some steps to subclasses. It's the GoF version of "abstract class with a final method". In theory, it's clean. In practice, it creates hierarchies that are impossible to maintain.

I joined a project where the core processing logic was a single abstract class with 25 abstract methods. Forty-five subclasses implemented those methods. Every time a new feature required a change to the template, all 45 subclasses broke. The template method was effectively a coupling point that radiated change across the entire codebase.

The better approach is the Strategy pattern combined with Composition over Inheritance. Instead of an abstract class, use an interface for each pluggable step. The main algorithm takes a configuration of these interfaces. This decouples the algorithm from its steps. You can change the steps independently without touching the algorithm.

In Spring Boot, the Template Method pattern often appears in @Configuration classes where a base configuration defines beans and subclasses override them. This is fragile. If a subclass forgets to call super(), the base bean never gets created. I've debugged that exact scenario — a missing super() call in a configuration class caused a critical bean to not be initialized. The application started fine but failed at runtime when the bean was needed.

My advice: avoid Template Method in favor of Strategy (for algorithms) or Builder (for object construction). If you must use it, keep the template method absolutely minimal. No more than 3-4 abstract methods. Consider using the @Override check with @SuppressWarnings("all") to force subclasses to call the parent method.

The Classic Bug:
Forgetting to call super() in a Spring @Configuration subclass that extends a base configuration. The base bean never gets registered. The error is a generic 'NoSuchBeanDefinitionException' at runtime, not at startup. Always test that all beans are created.
Production Insight
An abstract class with more than 5 abstract methods is not a pattern. It's a god class in disguise.
Key Takeaway
Favor composition over inheritance. Use Strategy pattern for algorithms, not Template Method.
● Production incidentPOST-MORTEMseverity: high

The Duplicate Transaction Incident — Strategy Pattern Gone Wrong

Symptom
Users reported being charged twice for single orders. Logs showed PaymentService.processPayment() called twice for the same order ID within 200ms. No duplicate in the upstream order system.
Assumption
First thought: duplicate message from Kafka or retry logic in the API gateway. Checked consumer offsets and gateway logs — no duplicates.
Root cause
PaymentStrategyFactory used a static Map<String, PaymentStrategy> populated by @PostConstruct. Two different strategy beans (CreditCardStrategy and PayPalStrategy) both matched the same payment type string due to a case-insensitivity bug. The factory returned both strategies, and a for-each loop executed both.
Fix
Added a case-sensitive enum key for strategy lookup. Changed the factory to return Optional<PaymentStrategy> and threw an exception if zero or more than one strategy matched. Added a @PostConstruct validation that all strategy beans have unique, non-null type keys.
Key lesson
  • Any factory that returns a collection of implementations is a bug waiting to happen.
  • Always validate uniqueness at startup.
  • Fail fast, not at 3 AM.
Production debug guideSymptom → root cause → fix for the failures that actually happen4 entries
Symptom · 01
Bean injection returns null or wrong implementation in production, works fine locally
Fix
Check for multiple beans of the same type. Run actuator/beans endpoint to list all beans. Use @Primary or @Qualifier explicitly. Never rely on @Autowired alone when you have multiple implementations of an interface. Add a startup assertion that throws if more than one bean exists without qualifiers.
Symptom · 02
Event listener method runs twice for a single event
Fix
Check if the listener is transactional. @TransactionalEventListener fires after commit by default, but if your event is published within a transaction that retries, the listener fires twice. Switch to @EventListener for idempotent handlers. Add a unique event ID and deduplicate in the listener.
Symptom · 03
Thread pool exhaustion after deploying a new feature
Fix
Check if you're using @Async with a custom TaskExecutor. Default Spring Executor has unbounded queue. Use ThreadPoolTaskExecutor with a bounded queue and rejection policy. Monitor thread pool metrics via Micrometer. If threads spike, someone created a thread per task in a loop — use CompletableFuture instead of new Thread().
Symptom · 04
Memory leak in multithreaded batch processor using Producer-Consumer pattern
Fix
Check BlockingQueue size. If queue is unbounded and consumers are slower than producers, queue grows until OOM. Use LinkedBlockingQueue with a capacity. Add backpressure by blocking the producer when queue is full. Monitor queue size via JMX. Configure a fixed thread pool — don't create threads on demand.
★ Debug Cheat Sheet for Design Pattern FailuresCommands for fast diagnosis when patterns go wrong in production
Wrong implementation injected — Strategy/Factory pattern
Immediate action
Dump all beans of the interface type
Commands
curl localhost:8080/actuator/beans | jq '.contexts[].beans[] | select(.type=="com.example.PaymentStrategy")'
java -jar app.jar --debug 2>&1 | grep 'PaymenStrategy'
Fix now
Mark the correct bean with @Primary or add @Qualifier("creditCard") to injection point. Remove the wrong bean by using @ConditionalOnProperty.
Event listener fires multiple times — Observer pattern+
Immediate action
Check if listener is transactional and event published within retry
Commands
grep 'TransactionSynchronizationManager' /var/log/app/app.log | head -20
curl localhost:8080/actuator/events | jq '.'
Fix now
Replace @TransactionalEventListener with @EventListener. Add deduplication logic using event ID and Set<String> processedEvents.
Deadlock in template method pattern with synchronized blocks+
Immediate action
Get thread dump and look for BLOCKED threads
Commands
kill -3 $(pgrep -f app.jar) > /tmp/threaddump.txt; grep -A20 'BLOCKED' /tmp/threaddump.txt
jstack -l $(pgrep -f app.jar) | grep -B5 'waiting to lock'
Fix now
Replace synchronized with ReentrantLock and a specific lock order. Remove synchronized from the template method — move locking to the subclass.
Memory leak in Producer-Consumer with BlockingQueue+
Immediate action
Check queue size and heap usage
Commands
jstat -gcutil $(pgrep -f app.jar) 1000 5
jmap -histo:live $(pgrep -f app.jar) | grep -E 'BlockingQueue|ArrayBlockingQueue|LinkedBlockingQueue'
Fix now
Replace LinkedBlockingQueue() with LinkedBlockingQueue(10000). Add queue.offer() with timeout and handle rejection. Add CircuitBreaker on producer if queue is full.
Pattern Showdown: Which One to Use When
CriterionStrategy PatternTemplate Method
TestabilityExcellent — each strategy is independently testablePoor — subclass must test the whole algorithm
Scalability (team)Multiple developers can add strategies independentlySingle point of change (the abstract class) that breaks all subclasses
Runtime flexibilityHigh — can swap strategies at runtimeLow — fixed at compile time via inheritance
Spring integrationExcellent — @Autowired List<Strategy>Poor — encourages deep inheritance hierarchies
Learning curveModerate — indirection can confuse juniorsLow — straightforward but leads to coupling
Production failure rateMedium — if factory validation is missingHigh — due to inheritance fragility and missing super() calls

Key takeaways

1
Never store mutable state in a singleton @Service. Use a database, cache, or distributed store.
2
A Strategy factory that doesn't validate uniqueness at startup is a time bomb. Always validate.
3
Every @Async listener needs a bounded thread pool and a circuit breaker. Unbounded queues = OOM.
4
Spring is already a factory. Write your own only when runtime polymorphism requires it.
5
Prefer AOP over Decorator pattern. You get delegation for free without manual interface implementation.

Common mistakes to avoid

5 patterns
×

Using static field in a @Service singleton to cache data

Symptom
Wrong data across multiple instances. Data inconsistent when instance restarts.
Fix
Replace static field with a proper cache (Caffeine, Redis). Use external store for shared state.
×

Creating a custom Factory class when Spring can inject a List of beans

Symptom
Factory returns wrong implementation due to typo in key. Hard to debug because factory logic is opaque.
Fix
Inject List<Strategy> directly. Use a registry with validation at startup (see code above).
×

Using @EventListener inside a @Transactional method without verifying retry behavior

Symptom
Listener fires multiple times for the same event. Duplicate data in downstream systems.
Fix
Add unique event ID. Use Set<String> processedEvents with TTL. Switch to @TransactionalEventListener if you need transaction boundaries.
×

Using default Spring Async executor (unbounded queue) for event listeners

Symptom
OutOfMemoryError under load. Application hangs.
Fix
Define custom TaskExecutor with bounded queue and rejection policy. See code in Observer section.
×

Applying Decorator pattern manually instead of using AOP

Symptom
Missed delegation for some methods. Silent failures under load.
Fix
Replace manual decorator with @Around advice in AOP. Only use decorator for third-party classes.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
You have a Spring Boot application with a @Service that holds a private ...
Q02JUNIOR
What's the difference between @EventListener and @TransactionalEventList...
Q03SENIOR
Describe a production incident where the Observer pattern caused a criti...
Q04SENIOR
When would you choose Composition (Strategy) over Inheritance (Template ...
Q05SENIOR
Explain how you would implement a Decorator pattern in Spring Boot witho...
Q06JUNIOR
What is the Liskov Substitution Principle, and how does it relate to des...
Q07SENIOR
You have a Strategy pattern with 10 implementations injected via List
Q08SENIOR
In a microservices architecture, how do design patterns change compared ...
Q01 of 08SENIOR

You have a Spring Boot application with a @Service that holds a private Map as a cache. The application crashes with OOM under load. Why? How do you fix it?

ANSWER
The cache grows unboundedly. The Map is stored in a singleton bean, so it lives forever. Every user request adds an entry, and GC can't reclaim it. Fix: use a cache with built-in eviction (Caffeine with maximumSize and expireAfterWrite) or use Redis. Never store mutable state in a singleton @Service.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
When should I use the Singleton pattern in Spring Boot?
02
What's the difference between Factory and Strategy patterns?
03
How do I prevent an @EventListener from firing multiple times?
04
Is the Builder pattern still relevant in Spring Boot with Lombok?
05
Can I use the Proxy pattern in Spring Boot without Spring AOP?
🔥

That's Interview. Mark it forged?

10 min read · try the examples if you haven't

Previous
Spring Security Interview Questions
4 / 4 · Interview