1. Introduction
Design patterns are useful solutions to recurring software-design problems.
Patterns such as Strategy, Factory, Builder, Adapter, Decorator, Observer, Template Method, and Chain of Responsibility can make Java applications easier to extend when the underlying problem genuinely requires them.
The problem begins when developers introduce patterns before the problem exists.
For example, a simple Spring Boot service that only needs to retrieve a customer from a repository may be expanded into:
- Interface
- Implementation class
- Strategy
- Strategy factory
- Context object
- Command object
- Builder
- Provider
- Abstract base class
The final implementation may technically follow several design patterns, but it becomes harder to understand than the business requirement itself.
This is unnecessary design-pattern usage.
A good code reviewer should therefore not ask:
"Which design pattern can we apply here?"
The better question is:
"What is the simplest design that safely handles the current requirements and realistic expected changes?"
Design patterns should reduce complexity caused by real variation.
They should not create complexity merely to demonstrate architecture knowledge.
2. What This Topic Means
Avoiding unnecessary design patterns means resisting abstractions that do not solve a meaningful design problem.
The goal is not to avoid patterns completely.
The goal is to use them only when they provide concrete benefits such as:
- Isolating genuinely different algorithms
- Supporting multiple implementations
- Separating an external integration
- Preventing large conditional logic from growing
- Creating complex objects safely
- Decoupling event producers from consumers
- Handling a real extension requirement
- Protecting core business code from volatile infrastructure
A pattern becomes unnecessary when the application has no meaningful variation or complexity that requires it.
For example:
public interface CustomerFinder {
Customer find(Long customerId);
}
@Service
public class DatabaseCustomerFinder
implements CustomerFinder {
private final CustomerRepository repository;
public DatabaseCustomerFinder(
CustomerRepository repository) {
this.repository = repository;
}
@Override
public Customer find(Long customerId) {
return repository.findById(customerId)
.orElseThrow();
}
}This interface may be useful if the application genuinely needs multiple customer lookup mechanisms.
However, if:
- There is one implementation
- No alternate source is expected
- No architectural boundary is being protected
- Tests can already mock the repository
then the interface may add no practical value.
The key issue is not whether the code uses an interface.
The issue is whether the abstraction has a reason to exist.
3. Why It Matters in Real Projects
Readability
Unnecessary patterns create additional navigation.
A developer trying to understand:
findCustomer(1001L)may need to open:
- CustomerService
- CustomerLookupContext
- CustomerLookupStrategy
- CustomerLookupStrategyFactory
- DatabaseCustomerLookupStrategy
- CustomerLookupCommand
- CustomerLookupCommandBuilder
before discovering that the code eventually executes:
customerRepository.findById(customerId)The implementation becomes more complicated than the requirement.
Maintainability
Every additional abstraction becomes code that must be:
- Named
- Tested
- Reviewed
- Documented
- Refactored
- Migrated
- Debugged
Unnecessary abstractions increase maintenance cost without reducing meaningful change cost.
Debugging
Extra layers make stack traces longer and runtime flow less obvious.
Instead of:
Controller
↓
CustomerService
↓
CustomerRepositorydevelopers may need to trace:
Controller
↓
Facade
↓
Command
↓
Context
↓
Factory
↓
Strategy
↓
Provider
↓
RepositoryWhen production issues occur, this indirection slows diagnosis.
Testability
Patterns can improve testing when they isolate dependencies.
However, unnecessary patterns often increase the number of objects that tests need to construct or mock.
A simple business test can turn into a test involving several mock collaborators.
Team Development
Over-engineered architecture raises the learning curve for new developers.
Developers may copy existing unnecessary abstractions because they assume the project's complexity is intentional.
Complexity then spreads.
Reliability
More code means more places where:
- Wiring can fail
- Spring beans can conflict
- Incorrect implementation selection can occur
- Configuration can be wrong
- Null values can be introduced
- Logic can be duplicated
The pattern itself may not cause a production bug, but unnecessary moving parts increase the defect surface.
4. Core Concept
The central principle is:
Use a pattern when it removes existing or strongly justified complexity, not when it merely creates the possibility of future flexibility.
Three questions are particularly useful.
Does Real Variation Exist?
Strategy Pattern is valuable when multiple algorithms actually exist.
For example:
CardPaymentProcessor
UpiPaymentProcessor
BankTransferPaymentProcessorIf payment behavior differs significantly and variants continue growing, Strategy can provide value.
If there is only:
DatabaseCustomerFindercreating a customer-finding Strategy may provide little value.
Does the Pattern Localize Change?
A useful pattern should make an expected change easier.
For example, if supporting a new payment provider currently requires modifying five switch statements, Strategy may localize provider-specific behavior.
If adding the pattern still requires modifying the same classes plus new factory configuration, the abstraction may not solve the real problem.
Is the Pattern Simpler Than the Problem It Solves?
A pattern should reduce conceptual complexity.
If a three-line method becomes eight classes, the pattern is probably not justified unless those classes protect an important architectural boundary.
5. Important Rules
- Do not introduce patterns only because they are considered "best practice."
- Identify the actual design problem before selecting a pattern.
- Prefer direct code when there is only one stable behavior.
- Do not create interfaces mechanically for every Spring service.
- Use Strategy when meaningful interchangeable behavior exists.
- Use Factory when object-creation selection is genuinely complex or variable.
- Use Builder when object construction is difficult enough to justify it.
- Do not use Builder merely because a DTO has three fields.
- Use Adapter when integrating incompatible interfaces or isolating infrastructure.
- Do not wrap every dependency in an Adapter automatically.
- Use Observer/events when decoupled notification of multiple consumers is useful.
- Do not introduce asynchronous events for simple sequential logic without a requirement.
- Avoid Abstract Factory unless families of related objects really need coordinated creation.
- Do not add Command classes for every one-line method invocation.
- Prefer Java records for simple immutable data carriers where appropriate.
- Prefer enums over strategy hierarchies when behavior is simple and stable.
- Keep abstraction proportional to expected change.
- Review actual change history before predicting future flexibility.
- Avoid speculative extension points.
- Remove unused abstraction when requirements become simpler.
- Remember that simple code can still follow SOLID principles.
6. Bad Code Example
Consider a Spring Boot customer-management application.
The requirement is simple:
Given a customer ID, retrieve an active customer from the database.
A developer implements the following structure.
Lookup Command
public class CustomerLookupCommand {
private final Long customerId;
private CustomerLookupCommand(
Builder builder) {
this.customerId =
builder.customerId;
}
public Long getCustomerId() {
return customerId;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long customerId;
public Builder customerId(
Long customerId) {
this.customerId =
customerId;
return this;
}
public CustomerLookupCommand build() {
return new CustomerLookupCommand(
this
);
}
}
}Strategy Interface
public interface CustomerLookupStrategy {
boolean supports(
CustomerLookupCommand command
);
Customer find(
CustomerLookupCommand command
);
}Database Strategy
import org.springframework.stereotype.Component;
@Component
public class DatabaseCustomerLookupStrategy
implements CustomerLookupStrategy {
private final CustomerRepository
customerRepository;
public DatabaseCustomerLookupStrategy(
CustomerRepository customerRepository) {
this.customerRepository =
customerRepository;
}
@Override
public boolean supports(
CustomerLookupCommand command) {
return true;
}
@Override
public Customer find(
CustomerLookupCommand command) {
return customerRepository
.findById(
command.getCustomerId()
)
.orElseThrow(
() ->
new CustomerNotFoundException(
command.getCustomerId()
)
);
}
}Strategy Factory
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class CustomerLookupStrategyFactory {
private final List<CustomerLookupStrategy>
strategies;
public CustomerLookupStrategyFactory(
List<CustomerLookupStrategy> strategies) {
this.strategies = strategies;
}
public CustomerLookupStrategy getStrategy(
CustomerLookupCommand command) {
return strategies.stream()
.filter(
strategy ->
strategy.supports(
command
)
)
.findFirst()
.orElseThrow(
() ->
new IllegalStateException(
"No customer lookup strategy found"
)
);
}
}Context
import org.springframework.stereotype.Component;
@Component
public class CustomerLookupContext {
private final CustomerLookupStrategyFactory
strategyFactory;
public CustomerLookupContext(
CustomerLookupStrategyFactory
strategyFactory) {
this.strategyFactory =
strategyFactory;
}
public Customer execute(
CustomerLookupCommand command) {
CustomerLookupStrategy strategy =
strategyFactory.getStrategy(
command
);
return strategy.find(command);
}
}Service
import org.springframework.stereotype.Service;
@Service
public class CustomerService {
private final CustomerLookupContext
lookupContext;
public CustomerService(
CustomerLookupContext lookupContext) {
this.lookupContext =
lookupContext;
}
public Customer getCustomer(
Long customerId) {
CustomerLookupCommand command =
CustomerLookupCommand
.builder()
.customerId(customerId)
.build();
Customer customer =
lookupContext.execute(
command
);
if (!customer.isActive()) {
throw new InactiveCustomerException(
customerId
);
}
return customer;
}
}The implementation works.
However, the application has exactly one customer source:
CustomerRepositoryThere is no alternate lookup strategy.
The factory always returns the same implementation.
The strategy's supports() method always returns:
trueThe design contains flexibility that the system does not currently need.
7. Problems in the Bad Code
Speculative Generality
The architecture assumes future customer lookup strategies without evidence that they will exist.
Possible hypothetical implementations may include:
- Cache customer lookup
- External API lookup
- Legacy database lookup
But none currently exist.
The code pays the complexity cost today for requirements that may never arrive.
Strategy Pattern Without Real Strategies
There is only one implementation:
DatabaseCustomerLookupStrategyA Strategy hierarchy provides little value when behavior is not interchangeable.
Factory Without Selection Logic
The factory exists to select among strategies, but every command matches the only strategy.
This makes the factory largely ceremonial.
Context Adds Another Pass-Through Layer
CustomerLookupContext does not contain meaningful state or algorithm coordination.
It delegates:
factory → strategyThe extra layer makes navigation harder without improving behavior.
Builder for a Single Required Field
CustomerLookupCommand contains one value:
customerIdUsing a nested builder makes construction much more verbose than:
new CustomerLookupCommand(customerId)or simply passing the ID directly.
Excessive Class Count
A one-step database lookup now requires several files.
This increases code review and maintenance cost.
Harder Debugging
A breakpoint on customer retrieval may require stepping through several layers before reaching the repository.
More Spring Beans
Three Spring components are introduced where one service dependency could be enough.
The runtime overhead is normally small, but the architectural overhead is significant.
Hidden Simplicity
The actual business rule is difficult to see:
- Load customer
- Ensure customer exists
- Ensure customer is active
Those rules are buried beneath pattern infrastructure.
8. Code Review Findings
A senior reviewer should identify specific unnecessary abstractions.
Finding 1: Only One Strategy Exists
Before accepting Strategy Pattern, ask:
- What are the interchangeable algorithms?
- What causes selection?
- How often are new implementations expected?
If no meaningful answers exist, Strategy may be premature.
Finding 2: Factory Selection Is Artificial
The factory currently performs:
strategies.stream()
.filter(strategy -> strategy.supports(command))but the only strategy always returns true.
There is no meaningful creation or selection problem.
Finding 3: Context Has No Independent Responsibility
The context simply obtains a strategy and invokes it.
It does not manage:
- Runtime strategy state
- Execution configuration
- Cross-cutting policy
- Algorithm coordination
The class may not be needed.
Finding 4: Builder Solves No Construction Problem
The command has one required field.
A builder increases code without improving clarity.
Finding 5: Abstraction Cost Exceeds Change Benefit
The reviewer should compare the number of concepts introduced with the actual business requirement.
Finding 6: Simpler Code Would Still Be Testable
Direct repository injection does not prevent unit testing.
The service can mock CustomerRepository directly.
Therefore the additional strategy interface is not required merely "for testing."
9. Reviewer Comment Example
There is currently only one lookup implementation and supports() always returns true. Can we keep this as a direct repository dependency until we have a real strategy-selection requirement?The factory currently has no meaningful creation or selection logic. Removing it would make the customer lookup flow easier to follow.CustomerLookupCommand only contains customerId. A builder seems heavier than the construction problem requires; a record or direct method parameter would be simpler.CustomerLookupContext is currently a pass-through between the service and strategy factory. Could we remove this layer unless it owns additional behavior?I would avoid adding this extension structure for hypothetical future customer sources. We can introduce the abstraction when a second implementation or infrastructure boundary actually appears.
10. Improved Code
The same requirement can be implemented directly.
Customer Service
import org.springframework.stereotype.Service;
@Service
public class CustomerService {
private final CustomerRepository
customerRepository;
public CustomerService(
CustomerRepository customerRepository) {
this.customerRepository =
customerRepository;
}
public Customer getActiveCustomer(
Long customerId) {
Customer customer =
customerRepository
.findById(customerId)
.orElseThrow(
() ->
new CustomerNotFoundException(
customerId
)
);
if (!customer.isActive()) {
throw new InactiveCustomerException(
customerId
);
}
return customer;
}
}That is enough for the current requirement.
If Application Input Later Becomes Larger
Suppose customer retrieval later requires:
- Customer ID
- Tenant ID
- Include suspended flag
A simple record may be useful.
public record CustomerLookupQuery(
Long customerId,
String tenantId,
boolean includeSuspended) {
}The application does not need a builder unless construction becomes genuinely complex.
Introduce Strategy Later When Real Variation Appears
Suppose the company eventually supports:
- Internal database customers
- Legacy platform customers
At that point, a Strategy-like abstraction may become justified.
public interface CustomerSource {
boolean supports(
CustomerSourceType sourceType
);
Customer find(Long customerId);
}Now there are multiple meaningful implementations:
DatabaseCustomerSource
LegacyCustomerSourceThe abstraction is introduced when the variation exists.
11. Improved Code Explanation
Direct Dependency Makes Intent Visible
The service requires customer data.
Therefore it depends directly on:
CustomerRepositoryThe business flow is immediately visible.
No Artificial Selection Logic
There is no factory because nothing needs selecting.
No One-Implementation Strategy Hierarchy
A single implementation does not require an interchangeable algorithm abstraction.
No Pass-Through Context
The service communicates directly with its actual dependency.
Construction Complexity Is Removed
A single Long customerId can be passed directly.
If application input later grows, a record can be introduced.
Testability Is Preserved
The simpler implementation remains easy to test because CustomerRepository can be mocked or faked.
Future Refactoring Remains Possible
Removing premature abstraction does not prevent future design patterns.
When a second customer source appears, the code can be refactored around the real variation.
This usually produces a better abstraction because the developers now understand the actual differences between implementations.
12. Bad Code vs Improved Code
| Area | Over-Engineered Code | Simpler Code |
|---|---|---|
| Business requirement | Hidden behind patterns | Immediately visible |
| Classes involved | Multiple infrastructure classes | Service and repository |
| Strategy variation | Hypothetical | None assumed |
| Factory | Artificial selection | Not required |
| Builder | Used for one field | Direct parameter |
| Navigation | Many files | Short dependency chain |
| Testing | Several collaborators | Repository can be mocked directly |
| Maintenance | More code to evolve | Small change surface |
| Debugging | Multiple delegation layers | Direct execution flow |
| Future flexibility | Speculative | Added when requirement becomes real |
13. Real Project Scenario
Consider a healthcare microservice responsible for retrieving appointment availability.
The first release supports only one internal scheduling database.
A developer expects the company might eventually integrate:
- External hospitals
- Partner clinics
- Third-party scheduling platforms
The developer therefore creates:
AppointmentProvider
AppointmentProviderFactory
AppointmentProviderRegistry
AppointmentProviderResolver
AppointmentProviderContext
InternalAppointmentProvider
AppointmentProviderConfiguration
AppointmentProviderTypeFor two years, the application continues using only:
InternalAppointmentProviderDuring that period:
- Every developer must understand the provider framework.
- New appointment features pass through the resolver and registry.
- Tests mock multiple provider components.
- Configuration must define provider selection.
- Production incidents contain several additional stack frames.
Eventually, an external provider is added.
The team discovers that the external integration behaves fundamentally differently:
- Different patient identity model
- Different availability structure
- Different retry rules
- Different authentication
- Different error model
- Different synchronization requirements
The original abstraction does not fit the actual second implementation.
The team must redesign it anyway.
This is a common problem with speculative patterns.
The abstraction was created before developers understood the real variation.
A better approach would have been:
AppointmentService
↓
AppointmentRepositoryfor the original requirement.
When the real external provider appeared, the team could design an abstraction based on actual requirements.
14. Production Impact
Unnecessary patterns usually create maintenance impact rather than immediate incorrect business output.
However, realistic production effects can still occur.
Configuration Errors
Factory or registry patterns often depend on keys such as:
CARD
INTERNAL
PRIMARY
DEFAULTIncorrect configuration can select the wrong implementation.
Bean Wiring Problems
Large abstraction hierarchies can create:
- Multiple bean candidates
- Missing qualifiers
- Circular dependencies
- Incorrect component scanning
Difficult Incident Debugging
Production stack traces become harder to follow because of multiple delegation layers.
Slower Fixes
When an incident occurs, developers may need more time to determine where actual behavior lives.
Duplicate Behavior
Developers may add business logic to both:
- Pattern infrastructure
- Concrete implementation
because ownership is unclear.
Wrong Extension Assumptions
A premature abstraction may force future functionality into an unsuitable structure.
Developers may work around the pattern rather than redesigning it.
Maintenance Problems
This is the most common impact.
Teams spend time maintaining architectural infrastructure that does not provide equivalent business value.
15. Common Developer Mistakes
Mistake 1: One Interface for Every Service
Example:
CustomerService
CustomerServiceImplAn interface is valuable when it represents a real contract or boundary.
It is not required merely because Spring supports dependency injection.
Mistake 2: Strategy Pattern for One Algorithm
One implementation does not normally require Strategy Pattern.
Mistake 3: Factory for a Single Constructor
Avoid:
CustomerFactory.createCustomer(...)when the factory only calls:
new Customer(...)and performs no meaningful creation logic.
Mistake 4: Builder for Small Immutable Objects
A record such as:
public record SearchQuery(
String text,
int page) {
}does not normally need a builder.
Mistake 5: Abstract Factory for Hypothetical Providers
Do not design entire product families when one concrete implementation exists.
Mistake 6: Command Pattern for Every Service Method
Not every call such as:
customerService.activate(id);needs:
ActivateCustomerCommand
ActivateCustomerCommandHandler
ActivateCustomerCommandFactoryA command object may still be useful when:
- Requests are queued
- Commands are persisted
- Undo is required
- Commands are dispatched dynamically
- Input needs its own contract
Mistake 7: Observer/Event Pattern for Sequential Required Work
If an order cannot be considered successful until inventory is reserved, converting inventory reservation into a loosely coupled event solely for design purity may complicate consistency.
Mistake 8: Chain of Responsibility for Two Simple Checks
A validation chain can be valuable for many independent validators.
For two stable conditions:
validateAmount();
validateCurrency();direct method calls may be clearer.
Mistake 9: Decorator Around Every Service
Decorators are useful when behavior must be composed dynamically.
For standard concerns such as:
- Logging
- Metrics
- Transactions
Spring infrastructure may already provide simpler mechanisms.
Mistake 10: Pattern-First Design
Starting with:
"We should use Factory + Strategy"
before understanding the requirement frequently creates poor abstractions.
16. Edge Cases
Multiple Implementations Exist
If multiple implementations already exist, an abstraction may be completely justified.
For example:
S3DocumentStorage
AzureBlobDocumentStoragebehind:
DocumentStorageThis protects business code from storage-provider details.
External Systems
Even with one implementation, an interface may be valuable when it protects the application from a volatile vendor API.
For example:
FraudCheckermay have only one production implementation:
VendorXFraudCheckerbut the interface protects business code from Vendor X SDK types.
Therefore "one implementation" does not automatically mean "interface is unnecessary."
Testing Boundaries
Sometimes an interface exists because a difficult technical dependency needs isolation.
Reviewers should evaluate whether the boundary provides actual test or architecture value.
Framework Requirements
Some frameworks or libraries require particular patterns or interfaces.
Those should not be removed merely to reduce class count.
Complex Object Construction
Builder Pattern may be justified when:
- Many optional values exist
- Construction has multiple valid configurations
- Readable named construction matters
- Immutable object construction would otherwise become error-prone
Stable Enum Variants
For a few stable simple behaviors, an enum may be simpler than multiple Strategy classes.
Growing Business Variants
A switch that grows in every sprint may be evidence that Strategy or polymorphism is now justified.
Public API Contracts
An interface can provide long-term compatibility even with one current implementation.
The architectural context matters.
17. Performance Considerations
Unnecessary design patterns are usually more significant for maintainability than runtime performance.
The JVM can optimize many small method calls effectively.
Therefore reviewers should not claim that removing a Strategy or Factory will meaningfully improve application performance without measurement.
However, some indirect costs can exist.
Additional Object Creation
Pattern-heavy implementations may create unnecessary:
- Commands
- Builders
- Wrappers
- Context objects
- Result wrappers
In ordinary REST applications, this cost is often negligible.
In high-volume loops or batch processing, unnecessary allocations can matter.
Spring Bean Count
Excessive pattern infrastructure may increase:
- Bean scanning
- Startup wiring
- Application context complexity
The impact is normally small compared with architectural complexity, but very large applications may notice startup effects.
Dynamic Strategy Resolution
Repeatedly executing:
strategies.stream()
.filter(...)
.findFirst()for every high-volume request may be less efficient than direct lookup.
If many implementations exist, a prebuilt map may be more appropriate.
Reflection-Based Factories
Generic reflection-heavy factories can introduce complexity and runtime failure modes.
Use them only when their flexibility is required.
Do Not Optimize Architecture Based on Assumption
The primary reason to avoid unnecessary patterns is clarity and maintainability.
Performance should be measured separately.
18. Security Considerations
Unnecessary design patterns are not inherently a security problem.
However, excessive abstraction can obscure where security-sensitive behavior occurs.
Authorization Can Become Hidden
If authorization passes through:
Command
↓
Handler
↓
Decorator
↓
Strategy
↓
Providerreviewers may find it harder to verify that every execution path performs authorization.
Validation Can Be Bypassed
Multiple handlers or strategies may accidentally implement validation differently.
Sensitive Logging
Generic decorators may log:
- Request objects
- Authentication tokens
- Payment information
- Personal data
without understanding domain sensitivity.
Incorrect Strategy Selection
If implementation selection depends on user-controlled string values, a poorly designed factory may allow access to inappropriate behavior.
Selection inputs must still be validated.
Security Boundaries May Justify Abstraction
An abstraction that isolates:
- Authentication provider
- Secrets manager
- Encryption provider
may be valuable even with one implementation.
Do not remove useful security boundaries merely to simplify the class diagram.
19. Testing Considerations
Testing should confirm both business behavior and whether abstractions provide real value.
Simple Service Test
The improved customer service is straightforward to test.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CustomerServiceTest {
@Mock
private CustomerRepository
customerRepository;
@InjectMocks
private CustomerService
customerService;
@Test
void shouldReturnActiveCustomer() {
Customer customer =
new Customer(
10L,
true
);
when(
customerRepository
.findById(10L)
).thenReturn(
Optional.of(customer)
);
Customer result =
customerService
.getActiveCustomer(
10L
);
assertEquals(
10L,
result.getId()
);
}
}No Strategy, Factory, Context, or Builder mocks are required.
Negative Tests
Test:
- Customer not found
- Inactive customer
- Null or invalid ID if relevant
When a Pattern Is Introduced
If multiple strategies genuinely exist, test:
- Correct strategy selection
- Unsupported type
- Each strategy independently
- Duplicate-support scenarios
- Failure behavior
Refactoring Tests
Before removing an unnecessary pattern from legacy code, create characterization tests.
Ensure simplifying architecture does not accidentally alter behavior.
Integration Tests
If the abstraction protects an external service, integration tests may justify retaining the boundary.
20. Refactoring Guidelines
Removing unnecessary patterns should be done carefully.
Step 1: Understand Why the Pattern Exists
Check:
- Git history
- Architecture documentation
- Existing implementations
- Future committed requirements
- Testing needs
- External boundaries
Do not delete an abstraction simply because only one implementation exists today.
Step 2: Identify Pass-Through Layers
Look for classes that only delegate.
Example:
Context.execute()
→ Factory.get()
→ Strategy.execute()Determine which layers provide no independent behavior.
Step 3: Preserve Tests
Add tests around current business behavior.
Step 4: Collapse One Layer at a Time
Remove unnecessary context first.
Then evaluate the factory.
Then evaluate the strategy abstraction.
Avoid a large rewrite.
Step 5: Move Business Logic to a Clear Owner
Do not simplify by moving all behavior into the controller.
A service may still be the correct owner.
Step 6: Remove Dead Configuration
Delete unused:
- Strategy names
- Factory mappings
- Qualifiers
- Feature switches
only after verifying they are not used.
Step 7: Simplify Tests
Tests should depend on meaningful collaborators rather than architectural plumbing.
Step 8: Preserve Real Boundaries
Keep abstractions that isolate:
- External vendors
- Persistence
- Messaging
- Storage
- Security-sensitive components
when those boundaries provide genuine value.
21. Best Practices
Start Simple
Implement current requirements directly.
Refactor Toward Patterns
Patterns are often most effective when introduced after repeated structure becomes visible.
Base Abstractions on Real Implementations
Two or more real implementations reveal what is genuinely common.
Use Patterns to Localize Change
A good pattern reduces the number of existing classes modified when a known variation changes.
Keep Business Workflow Visible
Do not hide a straightforward workflow behind excessive dispatch infrastructure.
Prefer Modern Java Features
Use simple language features where they solve the problem.
Examples:
- Records
- Enums
- Sealed types where appropriate
- Lambdas
- Standard functional interfaces
Do not create custom patterns when Java already provides a simpler mechanism.
Use Spring Capabilities Appropriately
For cross-cutting concerns, consider existing Spring mechanisms instead of manually implementing infrastructure patterns.
Document Non-Obvious Patterns
If a pattern solves an important architectural requirement, explain that requirement.
Review Abstraction Cost
Every abstraction should justify:
- Another class
- Another concept
- Another test surface
- Another dependency relationship
22. Practices to Avoid
Pattern for Resume Value
Production code should not become a demonstration of every Gang of Four pattern.
Interface + Impl by Default
Avoid mechanically creating:
XService
XServiceImplwithout a boundary reason.
Factory Returning One Type
Question factories that always create or return the same implementation.
Builder for Trivial Objects
Avoid large builders for one or two required fields.
Empty Abstract Base Classes
Do not create inheritance hierarchies before shared behavior exists.
Generic Framework Inside the Application
Avoid building custom:
HandlerRegistry
ProcessorEngine
RuleEngine
ExecutorFrameworkfor a handful of straightforward calls unless the domain genuinely needs such extensibility.
Pattern Chains
Be suspicious when one request passes through several patterns before reaching business logic.
Premature Microservice-Like Abstractions
Do not model every internal method as if it were an independent service boundary.
Keeping Obsolete Patterns Forever
If requirements changed and a pattern no longer provides value, simplification is legitimate.
23. Code Review Checklist
- What specific problem does this design pattern solve?
- Does the application currently have multiple implementations or algorithms?
- Is future variation committed or merely hypothetical?
- Would direct code be easier to understand?
- Does this interface protect a meaningful architectural boundary?
- Does this factory perform real selection or construction logic?
- Does this builder simplify genuinely complex construction?
- Is this Strategy Pattern solving growing conditional behavior?
- Does this context class own behavior or only delegate?
- Are we creating an interface solely because Spring dependency injection is used?
- Could a record or enum replace several infrastructure classes?
- Is an external vendor being intentionally isolated behind this abstraction?
- Does this pattern reduce the number of classes modified for a real change?
- How many additional classes does the abstraction introduce?
- Does the pattern make unit tests simpler or more complicated?
- Is this abstraction based on actual implementation differences?
- Are developers predicting requirements without evidence?
- Does the pattern obscure the business workflow?
- Are pass-through layers adding meaningful responsibility?
- Would removing the pattern weaken an important security or infrastructure boundary?
- Can the code be simplified without changing business behavior?
- Are we optimizing for theoretical flexibility instead of current maintainability?
24. Common Pull Request Review Comments
We currently have only one implementation and no selection requirement. Can we keep this dependency direct until a real Strategy use case appears?
This factory always returns the same implementation. I don't see a construction or selection problem that requires the extra layer yet.
The context only delegates to the factory and strategy. Could we remove this pass-through layer to keep the execution flow visible?
This DTO contains only two required values. A builder adds significant boilerplate here; a record or constructor would be simpler.
I would avoid introducing this extension hierarchy for hypothetical providers. We can extract the abstraction when the second real implementation gives us concrete variation to model.
This interface is useful only if it protects an actual boundary. If the intention is solely to mock the service, Mockito can already mock the concrete dependency.
The new Command/Handler structure adds several classes for a synchronous one-step operation. What requirement does the command lifecycle solve here?
A Strategy makes sense if these payment variants continue growing. For the current single behavior, direct code would be easier to maintain.
Please avoid removing this gateway interface only because it has one implementation; it intentionally prevents vendor SDK types from leaking into the business layer.
Can we first implement the current behavior directly and refactor when repeated variation becomes visible? That would give us a better abstraction based on real requirements.
25. Code Review Exercise
Review the following notification-preference implementation.
Identify:
- Unnecessary design patterns
- Useful abstractions
- Boilerplate
- Maintainability issues
- Test complexity
- Possible simplification
The current requirement is:
Load the notification preference for a user from the application's database.
There is currently no external preference provider.
public interface PreferenceProvider {
boolean supports(
PreferenceProviderType type
);
NotificationPreference load(
Long userId
);
}
public enum PreferenceProviderType {
DATABASE
}
import org.springframework.stereotype.Component;
@Component
public class DatabasePreferenceProvider
implements PreferenceProvider {
private final PreferenceRepository
preferenceRepository;
public DatabasePreferenceProvider(
PreferenceRepository
preferenceRepository) {
this.preferenceRepository =
preferenceRepository;
}
@Override
public boolean supports(
PreferenceProviderType type) {
return type ==
PreferenceProviderType.DATABASE;
}
@Override
public NotificationPreference load(
Long userId) {
return preferenceRepository
.findByUserId(userId)
.orElseThrow();
}
}
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class PreferenceProviderFactory {
private final List<PreferenceProvider>
providers;
public PreferenceProviderFactory(
List<PreferenceProvider> providers) {
this.providers = providers;
}
public PreferenceProvider get(
PreferenceProviderType type) {
return providers.stream()
.filter(
provider ->
provider.supports(type)
)
.findFirst()
.orElseThrow();
}
}
public class LoadPreferenceCommand {
private final Long userId;
private final PreferenceProviderType type;
private LoadPreferenceCommand(
Builder builder) {
this.userId = builder.userId;
this.type = builder.type;
}
public Long getUserId() {
return userId;
}
public PreferenceProviderType getType() {
return type;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private Long userId;
private PreferenceProviderType type;
public Builder userId(
Long userId) {
this.userId = userId;
return this;
}
public Builder type(
PreferenceProviderType type) {
this.type = type;
return this;
}
public LoadPreferenceCommand build() {
return new LoadPreferenceCommand(
this
);
}
}
}
import org.springframework.stereotype.Service;
@Service
public class PreferenceQueryService {
private final PreferenceProviderFactory
providerFactory;
public PreferenceQueryService(
PreferenceProviderFactory
providerFactory) {
this.providerFactory =
providerFactory;
}
public NotificationPreference load(
LoadPreferenceCommand command) {
PreferenceProvider provider =
providerFactory.get(
command.getType()
);
return provider.load(
command.getUserId()
);
}
}Review the design based on current requirements rather than hypothetical future providers.
26. Exercise Solution
The code contains several abstractions that are not currently justified.
Issue 1: Provider Strategy Has One Implementation
The only provider is:
DatabasePreferenceProviderThere is no meaningful interchangeable behavior.
Issue 2: Provider Type Has One Value
DATABASEThe enum currently exists only to select the one available implementation.
Issue 3: Factory Performs Artificial Selection
The factory iterates over a list to find the only provider.
Issue 4: Builder Is Unnecessary
The command has two simple fields.
If a query object is useful, a Java record is sufficient.
Issue 5: Business Flow Is Hidden
The actual operation is:
preferenceRepository.findByUserId(userId)but developers must understand several pattern classes before discovering it.
Simplest Appropriate Implementation
import org.springframework.stereotype.Service;
@Service
public class PreferenceQueryService {
private final PreferenceRepository
preferenceRepository;
public PreferenceQueryService(
PreferenceRepository
preferenceRepository) {
this.preferenceRepository =
preferenceRepository;
}
public NotificationPreference load(
Long userId) {
return preferenceRepository
.findByUserId(userId)
.orElseThrow(
() ->
new PreferenceNotFoundException(
userId
)
);
}
}This implementation directly expresses the current requirement.
If a Query Object Is Required
Suppose additional search information is expected immediately.
Use a record.
public record PreferenceQuery(
Long userId) {
}There is still no need for a builder.
When the Provider Pattern Could Become Useful
Assume a confirmed requirement later introduces:
DatabasePreferenceProvider
CorporateDirectoryPreferenceProviderand behavior depends on customer type.
Then a provider abstraction may become valuable.
At that point:
public interface PreferenceProvider {
boolean supports(
CustomerType customerType
);
NotificationPreference load(
Long userId
);
}now expresses real variation.
Why Delaying the Pattern Is Better
Once both implementations exist, the team can answer important questions accurately:
- What determines provider selection?
- Do both providers return the same model?
- How do failure semantics differ?
- Should fallback occur?
- Should results be cached?
- Can providers be combined?
- Is selection tenant-based?
The resulting abstraction is based on actual behavior rather than guesses.
27. Interview Perspective
This topic frequently appears in senior Java and code-review interviews.
An interviewer may show:
PaymentService
PaymentServiceImpland ask:
"Why do we need this interface?"
A weak answer is:
"Because interfaces are best practice."
A stronger answer is:
An interface is useful when it:
- Defines a meaningful contract
- Protects an architectural boundary
- Supports multiple implementations
- Is part of a public module API
- Isolates volatile infrastructure
If none of those apply, the concrete service may be sufficient.
Another common interview question is:
"When would you use Strategy Pattern?"
A strong answer explains that Strategy is useful when multiple interchangeable algorithms or behaviors exist and callers should depend on a common capability.
It should not be introduced solely to eliminate one small if.
Senior interviews may also ask:
"Should we design for future requirements?"
The correct balance is:
- Do not knowingly block likely near-term requirements.
- Avoid hard-coding volatile infrastructure.
- Do not build large extension frameworks for imagined requirements.
This principle is closely related to YAGNI:
You Aren't Gonna Need It.
YAGNI does not mean ignoring architecture.
It means avoiding functionality and flexibility that have no current justification.
28. Interview Questions and Answers
Basic Question
Question: Is using more design patterns a sign of better code?
Answer:
No.
Design quality depends on whether the solution fits the problem.
A simple implementation can be better than a pattern-heavy implementation when there is no meaningful variation or complexity to manage.
Intermediate Question
Question: When is Strategy Pattern justified?
Answer:
Strategy is useful when:
- Multiple interchangeable behaviors exist
- Behavior selection is meaningful
- Variants are expected to grow
- Large conditionals are spreading
- Each variant contains enough behavior to deserve independent ownership
Advanced Question
Question: Is an interface with one implementation always unnecessary?
Answer:
No.
One implementation may still justify an interface when it represents an architectural boundary.
Examples include:
- External payment provider
- Cloud storage
- Messaging system
- Fraud service
- Security provider
The interface can prevent vendor-specific details from leaking into business code.
Scenario-Based Question
Question: A developer proposes Strategy and Factory patterns for a discount calculator. There is currently one discount algorithm. What would you review?
Answer:
Ask:
- Is another algorithm confirmed?
- Is current conditional logic already difficult?
- Does the abstraction isolate a volatile external dependency?
- Does selection logic actually exist?
- Will the pattern reduce real change cost?
If there is one simple stable algorithm, direct implementation may be preferable.
Code-Review Question
Question: What is suspicious about this architecture?
Controller
↓
Facade
↓
CommandHandler
↓
Context
↓
Factory
↓
Strategy
↓
RepositoryAnswer:
The number of layers is not automatically wrong, but each layer should have a clear responsibility.
If several classes only delegate to the next class, the design contains unnecessary indirection.
Review whether the architecture solves actual:
- Variation
- Transaction
- Security
- Integration
- Command-dispatch
- Module-boundary
requirements.
Real-Project Question
Question: When should Builder Pattern be used?
Answer:
Builder is useful for complex construction involving:
- Many optional parameters
- Multiple valid configurations
- Immutable objects
- Readability problems with constructors
- Stepwise construction
For a record containing two required fields, a builder often adds unnecessary boilerplate.
Architecture Question
Question: Why can waiting for a second implementation produce a better abstraction?
Answer:
With only one implementation, developers must guess what future implementations will have in common.
Once a second implementation exists, actual common behavior and differences become visible.
The abstraction can then be designed around real requirements.
Spring Boot Question
Question: Do all Spring services need an interface?
Answer:
No.
Spring can inject concrete classes.
Create a service interface when it represents a useful contract or architectural boundary, not as a mandatory naming convention.
29. Quick Rule to Remember
Do not add a pattern because the code might need flexibility someday; add it when real complexity or change makes the pattern simpler than the direct solution.
30. Final Takeaway
Design patterns are tools, not goals.
Developers should know patterns well enough to recognize when they solve a real problem, but they should also know when not to use them.
During implementation, remember:
- Start with the simplest design that correctly models the requirement.
- Introduce abstractions around real variation.
- Do not create interfaces mechanically.
- Avoid factories without meaningful selection or construction logic.
- Avoid builders for trivial objects.
- Avoid strategies when only one stable behavior exists.
- Avoid command infrastructure for simple direct calls.
- Avoid event-driven designs when strict sequential processing is simpler and required.
- Preserve useful boundaries around external systems and volatile infrastructure.
- Prefer refactoring toward patterns after repetition and change pressure become visible.
During Pull Request review, do not praise an implementation merely because it uses well-known patterns.
Ask:
- What problem does the pattern solve?
- What change does it make easier?
- What complexity does it remove?
- What complexity does it introduce?
- Does the abstraction represent current reality?
- Can a developer understand the business workflow without navigating unnecessary layers?
Avoiding unnecessary design patterns does not mean writing simplistic code.
It means refusing accidental complexity.
The strongest production design is usually not the design containing the largest number of patterns.
It is the design where every abstraction has a clear reason to exist and the business requirement remains easy to understand.