Avoiding Unnecessary Design Patterns

27 min read

Object-Oriented Design and SOLID Review — review Java code for design patterns applied without a real problem to solve and replace forced complexity with simpler direct solutions.

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:

JAVA
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:

JAVA
findCustomer(1001L)

may need to open:

  • CustomerService
  • CustomerLookupContext
  • CustomerLookupStrategy
  • CustomerLookupStrategyFactory
  • DatabaseCustomerLookupStrategy
  • CustomerLookupCommand
  • CustomerLookupCommandBuilder

before discovering that the code eventually executes:

JAVA
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:

JAVA
Controller
    ↓
CustomerService
    ↓
CustomerRepository

developers may need to trace:

JAVA
Controller
    ↓
Facade
    ↓
Command
    ↓
Context
    ↓
Factory
    ↓
Strategy
    ↓
Provider
    ↓
Repository

When 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:

JAVA
CardPaymentProcessor
UpiPaymentProcessor
BankTransferPaymentProcessor

If payment behavior differs significantly and variants continue growing, Strategy can provide value.

If there is only:

JAVA
DatabaseCustomerFinder

creating 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

JAVA
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

JAVA
public interface CustomerLookupStrategy {
    boolean supports(
            CustomerLookupCommand command
    );

    Customer find(
            CustomerLookupCommand command
    );
}

Database Strategy

JAVA
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

JAVA
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

JAVA
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

JAVA
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:

JAVA
CustomerRepository

There is no alternate lookup strategy.

The factory always returns the same implementation.

The strategy's supports() method always returns:

JAVA
true

The 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:

JAVA
DatabaseCustomerLookupStrategy

A 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:

JAVA
factory → strategy

The extra layer makes navigation harder without improving behavior.

Builder for a Single Required Field

CustomerLookupCommand contains one value:

JAVA
customerId

Using a nested builder makes construction much more verbose than:

JAVA
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:

JAVA
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

JAVA
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.

JAVA
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.

JAVA
public interface CustomerSource {
    boolean supports(
            CustomerSourceType sourceType
    );

    Customer find(Long customerId);
}

Now there are multiple meaningful implementations:

JAVA
DatabaseCustomerSource
LegacyCustomerSource

The 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:

JAVA
CustomerRepository

The 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

AreaOver-Engineered CodeSimpler Code
Business requirementHidden behind patternsImmediately visible
Classes involvedMultiple infrastructure classesService and repository
Strategy variationHypotheticalNone assumed
FactoryArtificial selectionNot required
BuilderUsed for one fieldDirect parameter
NavigationMany filesShort dependency chain
TestingSeveral collaboratorsRepository can be mocked directly
MaintenanceMore code to evolveSmall change surface
DebuggingMultiple delegation layersDirect execution flow
Future flexibilitySpeculativeAdded 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:

JAVA
AppointmentProvider
AppointmentProviderFactory
AppointmentProviderRegistry
AppointmentProviderResolver
AppointmentProviderContext
InternalAppointmentProvider
AppointmentProviderConfiguration
AppointmentProviderType

For two years, the application continues using only:

JAVA
InternalAppointmentProvider

During 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:

JAVA
AppointmentService
    ↓
AppointmentRepository

for 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:

JAVA
CARD
INTERNAL
PRIMARY
DEFAULT

Incorrect 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:

JAVA
CustomerService
CustomerServiceImpl

An 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:

JAVA
CustomerFactory.createCustomer(...)

when the factory only calls:

JAVA
new Customer(...)

and performs no meaningful creation logic.

Mistake 4: Builder for Small Immutable Objects

A record such as:

JAVA
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:

JAVA
customerService.activate(id);

needs:

JAVA
ActivateCustomerCommand
ActivateCustomerCommandHandler
ActivateCustomerCommandFactory

A 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:

JAVA
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:

JAVA
S3DocumentStorage
AzureBlobDocumentStorage

behind:

JAVA
DocumentStorage

This 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:

JAVA
FraudChecker

may have only one production implementation:

JAVA
VendorXFraudChecker

but 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:

JAVA
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:

JAVA
Command
    ↓
Handler
    ↓
Decorator
    ↓
Strategy
    ↓
Provider

reviewers 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.

JAVA
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:

JAVA
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:

JAVA
XService
XServiceImpl

without 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:

JAVA
HandlerRegistry
ProcessorEngine
RuleEngine
ExecutorFramework

for 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

  1. We currently have only one implementation and no selection requirement. Can we keep this dependency direct until a real Strategy use case appears?
  1. This factory always returns the same implementation. I don't see a construction or selection problem that requires the extra layer yet.
  1. The context only delegates to the factory and strategy. Could we remove this pass-through layer to keep the execution flow visible?
  1. This DTO contains only two required values. A builder adds significant boilerplate here; a record or constructor would be simpler.
  1. 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.
  1. 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.
  1. The new Command/Handler structure adds several classes for a synchronous one-step operation. What requirement does the command lifecycle solve here?
  1. A Strategy makes sense if these payment variants continue growing. For the current single behavior, direct code would be easier to maintain.
  1. Please avoid removing this gateway interface only because it has one implementation; it intentionally prevents vendor SDK types from leaking into the business layer.
  1. 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.

JAVA
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:

JAVA
DatabasePreferenceProvider

There is no meaningful interchangeable behavior.

Issue 2: Provider Type Has One Value

JAVA
DATABASE

The 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:

JAVA
preferenceRepository.findByUserId(userId)

but developers must understand several pattern classes before discovering it.

Simplest Appropriate Implementation

JAVA
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.

JAVA
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:

JAVA
DatabasePreferenceProvider
CorporateDirectoryPreferenceProvider

and behavior depends on customer type.

Then a provider abstraction may become valuable.

At that point:

JAVA
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:

JAVA
PaymentService
PaymentServiceImpl

and 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?

JAVA
Controller
    ↓
Facade
    ↓
CommandHandler
    ↓
Context
    ↓
Factory
    ↓
Strategy
    ↓
Repository

Answer:

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.