Choosing Simple Design Over Over-Engineering

24 min read

Object-Oriented Design and SOLID Review — review Java code for unnecessary layers and complexity and keep the design as simple as the current requirement requires.

1. Introduction

Simple design means solving the current business requirement with the minimum structure necessary to keep the code readable, maintainable, testable, and safe for production.

Over-engineering happens when developers add complexity that the current problem does not require.

Typical examples include:

  • Multiple interfaces for one implementation
  • Factories that always return one object
  • Strategy patterns for a single rule
  • Deep inheritance hierarchies
  • Generic frameworks built for one use case
  • Custom wrappers around stable framework APIs
  • Event-driven flows where a direct method call is enough
  • Distributed components for a simple local operation
  • Configuration-driven behavior that never actually varies

A production-quality design is not the design with the most patterns.

It is the design that correctly solves the requirement while remaining easy for another developer to understand and change.

During code review, reviewers should continuously ask:

Is this complexity required by the current problem?

If the answer is no, simpler code may be the better engineering decision.

2. What This Topic Means

Choosing simple design means resisting the temptation to solve hypothetical future problems before they exist.

Consider a requirement:

Retrieve an active customer by ID and return basic customer information.

A straightforward Spring Boot implementation may look like:

JAVA
@Service
public class CustomerService {
    private final CustomerRepository customerRepository;
    public CustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }
    public Customer getActiveCustomer(Long customerId) {
        return customerRepository.findByIdAndActiveTrue(customerId).orElseThrow(() -> new CustomerNotFoundException(customerId));
    }
}

An over-engineered implementation might introduce:

JAVA
CustomerQuery
CustomerQueryHandler
CustomerQueryHandlerFactory
CustomerQueryExecutor
QueryExecutionContext
CustomerLookupStrategy
DefaultCustomerLookupStrategy
CustomerLookupStrategyRegistry

even though there is:

  • One query
  • One repository
  • One lookup rule
  • One execution path

The extra abstractions may technically work, but they increase the amount of code developers must understand without increasing business value.

Simple design does not mean careless design.

It still requires:

  • Clear responsibilities
  • Proper exception handling
  • Correct validation
  • Appropriate abstractions
  • Good tests
  • Secure configuration
  • Production-ready error handling

The objective is to remove accidental complexity, not necessary engineering.

3. Why It Matters in Real Projects

Readability

Simple code allows developers to understand a workflow quickly.

Compare:

JAVA
orderRepository.save(order);

with:

JAVA
persistenceOperationExecutor.execute(PersistenceOperationFactory.create(PersistenceOperationType.ORDER_SAVE, order));

If only one repository operation exists, the second design creates unnecessary cognitive overhead.

Maintainability

Every additional abstraction introduces another contract that developers must preserve.

More classes mean more:

  • Navigation
  • Tests
  • Documentation
  • Spring configuration
  • Dependency wiring
  • Potential regressions

Debugging

During production incidents, simple call paths are easier to investigate.

A direct flow:

JAVA
Controller
    -> Service
        -> Repository

is generally easier to debug than:

JAVA
Controller
    -> CommandBus
        -> CommandFactory
            -> CommandDispatcher
                -> HandlerResolver
                    -> Handler
                        -> Processor
                            -> Repository

unless those additional layers solve real architectural requirements.

Reliability

Every runtime lookup, registry, dynamic resolver, or configuration-driven selection creates another possible failure path.

Team Development

Simple code reduces onboarding time.

A new developer can understand business behavior without first learning an internal framework.

Scalability

Simple design does not prevent future scalability.

A system should be made more sophisticated when actual requirements justify it.

Designing everything for theoretical extreme scale often produces unnecessary complexity before the application has demonstrated that need.

4. Core Concept

The core principle is:

Use the simplest design that correctly handles today's known requirements and can still be refactored when new requirements appear.

Simple design is not the same as minimal code.

For example:

JAVA
public void pay() {
    // everything in one 500-line method
}

is not good simple design.

That is merely poorly structured code.

A simple design may still contain:

  • Controller
  • Service
  • Repository
  • External API adapter
  • Validator
  • Mapper

if each component has a clear responsibility.

Necessary Complexity

Suppose payment processing requires:

  • Idempotency
  • Fraud validation
  • Payment gateway integration
  • Retry handling
  • Transaction persistence
  • Audit events

The solution will naturally require multiple components.

That complexity is justified because the business problem itself is complex.

Accidental Complexity

If a simple discount calculation requires:

JAVA
DiscountEngine
DiscountEngineFactory
DiscountRuleRegistry
DiscountRuleResolver
AbstractDiscountRule
DiscountExecutionContext
DiscountResultBuilder

for one discount rule, the architecture may be more complex than the problem.

A reviewer should distinguish between:

  • Complexity caused by the business domain
  • Complexity created by the implementation

Only the second category should be aggressively questioned.

