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:
@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:
CustomerQuery
CustomerQueryHandler
CustomerQueryHandlerFactory
CustomerQueryExecutor
QueryExecutionContext
CustomerLookupStrategy
DefaultCustomerLookupStrategy
CustomerLookupStrategyRegistryeven 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:
orderRepository.save(order);with:
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:
Controller
-> Service
-> Repositoryis generally easier to debug than:
Controller
-> CommandBus
-> CommandFactory
-> CommandDispatcher
-> HandlerResolver
-> Handler
-> Processor
-> Repositoryunless 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:
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:
DiscountEngine
DiscountEngineFactory
DiscountRuleRegistry
DiscountRuleResolver
AbstractDiscountRule
DiscountExecutionContext
DiscountResultBuilderfor 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:
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:
order != nullThere 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:
OrderCancellationRuleMore 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.
@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:
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
| Area | Over-Engineered Design | Simple Design |
|---|---|---|
| Rule visibility | Distributed across multiple classes | Visible in one validator |
| Runtime selection | Registry and supports() | None required |
| Execution order | Numeric priorities | Explicit code order |
| Maintainability | Several abstraction layers | Small focused implementation |
| Testability | Many structural tests | Direct behavior tests |
| Debugging | Multiple indirection layers | Short call path |
| Reliability | More configuration/wiring possibilities | Fewer moving parts |
| Change surface | Multiple classes may change | Localized |
| Future extensibility | Built before requirement | Added when requirement appears |
| Performance | Minor unnecessary framework overhead | Direct 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:
ClaimSubmissionProvider
ClaimSubmissionProviderFactory
ClaimSubmissionProviderRegistry
ProviderResolver
ProviderRoutingStrategy
ProviderRoutingContext
AbstractClaimSubmissionProvider
ClaimSubmissionPipeline
ClaimPipelineStage
ClaimPipelineExecutorOnly 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:
CustomerService
CustomerServiceImplThis 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:
paymentProcessors.get("DEFAULT");when only one processor exists.
Deep Inheritance
Example:
BaseProcessor
-> AbstractValidatedProcessor
-> AbstractTransactionalProcessor
-> OrderProcessorThe inheritance structure may be harder to understand than composition.
Generic Framework for One Workflow
Example:
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:
inventoryService.reserve(order);with:
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:
CreditScoreProvidercan 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:
orders.stream()to:
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:
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:
Map<String, Object> securityContextmay 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:
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:
RuleRegistry
-> ValidationEngine
-> Rulemay be simplified into:
Validatorwhen runtime composition is not required.
Step 6: Replace String Selection
Convert:
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:
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:
RequestDto
-> CommandDto
-> DomainDto
-> PersistenceDtounless 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
- This flow currently has one implementation, so the factory and registry do not appear to provide runtime value. Could we inject the implementation directly?
- 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.
- 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.
- 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?
- 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?
- This cache introduces invalidation and consistency concerns. Do we have measurements showing the current database call is a performance bottleneck?
- The caller always requests the
"DEFAULT"implementation. Could we replace the runtime lookup with constructor injection?
- This abstraction spreads a simple rule across several files. Could we keep the rule explicit in one focused component?
- Before adding parallel execution here, could we benchmark the current implementation and confirm that this section is actually CPU-bound?
- 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.
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:
- Validate employee email.
- Save employee.
The pipeline framework is unnecessary for the current problem.
Issue 1: Generic Pipeline Concept
EmployeeUpdateStepexists even though there are only two fixed operations and no dynamic pipeline requirement.
Issue 2: Mutable Context Object
EmployeeUpdateContextcreates mutable state merely to communicate whether validation succeeded.
An exception or validation result would be clearer.
Issue 3: Numeric Ordering
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:
valid = falseThe 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
@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:
validate
saveThere 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?
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:
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:
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.