5. Important Rules

  • Solve the current requirement before designing for hypothetical future requirements.
  • Prefer explicit code over unnecessary dynamic behavior.
  • Do not add a design pattern unless it solves a recognizable problem.
  • Avoid interfaces created only because every service "should" have one.
  • Avoid factories when constructor injection already provides object creation.
  • Do not build registries when the application has one implementation.
  • Prefer composition over deep inheritance.
  • Avoid unnecessary generic type parameters.
  • Keep configuration only where real variation exists.
  • Do not convert simple method calls into events without a real decoupling need.
  • Keep business rules close to the business service that owns them.
  • Prefer compile-time safety over string-based runtime selection.
  • Avoid custom wrappers around framework APIs without a clear purpose.
  • Do not add extension points before a real extension exists.
  • Keep application layers proportional to application complexity.
  • Prefer small focused methods over generalized processing engines.
  • Do not optimize for extreme performance without evidence.
  • Measure performance before introducing complex caching or parallelization.
  • Keep error handling explicit.
  • Make the normal execution path easy to follow.
  • Prefer straightforward Spring dependency injection over service locators.
  • Question abstractions that require extensive documentation to explain a simple operation.

6. Bad Code Example

Consider a Spring Boot application with the following requirement:

Validate whether an order is eligible for cancellation.

Current business rules:

  • Order must exist.
  • Order status must be CREATED.
  • Order must not already be shipped.

An over-engineered implementation looks like this:

JAVA
public interface OrderCancellationRule {
    boolean supports(Order order);
    ValidationResult validate(Order order);
    int priority();
}

public abstract class AbstractOrderCancellationRule implements OrderCancellationRule {
    protected ValidationResult success() {
        return ValidationResult.valid();
    }
    protected ValidationResult failure(String message) {
        return ValidationResult.invalid(message);
    }
}

@Component
public class CreatedStatusCancellationRule extends AbstractOrderCancellationRule {
    @Override
    public boolean supports(Order order) {
        return order != null;
    }
    @Override
    public ValidationResult validate(Order order) {
        if (order.getStatus() != OrderStatus.CREATED) {
            return failure("Only created orders can be cancelled");
        }
        return success();
    }
    @Override
    public int priority() {
        return 1;
    }
}

@Component
public class ShipmentCancellationRule extends AbstractOrderCancellationRule {
    @Override
    public boolean supports(Order order) {
        return order != null;
    }
    @Override
    public ValidationResult validate(Order order) {
        if (order.isShipped()) {
            return failure("Shipped orders cannot be cancelled");
        }
        return success();
    }
    @Override
    public int priority() {
        return 2;
    }
}

@Component
public class OrderCancellationRuleRegistry {
    private final List<OrderCancellationRule> rules;
    public OrderCancellationRuleRegistry(List<OrderCancellationRule> rules) {
        this.rules = rules.stream().sorted(Comparator.comparingInt(OrderCancellationRule::priority)).toList();
    }
    public List<OrderCancellationRule> getApplicableRules(Order order) {
        return rules.stream().filter(rule -> rule.supports(order)).toList();
    }
}

@Component
public class OrderCancellationValidationEngine {
    private final OrderCancellationRuleRegistry registry;
    public OrderCancellationValidationEngine(OrderCancellationRuleRegistry registry) {
        this.registry = registry;
    }
    public ValidationResult validate(Order order) {
        for (OrderCancellationRule rule : registry.getApplicableRules(order)) {
            ValidationResult result = rule.validate(order);
            if (!result.valid()) {
                return result;
            }
        }
        return ValidationResult.valid();
    }
}

@Service
public class OrderCancellationService {
    private final OrderRepository orderRepository;
    private final OrderCancellationValidationEngine validationEngine;
    public OrderCancellationService(OrderRepository orderRepository, OrderCancellationValidationEngine validationEngine) {
        this.orderRepository = orderRepository;
        this.validationEngine = validationEngine;
    }
    public void cancel(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow(() -> new OrderNotFoundException(orderId));
        ValidationResult result = validationEngine.validate(order);
        if (!result.valid()) {
            throw new OrderCancellationException(result.message());
        }
        order.cancel();
        orderRepository.save(order);
    }
}

The design works, but the current business problem does not require a configurable rule engine.

7. Problems in the Bad Code

Too Many Abstractions

Three simple conditions have become:

  • Rule interface
  • Abstract class
  • Individual rule classes
  • Registry
  • Validation engine
  • Service

The architecture is significantly larger than the requirement.

Artificial supports() Method

Every current rule returns:

JAVA
order != null

There is no real rule selection.

Artificial Priority

The rules require numeric priorities even though the execution order is simple and fixed.

This adds hidden ordering behavior.

Runtime Behavior for Static Logic

A straightforward sequence of conditions has been converted into runtime discovery and ordering.

Harder Business Rule Discovery

A developer cannot understand cancellation eligibility by opening one class.

They must inspect all Spring beans implementing:

JAVA
OrderCancellationRule

More Tests

The team must now test:

  • Individual rules
  • Rule registry
  • Sorting
  • Applicability
  • Validation engine
  • Cancellation service

More Failure Possibilities

Future developers may accidentally:

  • Duplicate a priority
  • Forget @Component
  • Return incorrect supports()
  • Add a rule in the wrong order
  • Introduce inconsistent validation messages

Maintenance Cost

A small business-rule change may require navigating several classes.

Performance

The stream processing and sorting do not create a serious performance problem because the number of rules is tiny.

Performance is not the primary issue.

The main issue is unnecessary complexity.

Security

No significant security problem is introduced specifically by this design.

8. Code Review Findings

A senior reviewer should notice:

  • The cancellation rules are small and belong to one workflow.
  • There is no current runtime requirement for dynamically adding rules.
  • Every rule applies to the same order.
  • The registry introduces unnecessary discovery logic.
  • Priority values create hidden control flow.
  • The abstract base class provides minimal value.
  • The business requirement could be represented directly and more clearly.
  • The current structure increases maintenance and test surface.
  • The design appears optimized for a hypothetical future rule engine.
  • There is no evidence that multiple teams or plugins independently contribute cancellation rules.

The reviewer should not request simplification only because there are many classes.

The important question is whether those classes represent useful independent responsibilities.

Here, most layers exist only to support the generalized rule framework.

9. Reviewer Comment Example

The current cancellation rules are fixed and always run for the same order, so the registry, supports() logic, and priority-based execution appear to add complexity without a current requirement. Could we keep these checks explicit in one cancellation validator and introduce a rule engine only if dynamic rule composition becomes necessary?

Another useful comment:

This abstraction makes the actual cancellation criteria difficult to see during review. A focused validator with explicit checks would make the business rule easier to understand and maintain.

10. Improved Code

A simpler production implementation can keep the validation rules together.

JAVA
@Component
public class OrderCancellationValidator {
    public void validate(Order order) {
        if (order.getStatus() != OrderStatus.CREATED) {
            throw new OrderCancellationException("Only created orders can be cancelled");
        }
        if (order.isShipped()) {
            throw new OrderCancellationException("Shipped orders cannot be cancelled");
        }
    }
}

@Service
public class OrderCancellationService {
    private final OrderRepository orderRepository;
    private final OrderCancellationValidator validator;
    public OrderCancellationService(OrderRepository orderRepository, OrderCancellationValidator validator) {
        this.orderRepository = orderRepository;
        this.validator = validator;
    }
    public void cancel(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow(() -> new OrderNotFoundException(orderId));
        validator.validate(order);
        order.cancel();
        orderRepository.save(order);
    }
}

If the checks are used only by this service and remain very small, even the separate validator may not be necessary.

A private method may be enough:

JAVA
private void validateCancellation(Order order) {
    if (order.getStatus() != OrderStatus.CREATED) {
        throw new OrderCancellationException("Only created orders can be cancelled");
    }
    if (order.isShipped()) {
        throw new OrderCancellationException("Shipped orders cannot be cancelled");
    }
}

The correct level depends on reuse, ownership, and testability.

11. Improved Code Explanation

Business Rules Are Visible

A developer can see all cancellation conditions in one place.

No Artificial Runtime Discovery

There is no Spring bean collection, strategy lookup, or priority sorting.

No Hidden Execution Order

The validation order appears directly in the code.

Reduced File Count

The design uses only components that represent actual responsibilities.

Easier Testing

The validator can be tested directly.

Easy Future Refactoring

If cancellation rules later become:

  • Country-specific
  • Merchant-specific
  • Configurable
  • Dynamically loaded
  • Independently owned

the team can introduce a strategy or rules architecture using real requirements.

Better Debugging

Stack traces and call paths are shorter.

Lower Maintenance Cost

A developer changing cancellation rules does not need to understand a mini rule engine.

12. Bad Code vs Improved Code

AreaOver-Engineered DesignSimple Design
Rule visibilityDistributed across multiple classesVisible in one validator
Runtime selectionRegistry and supports()None required
Execution orderNumeric prioritiesExplicit code order
MaintainabilitySeveral abstraction layersSmall focused implementation
TestabilityMany structural testsDirect behavior tests
DebuggingMultiple indirection layersShort call path
ReliabilityMore configuration/wiring possibilitiesFewer moving parts
Change surfaceMultiple classes may changeLocalized
Future extensibilityBuilt before requirementAdded when requirement appears
PerformanceMinor unnecessary framework overheadDirect execution

13. Real Project Scenario

Consider a healthcare claims microservice.

The initial requirement is:

Send an approved claim to one external clearinghouse.

The team currently integrates only with one provider.

A developer predicts that the company may eventually support several clearinghouses and creates:

JAVA
ClaimSubmissionProvider
ClaimSubmissionProviderFactory
ClaimSubmissionProviderRegistry
ProviderResolver
ProviderRoutingStrategy
ProviderRoutingContext
AbstractClaimSubmissionProvider
ClaimSubmissionPipeline
ClaimPipelineStage
ClaimPipelineExecutor

Only one provider exists.

For every production incident, developers must trace through the entire framework.

Six months later, a second provider is introduced.

The team discovers that the second provider:

  • Uses asynchronous submission
  • Requires batch files
  • Has different retry rules
  • Does not return an immediate claim ID

The original generic abstraction assumed all providers worked like the first provider.

The team must redesign the abstraction anyway.

A simpler first implementation would have isolated the existing external client behind a clear application service or gateway without attempting to predict the future routing architecture.

14. Production Impact

Over-engineering often creates indirect production problems.

Difficult Incident Investigation

More layers increase debugging time.

During an outage, engineers must determine:

  • Which implementation was selected
  • Which factory created it
  • Which configuration affected resolution
  • Which pipeline step failed

Configuration Failures

Dynamic architectures often depend on:

  • Bean names
  • String identifiers
  • Registry entries
  • Rule ordering
  • Feature flags

Incorrect configuration creates additional failure modes.

Longer Fix Cycles

A small production fix can take longer because developers must understand unnecessary abstractions before modifying behavior safely.

Higher Regression Risk

Changes to shared generic frameworks can affect several unrelated workflows.

Hidden Behavior

Dynamic execution can make it difficult to predict which code path will run.

Maintenance Problems

The most common production cost is slower and riskier changes rather than direct application failure.

15. Common Developer Mistakes

Using Design Patterns as a Goal

Developers sometimes ask:

Where can we use Strategy Pattern?

instead of:

What problem are we solving?

Patterns should follow problems.

Interface for Every Class

Example:

JAVA
CustomerService
CustomerServiceImpl

This may be justified, but not automatically.

Factory for Dependency Injection

Spring already creates and injects objects.

A custom factory may be unnecessary when normal constructor injection is sufficient.

Registry with One Implementation

Example:

JAVA
paymentProcessors.get("DEFAULT");

when only one processor exists.

Deep Inheritance

Example:

JAVA
BaseProcessor
    -> AbstractValidatedProcessor
        -> AbstractTransactionalProcessor
            -> OrderProcessor

The inheritance structure may be harder to understand than composition.

Generic Framework for One Workflow

Example:

JAVA
WorkflowEngine<T, R, C>

created only to process customer activation.

Configuration for Fixed Behavior

Moving simple constants into ten configuration properties does not automatically improve flexibility.

Event for Every Internal Operation

Replacing:

JAVA
inventoryService.reserve(order);

with:

JAVA
eventPublisher.publish(new ReserveInventoryEvent(order));

is not automatically better.

Events are useful when asynchronous or loose coupling is required.

Microservice Too Early

Splitting a small cohesive module into another deployed service creates:

  • Network calls
  • Deployment complexity
  • Monitoring
  • Authentication
  • Failure handling

without guaranteed value.

Premature Caching

Adding Redis before measuring whether database performance is actually a problem.

Premature Parallelism

Using CompletableFuture, executors, or parallel streams without evidence that concurrency improves the workload.

16. Edge Cases

Real Extensibility Requirement

If multiple implementations already exist, Strategy or Factory patterns may be completely appropriate.

Regulatory Isolation

Banking or healthcare applications may need explicit boundaries even when they appear more complex.

That complexity can be justified by compliance requirements.

External Integration

One external provider may still deserve an abstraction because isolating external APIs is valuable.

Example:

JAVA
CreditScoreProvider

can protect the domain from a vendor-specific API.

Public APIs

Libraries and shared modules may require stronger abstraction because changing public contracts later can affect many consumers.

Large Teams

Clear module boundaries may introduce extra classes but reduce cross-team coupling.

That is not necessarily over-engineering.

High Availability

Retries, circuit breakers, fallbacks, and bulkheads increase complexity but may be necessary for critical external calls.

Performance-Critical Code

Caching, batching, asynchronous processing, or parallelization may be justified when metrics demonstrate the need.

Security Requirements

Additional security layers may be necessary even when they make the design less simple.

Simplicity must never be used as an excuse to remove required security controls.

17. Performance Considerations

Simple design does not mean ignoring performance.

It means avoiding optimization without evidence.

Premature Caching

Bad reasoning:

This query may become slow someday, so let's add Redis.

Caching introduces:

  • Cache invalidation
  • Serialization
  • Additional infrastructure
  • Stale data risk
  • Failure handling
  • Monitoring

First determine whether the database query is actually a bottleneck.

Premature Parallel Processing

Changing:

JAVA
orders.stream()

to:

JAVA
orders.parallelStream()

without profiling may introduce:

  • Thread contention
  • Transaction-context issues
  • Harder debugging
  • Unexpected ordering

Unnecessary Object Layers

Creating many wrapper objects can increase allocation, but this is rarely the primary reason to simplify enterprise code.

Dynamic Reflection

Generic mapping or processing frameworks may rely heavily on reflection.

Measure before assuming that it is a problem.

External Calls

The largest performance issues in enterprise systems usually involve:

  • Database queries
  • Network calls
  • Serialization
  • Large collections
  • Inefficient algorithms

Focus optimization effort on measured bottlenecks.

Practical Rule

Do not add complexity for performance until the performance problem has been observed, measured, or reasonably demonstrated.

18. Security Considerations

Simple design should make security controls more visible, not remove them.

Do Not Simplify Away Authorization

Bad:

JAVA
customerRepository.deleteById(customerId);

if deletion requires permission checks.

A proper service-level authorization check may add complexity but is required.

Avoid Generic Security Frameworks Without Need

Do not create a custom permission engine when standard Spring Security authorization already solves the requirement.

Keep Sensitive Data Handling Explicit

Simple code should clearly show:

  • What sensitive data is accessed
  • Who can access it
  • What is logged
  • What is returned

Avoid Generic Maps for Sensitive Context

Example:

JAVA
Map<String, Object> securityContext

may reduce type safety and make data exposure harder to understand.

Secrets

Do not hardcode secrets merely because direct code appears simpler.

Secure configuration remains necessary.

Important Principle

Required security complexity is not over-engineering.

Unnecessary custom security infrastructure may be.

19. Testing Considerations

Simple design usually produces simpler tests.

For the cancellation validator:

JAVA
class OrderCancellationValidatorTest {
    private final OrderCancellationValidator validator = new OrderCancellationValidator();
    @Test
    void shouldAllowCreatedOrderThatIsNotShipped() {
        Order order = new Order(OrderStatus.CREATED, false);
        assertDoesNotThrow(() -> validator.validate(order));
    }
    @Test
    void shouldRejectNonCreatedOrder() {
        Order order = new Order(OrderStatus.PAID, false);
        assertThrows(OrderCancellationException.class, () -> validator.validate(order));
    }
    @Test
    void shouldRejectShippedOrder() {
        Order order = new Order(OrderStatus.CREATED, true);
        assertThrows(OrderCancellationException.class, () -> validator.validate(order));
    }
}

Positive Test

Verify valid cancellation.

Negative Test

Verify incorrect order status.

Boundary Test

Verify exact states at the cancellation boundary.

Exception Test

Verify repository lookup failures where applicable.

Integration Test

The service integration test should verify:

  • Order retrieved
  • Validation executed
  • State changed
  • Repository saved

Avoid Architecture-Only Tests

Do not create many tests merely to verify unnecessary factories, registries, and resolvers.

Tests should primarily protect business behavior.

20. Refactoring Guidelines

Simplifying an over-engineered system should be done carefully.

Step 1: Understand Existing Behavior

Before removing abstractions, identify:

  • All implementations
  • All consumers
  • Runtime configuration
  • Feature flags
  • Extension points

Step 2: Add Characterization Tests

Legacy behavior should be protected before structural changes.

Step 3: Identify Real Variation

Determine which abstractions genuinely have multiple behaviors.

Keep those.

Step 4: Remove Fake Variation

If a factory always returns one implementation, replace it with direct dependency injection.

Step 5: Inline Unnecessary Layers

Example:

JAVA
RuleRegistry
    -> ValidationEngine
        -> Rule

may be simplified into:

JAVA
Validator

when runtime composition is not required.

Step 6: Replace String Selection

Convert:

JAVA
resolver.get("DEFAULT");

to a typed constructor dependency when selection never changes.

Step 7: Preserve Business Rules

Do not combine architecture cleanup with unrelated behavioral changes.

Step 8: Remove Dead Classes

Once callers are migrated, delete unused:

  • Interfaces
  • Abstract classes
  • Factories
  • Registries
  • Configuration

Step 9: Re-run Tests

Verify both unit tests and relevant Spring context tests.

Step 10: Review the New Design

Ensure simplification has not gone too far.

Do not replace good modularity with one oversized class.

21. Best Practices

Keep the Main Path Obvious

A developer should be able to quickly understand:

  • Input
  • Business validation
  • Processing
  • Persistence
  • Output

Use Abstraction at Real Boundaries

Good boundaries include:

  • External API providers
  • Persistence contracts
  • Messaging infrastructure
  • Storage providers
  • Authentication providers

Prefer Direct Dependencies

Constructor injection is usually clearer than global lookup or custom registries.

Keep Domain Rules Explicit

If a business rule contains five readable conditions, that may be better than five strategy classes.

Refactor When Change Appears

Do not fear changing the design later.

Good automated tests make incremental refactoring practical.

Measure Before Optimizing

Use:

  • Metrics
  • Profiling
  • Query plans
  • Logs
  • APM data

before introducing complex optimizations.

Use Framework Capabilities

Do not rebuild features already provided reliably by Spring, Java, or established libraries without a strong reason.

Keep Public APIs Small

Expose only what consumers require.

Prefer Local Clarity Over Global Generic Reuse

A small amount of intentional duplication may be cheaper than a shared abstraction that couples unrelated workflows.

22. Practices to Avoid

Pattern-Driven Development

Avoid adding patterns because they appear architecturally impressive.

Factory Around a Single Spring Bean

Usually unnecessary.

Interface with No Boundary

Question interfaces that simply duplicate a concrete service API.

Generic Processors with Many Type Parameters

Example:

JAVA
Processor<I, O, C, E, M>

for one processing use case.

Unnecessary Eventing

Do not publish internal events when synchronous direct collaboration is required and simpler.

Excessive DTO Layers

Avoid:

JAVA
RequestDto
    -> CommandDto
        -> DomainDto
            -> PersistenceDto

unless the boundaries genuinely require distinct models.

Wrapper Around Every Library

Do not create abstractions that provide no meaningful policy.

Deep Inheritance

Prefer composition and focused classes.

Configuration Everywhere

Not every constant needs runtime configuration.

Premature Distributed Architecture

Do not create another microservice merely to separate 300 lines of cohesive application code.

Premature Performance Engineering

Do not add caches, queues, parallel execution, or batching without evidence.

23. Code Review Checklist

  • Does this design solve the current requirement directly?
  • Which parts of this complexity are required by the business problem?
  • Is a design pattern being used because a real variation exists?
  • Does this interface have a meaningful contract?
  • Does this interface have more than one implementation or protect an important boundary?
  • Does this factory select between real alternatives?
  • Is this registry required at runtime?
  • Is string-based implementation lookup necessary?
  • Is an abstract class providing meaningful reusable behavior?
  • Could composition be simpler than this inheritance hierarchy?
  • Is this generic framework used by more than one real workflow?
  • Does the design make the normal business path easy to follow?
  • Could a developer understand this feature without reading internal framework documentation?
  • Is a direct method call sufficient instead of an event?
  • Is asynchronous processing actually required?
  • Is caching based on measured performance data?
  • Is parallel processing justified by profiling?
  • Are we rebuilding functionality already provided by Spring or Java?
  • Has a simple condition been replaced with multiple classes unnecessarily?
  • Are configuration properties truly runtime-variable?
  • Does a new abstraction reduce real coupling?
  • Are we predicting future requirements without evidence?
  • Does simplification preserve necessary security controls?
  • Does simplification preserve transaction boundaries?
  • Are tests focused on business behavior rather than architecture plumbing?
  • Would this implementation remain easy to refactor if the requirement changes?
  • Is the complexity proportional to the risk and value of the feature?
  • Could fewer concepts communicate the same design more clearly?

24. Common Pull Request Review Comments

  1. This flow currently has one implementation, so the factory and registry do not appear to provide runtime value. Could we inject the implementation directly?
  1. The current requirement contains two fixed validation rules. A simple validator may be easier to maintain than introducing a configurable rule engine at this stage.
  1. What current requirement requires this event to be asynchronous? If the operation must complete before returning, a direct service call may be simpler and easier to reason about.
  1. This interface currently mirrors the concrete implementation and does not appear to protect a module or external boundary. Could we keep the concrete dependency until a meaningful abstraction is needed?
  1. The new generic processor adds four type parameters for one workflow. Could we start with the concrete business types and generalize only when another use case demonstrates the shared contract?
  1. This cache introduces invalidation and consistency concerns. Do we have measurements showing the current database call is a performance bottleneck?
  1. The caller always requests the "DEFAULT" implementation. Could we replace the runtime lookup with constructor injection?
  1. This abstraction spreads a simple rule across several files. Could we keep the rule explicit in one focused component?
  1. Before adding parallel execution here, could we benchmark the current implementation and confirm that this section is actually CPU-bound?
  1. Spring already provides this capability. Could we use the framework feature rather than maintaining a custom wrapper and lifecycle implementation?

25. Code Review Exercise

The current requirement is:

When an employee record is updated, validate the email address and save the employee.

Review this implementation.

JAVA
public interface EmployeeUpdateStep {
    void execute(EmployeeUpdateContext context);
    int order();
}

public class EmployeeUpdateContext {
    private Employee employee;
    private boolean valid;
    public EmployeeUpdateContext(Employee employee) {
        this.employee = employee;
        this.valid = true;
    }
    public Employee getEmployee() {
        return employee;
    }
    public boolean isValid() {
        return valid;
    }
    public void setValid(boolean valid) {
        this.valid = valid;
    }
}

@Component
public class EmployeeEmailValidationStep implements EmployeeUpdateStep {
    @Override
    public void execute(EmployeeUpdateContext context) {
        Employee employee = context.getEmployee();
        if (employee.getEmail() == null || !employee.getEmail().contains("@")) {
            context.setValid(false);
        }
    }
    @Override
    public int order() {
        return 1;
    }
}

@Component
public class EmployeePersistenceStep implements EmployeeUpdateStep {
    private final EmployeeRepository employeeRepository;
    public EmployeePersistenceStep(EmployeeRepository employeeRepository) {
        this.employeeRepository = employeeRepository;
    }
    @Override
    public void execute(EmployeeUpdateContext context) {
        if (context.isValid()) {
            employeeRepository.save(context.getEmployee());
        }
    }
    @Override
    public int order() {
        return 2;
    }
}

@Component
public class EmployeeUpdatePipeline {
    private final List<EmployeeUpdateStep> steps;
    public EmployeeUpdatePipeline(List<EmployeeUpdateStep> steps) {
        this.steps = steps.stream().sorted(Comparator.comparingInt(EmployeeUpdateStep::order)).toList();
    }
    public void execute(Employee employee) {
        EmployeeUpdateContext context = new EmployeeUpdateContext(employee);
        for (EmployeeUpdateStep step : steps) {
            step.execute(context);
        }
    }
}

@Service
public class EmployeeService {
    private final EmployeeUpdatePipeline pipeline;
    public EmployeeService(EmployeeUpdatePipeline pipeline) {
        this.pipeline = pipeline;
    }
    public void update(Employee employee) {
        pipeline.execute(employee);
    }
}

Learner Task

Identify:

  • Unnecessary abstractions
  • Hidden control flow
  • Validation problems
  • Runtime ordering risks
  • Testing complexity
  • Maintenance problems
  • What should remain separate
  • How the implementation could be simplified

Do not reveal the solution in this section.

26. Exercise Solution

The current requirement contains two clear operations:

  1. Validate employee email.
  2. Save employee.

The pipeline framework is unnecessary for the current problem.

Issue 1: Generic Pipeline Concept

JAVA
EmployeeUpdateStep

exists even though there are only two fixed operations and no dynamic pipeline requirement.

Issue 2: Mutable Context Object

JAVA
EmployeeUpdateContext

creates mutable state merely to communicate whether validation succeeded.

An exception or validation result would be clearer.

Issue 3: Numeric Ordering

JAVA
order()

creates hidden execution rules.

If persistence accidentally executes before validation, invalid data may be stored.

Issue 4: Validation Failure Is Silent

The code only sets:

JAVA
valid = false

The caller receives no useful reason for failure.

Issue 5: Persistence as Pipeline Step

Database persistence is a normal service responsibility here.

Treating it as a generic pipeline stage provides no current benefit.

Issue 6: Increased Testing Surface

Tests now need to verify:

  • Pipeline ordering
  • Context mutation
  • Step discovery
  • Validation step
  • Persistence step
  • Service delegation

Improved Code

JAVA
@Component
public class EmployeeValidator {
    public void validateForUpdate(Employee employee) {
        if (employee == null) {
            throw new IllegalArgumentException("Employee cannot be null");
        }
        String email = employee.getEmail();
        if (email == null || !email.contains("@")) {
            throw new EmployeeValidationException("Employee email is invalid");
        }
    }
}

@Service
public class EmployeeService {
    private final EmployeeRepository employeeRepository;
    private final EmployeeValidator employeeValidator;
    public EmployeeService(EmployeeRepository employeeRepository, EmployeeValidator employeeValidator) {
        this.employeeRepository = employeeRepository;
        this.employeeValidator = employeeValidator;
    }
    public void update(Employee employee) {
        employeeValidator.validateForUpdate(employee);
        employeeRepository.save(employee);
    }
}

Why This Is Better

The sequence is explicit:

JAVA
validate
save

There is:

  • No pipeline
  • No numeric ordering
  • No mutable context
  • No runtime step discovery
  • No silent validation state

The validator remains separate because validation is a meaningful responsibility and may be reused.

The repository remains injected as a real infrastructure dependency.

When a Pipeline Could Become Appropriate

A pipeline may become justified if the application later requires:

  • Dynamically configured steps
  • Multiple independently owned processing stages
  • Conditional step execution
  • Runtime plugin discovery
  • Auditable processing stages
  • Reusable pipeline infrastructure across many workflows

At that point, the real requirements would guide the pipeline design.

27. Interview Perspective

Choosing simple design is an important senior-level interview topic.

Interviewers may show a heavily patterned solution and ask:

Would you approve this design?

A strong answer should evaluate tradeoffs rather than automatically praising abstraction.

Junior-Level Thinking

More patterns mean better design.

Better Engineering Thinking

The design should contain only the complexity justified by the requirement.

Senior-Level Discussion

A senior developer should ask:

  • What changes frequently?
  • What is stable?
  • Which boundaries matter?
  • Is dynamic behavior required?
  • Is this system distributed for a reason?
  • Is performance optimization measured?
  • Can the design be simplified without losing required behavior?
  • Can future changes be handled through normal refactoring?

Relationship with SOLID

SOLID principles should guide design, not create ceremony.

For example, Open/Closed Principle does not mean:

Every class must support unlimited extension without modification.

Sometimes modifying a simple class when a new requirement appears is safer than maintaining a complex extensibility framework for years.

28. Interview Questions and Answers

Basic Question

Question: What is over-engineering in Java?

Answer:

Over-engineering means introducing more architectural complexity than the current requirement needs.

Examples include:

  • Interfaces without meaningful boundaries
  • Factories with one implementation
  • Unnecessary strategy patterns
  • Deep inheritance
  • Generic frameworks for one use case
  • Premature caching
  • Unnecessary asynchronous processing

The result is usually code that is harder to understand and maintain.

Intermediate Question

Question: How do you identify over-engineered code during Pull Request review?

Answer:

Look for signs such as:

  • Several layers around a simple operation
  • Runtime implementation selection with one implementation
  • String-based registries
  • Interfaces created only by convention
  • Abstract classes with one child
  • Generic types with no real variation
  • Infrastructure built for hypothetical future requirements
  • More tests for framework plumbing than business behavior

Then ask what concrete requirement each abstraction solves.

Advanced Question

Question: How do you balance simple design with SOLID principles?

Answer:

SOLID principles should be applied according to real change pressure and responsibility boundaries.

For example:

  • Use Single Responsibility Principle to keep classes focused.
  • Use Dependency Inversion when a meaningful boundary exists.
  • Apply Open/Closed Principle where stable extension points are known.

Do not turn these principles into mechanical rules.

Creating interfaces, factories, and patterns everywhere can make code less maintainable rather than more maintainable.

Scenario-Based Question

Question: A team wants to introduce Redis because a database lookup might become slow as traffic grows. What would you recommend?

Answer:

First measure the current behavior.

Check:

  • Query execution time
  • Indexes
  • Database load
  • Call frequency
  • APM metrics
  • Response-time requirements

If the database is a real bottleneck and caching provides meaningful benefit, Redis may be justified.

Adding distributed caching before evidence exists introduces consistency, invalidation, operational, and failure-handling complexity unnecessarily.

Code-Review Question

Question: What would you review here?

JAVA
PaymentProcessor processor = paymentProcessorFactory.getProcessor("DEFAULT");

Only one processor implementation exists.

Answer:

I would ask why runtime processor selection is required.

If there is one implementation and the caller always requests "DEFAULT", constructor injection is simpler:

JAVA
private final PaymentProcessor paymentProcessor;

The factory can be introduced later if actual runtime selection becomes necessary.

Real-Project Question

Question: Give an example where simpler design improved a production system.

Answer:

A common example is replacing an internal configurable processing pipeline with direct service orchestration.

The original system may contain:

  • Step registry
  • Priority ordering
  • Generic context
  • Dynamic resolver
  • Handler chain

while every production request always executes the same three steps.

Simplifying it to explicit calls:

JAVA
validate()
process()
save()

can improve:

  • Readability
  • Debugging
  • Testability
  • Failure diagnosis
  • Change safety

If dynamic workflow requirements appear later, the pipeline can be reintroduced based on actual use cases.

29. Quick Rule to Remember

Choose the simplest design that clearly solves the real requirement; add complexity only when the requirement proves that you need it.

30. Final Takeaway

Simple design is not simplistic code.

Production-quality simple design still includes the components required for:

  • Correct business behavior
  • Security
  • Validation
  • Transactions
  • External integrations
  • Error handling
  • Testing
  • Observability

The goal is to remove complexity that does not provide real value.

A developer should remember:

  • Patterns are tools, not goals.
  • Interfaces need a meaningful reason.
  • Factories should select real alternatives.
  • Dynamic registries require real runtime variation.
  • Generics should solve genuine type variation.
  • Caching should solve measured performance problems.
  • Asynchronous processing should solve real concurrency or decoupling needs.
  • Microservices should have meaningful deployment or ownership boundaries.
  • Refactoring later is normal engineering.

During Pull Request review, ask:

  • What problem does each layer solve?
  • Is this complexity required today?
  • Is the abstraction based on real variation?
  • Is the normal business flow easy to follow?
  • Does this code introduce new runtime failure paths unnecessarily?
  • Is optimization backed by evidence?
  • Are we duplicating framework features?
  • Could this design be made simpler without losing required behavior?
  • Will another developer understand this quickly during a production incident?

Avoid production code that becomes complicated merely to appear flexible, scalable, reusable, or architecturally sophisticated.

Good engineering does not maximize abstraction.

It minimizes unnecessary complexity while preserving correctness, maintainability, testability, reliability, and the flexibility the system actually needs.