1. Introduction
Premature abstraction happens when developers introduce interfaces, abstract classes, factories, strategies, generic frameworks, extension points, or additional layers before there is a real requirement for them.
Abstraction itself is not bad.
Java applications need abstractions for many legitimate reasons:
- Separating business logic from infrastructure
- Supporting multiple implementations
- Isolating external integrations
- Making important boundaries explicit
- Improving testability
- Protecting the application from unstable APIs
The problem starts when developers attempt to predict future requirements that do not yet exist.
For example, a simple service:
@Service
public class InvoiceService {
public BigDecimal calculateTotal(Invoice invoice) {
return invoice.getItems()
.stream()
.map(InvoiceItem::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}may be unnecessarily redesigned as:
public interface InvoiceCalculationStrategy {
BigDecimal calculate(Invoice invoice);
}
public abstract class AbstractInvoiceCalculationStrategy
implements InvoiceCalculationStrategy {
protected abstract BigDecimal calculateBaseAmount(Invoice invoice);
}
@Component
public class DefaultInvoiceCalculationStrategy
extends AbstractInvoiceCalculationStrategy {
@Override
protected BigDecimal calculateBaseAmount(Invoice invoice) {
return invoice.getItems()
.stream()
.map(InvoiceItem::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
@Override
public BigDecimal calculate(Invoice invoice) {
return calculateBaseAmount(invoice);
}
}
@Component
public class InvoiceCalculationStrategyFactory {
private final DefaultInvoiceCalculationStrategy strategy;
public InvoiceCalculationStrategyFactory(
DefaultInvoiceCalculationStrategy strategy) {
this.strategy = strategy;
}
public InvoiceCalculationStrategy getStrategy(
Invoice invoice) {
return strategy;
}
}If the application has only one calculation rule and no realistic variation requirement, these extra abstractions create complexity without solving an actual problem.
During code review, a reviewer should ask:
What current problem does this abstraction solve?
If the answer is only:
We may need this someday.
the abstraction deserves closer review.
2. What This Topic Means
Premature abstraction means generalizing code before enough real variation exists to understand what should actually be generalized.
Consider a Spring Boot application that currently sends account verification emails.
A developer creates:
public interface MessageChannel {
void send(Message message);
}Then adds:
EmailMessageChannelfollowed by:
MessageChannelFactory
MessageChannelRegistry
MessageChannelResolver
AbstractMessageChannel
MessageChannelConfigurationeven though the application supports only email and no SMS, push notification, WhatsApp, or other channel requirement exists.
The abstraction was built around an imagined future.
The team now has to understand and maintain several classes for a problem that originally required one simple service.
A simpler implementation may be:
@Service
public class VerificationEmailService {
private final EmailClient emailClient;
public VerificationEmailService(
EmailClient emailClient) {
this.emailClient = emailClient;
}
public void sendVerificationEmail(
User user,
String verificationLink) {
emailClient.send(
user.getEmail(),
"Verify your account",
verificationLink);
}
}If a second meaningful notification channel appears later, the team will have real information about:
- What behavior is shared
- What behavior differs
- What data each channel requires
- What failure handling is needed
- Whether one abstraction is even appropriate
That is a better time to introduce abstraction.
3. Why It Matters in Real Projects
Premature abstraction affects real software teams because every abstraction creates a maintenance cost.
Readability
Simple behavior may become distributed across several files.
Instead of reading:
paymentService.refund(payment);a developer may need to trace:
RefundProcessor
-> RefundProcessorFactory
-> RefundProcessorRegistry
-> DefaultRefundProcessor
-> AbstractRefundProcessor
-> RefundExecutionTemplateThe code may technically be flexible but unnecessarily difficult to understand.
Maintainability
Every extra abstraction becomes another contract that developers must preserve.
Changing a simple rule may require modifying:
- Interface
- Implementation
- Factory
- Registry
- Configuration
- Tests
- Dependency injection setup
Debugging
Additional indirection increases the number of places a developer must inspect during production investigation.
A debugger may jump through multiple abstraction layers before reaching the actual business operation.
Reliability
Complex designs create more configuration and wiring paths.
This can introduce:
- Wrong implementation selection
- Missing bean configuration
- Incorrect registry entries
- Unexpected fallback behavior
Team Development
New developers must learn abstractions that may not represent actual business concepts.
This increases onboarding time and review effort.
Performance
Premature abstraction usually does not create significant algorithmic performance problems.
However, unnecessarily dynamic designs can sometimes introduce:
- Repeated lookups
- Reflection
- Excessive object creation
- Unnecessary configuration processing
These are secondary concerns. Maintainability is usually the larger problem.
4. Core Concept
The key principle is:
Abstract after you understand the variation, not before you imagine it.
Developers often see duplicate-looking code and immediately create a generic abstraction.
But code that looks similar today may change in different directions tomorrow.
Example
Suppose the application has:
CustomerCsvExporterand later receives a requirement for:
OrderCsvExporterA developer may immediately create:
AbstractExporter<T>
GenericExportStrategy<T>
ExportFactory
ExportConfiguration
ExportWriter<T>That may be unnecessary.
The two exporters might look similar today but eventually have very different requirements:
Customer export may require:
- Data masking
- Consent filtering
- Personal-data handling
Order export may require:
- Line item expansion
- Tax columns
- Currency conversion
Extracting a common abstraction too early could force unrelated business rules into one artificial design.
Useful Abstraction
An abstraction is usually justified when there is evidence such as:
- Multiple real implementations
- Repeated business behavior
- External implementation likely to change
- Clear architectural boundary
- Stable common contract
- Testing requires substitutable behavior
- Multiple modules depend on the same capability
The important word is evidence.
5. Important Rules
- Start with the simplest design that correctly solves the current requirement.
- Do not create an interface automatically for every service class.
- Do not introduce abstract classes merely to share a few lines of code.
- Avoid factory classes when normal constructor injection is sufficient.
- Do not build plugin architectures without actual plugin requirements.
- Avoid generic frameworks for one use case.
- Introduce abstractions around stable concepts, not guessed future variations.
- Prefer duplication temporarily when the correct abstraction is still unclear.
- Observe how multiple implementations evolve before extracting shared behavior.
- Keep domain-specific behavior explicit.
- Do not hide simple business rules behind unnecessary design patterns.
- Avoid excessive inheritance hierarchies.
- Use composition when real behavior variation exists.
- Do not add strategy patterns simply because a method contains one
if. - Review whether a new interface has more than one meaningful implementation.
- Do not treat every theoretical future change as a current design requirement.
- Make abstractions easy to remove when they no longer provide value.
- Prefer local refactoring over building reusable infrastructure prematurely.
- Keep public contracts as small as possible.
- Let actual project evolution guide abstraction.
6. Bad Code Example
Consider an e-commerce application that currently calculates one delivery fee rule.
The business rule is simple:
- Orders of ₹1,000 or more receive free delivery.
- Other orders pay ₹80 delivery charge.
A developer introduces the following design.
public interface DeliveryFeeStrategy {
BigDecimal calculate(Order order);
String getStrategyType();
}
public abstract class AbstractDeliveryFeeStrategy
implements DeliveryFeeStrategy {
protected static final BigDecimal FREE_DELIVERY_THRESHOLD =
BigDecimal.valueOf(1000);
protected static final BigDecimal STANDARD_DELIVERY_FEE =
BigDecimal.valueOf(80);
protected boolean qualifiesForFreeDelivery(
Order order) {
return order.getTotalAmount()
.compareTo(FREE_DELIVERY_THRESHOLD) >= 0;
}
}
@Component
public class StandardDeliveryFeeStrategy
extends AbstractDeliveryFeeStrategy {
@Override
public BigDecimal calculate(Order order) {
if (qualifiesForFreeDelivery(order)) {
return BigDecimal.ZERO;
}
return STANDARD_DELIVERY_FEE;
}
@Override
public String getStrategyType() {
return "STANDARD";
}
}
@Component
public class DeliveryFeeStrategyRegistry {
private final Map<String, DeliveryFeeStrategy> strategies;
public DeliveryFeeStrategyRegistry(
List<DeliveryFeeStrategy> strategies) {
this.strategies = strategies.stream()
.collect(Collectors.toMap(
DeliveryFeeStrategy::getStrategyType,
Function.identity()));
}
public DeliveryFeeStrategy getStrategy(
String strategyType) {
DeliveryFeeStrategy strategy =
strategies.get(strategyType);
if (strategy == null) {
throw new IllegalArgumentException(
"Unknown strategy: " + strategyType);
}
return strategy;
}
}
@Service
public class DeliveryFeeService {
private final DeliveryFeeStrategyRegistry registry;
public DeliveryFeeService(
DeliveryFeeStrategyRegistry registry) {
this.registry = registry;
}
public BigDecimal calculate(
Order order) {
DeliveryFeeStrategy strategy =
registry.getStrategy("STANDARD");
return strategy.calculate(order);
}
}There is only one delivery fee implementation.
The registry always selects "STANDARD".
No alternative delivery fee model currently exists.
7. Problems in the Bad Code
Unnecessary Interface
There is only one current behavior.
DeliveryFeeStrategydoes not yet protect a meaningful variation point.
Artificial Abstract Base Class
AbstractDeliveryFeeStrategyexists mainly to support one subclass.
This inheritance hierarchy adds structure without useful polymorphism.
Unnecessary Registry
The registry creates a map of strategies even though there is exactly one strategy.
Hardcoded Strategy Selection
The service performs:
registry.getStrategy("STANDARD");This makes the abstraction even less useful because the caller already knows the only available implementation.
Increased File Count
A simple business rule has been distributed across:
- Interface
- Abstract class
- Concrete implementation
- Registry
- Service
Increased Cognitive Load
A developer trying to understand the ₹80 delivery rule must navigate multiple classes.
More Tests Required
The team may now write separate tests for:
- Strategy
- Registry
- Service
- Unknown strategy handling
The original requirement only required testing the business calculation.
More Failure Paths
The new architecture creates an artificial runtime failure:
Unknown strategyThis failure did not exist in the simple design.
Maintenance Issue
Changing the delivery threshold may require finding constants in an abstract class even though the rule belongs to the delivery-fee calculation.
Performance
Map creation and lookup are technically more expensive than a direct method call.
However, the performance difference is insignificant here.
The real issue is unnecessary design complexity.
Security
There is no meaningful security concern caused specifically by this abstraction.
8. Code Review Findings
A reviewer should notice:
- Only one strategy currently exists.
- The registry has no real selection responsibility.
- The strategy key
"STANDARD"is hardcoded by the caller. - The abstract class has only one implementation.
- The interface has no demonstrated variation.
- The architecture creates runtime lookup where compile-time dependency would be simpler.
- The business rule is harder to locate.
- Several classes exist only to support imagined future behavior.
- The implementation increases test and maintenance surface.
- No requirement in the current change appears to justify dynamic strategy resolution.
The reviewer should ask whether multiple delivery rules are already planned and confirmed.
If not, the simpler design should probably be preferred.
9. Reviewer Comment Example
There is currently only one delivery-fee behavior, and the service always resolves
"STANDARD". Could we keep this as a direct service implementation for now and introduce a strategy abstraction when we have a real second rule?
Another useful comment:
The interface, abstract class, and registry add several layers around one business rule. What current variation are we protecting here? If there is no confirmed requirement, simplifying this would make the flow easier to understand and test.
10. Improved Code
A simpler implementation can keep the rule directly in the service.
@Service
public class DeliveryFeeService {
private static final BigDecimal FREE_DELIVERY_THRESHOLD =
BigDecimal.valueOf(1000);
private static final BigDecimal STANDARD_DELIVERY_FEE =
BigDecimal.valueOf(80);
public BigDecimal calculate(
Order order) {
if (order.getTotalAmount()
.compareTo(FREE_DELIVERY_THRESHOLD) >= 0) {
return BigDecimal.ZERO;
}
return STANDARD_DELIVERY_FEE;
}
}If configuration is required:
@ConfigurationProperties(
prefix = "delivery")
public record DeliveryFeeProperties(
BigDecimal freeDeliveryThreshold,
BigDecimal standardFee) {
}Then:
@Service
public class DeliveryFeeService {
private final DeliveryFeeProperties properties;
public DeliveryFeeService(
DeliveryFeeProperties properties) {
this.properties = properties;
}
public BigDecimal calculate(
Order order) {
if (order.getTotalAmount()
.compareTo(
properties.freeDeliveryThreshold()) >= 0) {
return BigDecimal.ZERO;
}
return properties.standardFee();
}
}This design solves the current business requirement without introducing unnecessary polymorphism.
11. Improved Code Explanation
One Business Concept, One Clear Location
The delivery-fee rule is easy to find.
A developer opening:
DeliveryFeeServicecan immediately understand the calculation.
No Artificial Runtime Selection
There is no strategy registry and no string-based lookup.
Fewer Classes
The implementation requires only the classes that provide real value.
Easier Testing
Tests directly verify:
calculate(order)without configuring registries or strategy lists.
Easier Debugging
The call stack directly reaches the business calculation.
Still Refactorable Later
If the application later introduces:
- Express delivery
- International delivery
- Marketplace seller delivery
- Membership-based delivery
the service can be refactored when those differences are known.
Nothing prevents introducing a strategy pattern later.
12. Bad Code vs Improved Code
| Area | Premature Abstraction | Simple Current Design |
|---|---|---|
| Number of concepts | Interface, base class, strategy, registry, service | Service |
| Readability | Business rule spread across files | Rule easy to locate |
| Maintainability | More contracts to maintain | Small change surface |
| Testability | Multiple units and wiring to test | Direct business-rule tests |
| Runtime failure paths | Strategy lookup can fail | No unnecessary lookup |
| Flexibility | Supports hypothetical variations | Supports current requirement |
| Debugging | More indirection | Direct call path |
| Refactoring later | Already generalized without evidence | Can generalize when variation appears |
| Performance | Small unnecessary lookup overhead | Direct execution |
| Reliability | Additional wiring/configuration | Fewer moving parts |
13. Real Project Scenario
Consider a banking application that initially supports downloading account statements only as PDF.
The requirement is:
Generate a PDF statement for a customer's selected account and date range.
A developer anticipates future formats and creates:
StatementGenerator<T>
AbstractStatementGenerator<T>
StatementGenerationStrategy<T>
StatementGeneratorFactory
StatementGeneratorResolver
StatementFormatRegistry
StatementOutputAdapter<T>Only one implementation exists:
PdfStatementGeneratorFor the next year, no other format is requested.
During that time, developers must maintain the complete abstraction hierarchy whenever statement generation changes.
Later, the business requests CSV statements.
The team discovers that PDF and CSV do not share the architecture originally predicted.
PDF requires:
- Layout templates
- Page headers
- Page breaks
- Fonts
- Branding
CSV requires:
- Flat rows
- Delimiters
- Escaping
- Column configuration
The original generic abstraction now becomes an obstacle.
If the original implementation had remained simple, the team could have observed the actual differences before extracting the correct common boundary.
14. Production Impact
Premature abstraction usually causes maintenance problems rather than immediate production failures.
However, it can still create real operational risk.
Wrong Runtime Selection
Registries and factories can select the wrong implementation due to incorrect keys or configuration.
Missing Bean Configuration
Over-generalized Spring designs may introduce ambiguous or missing beans.
Difficult Debugging
During an incident, engineers may need to trace through multiple layers before finding the actual failing code.
Slower Production Fixes
A simple change may require understanding an entire framework-like architecture before applying a safe fix.
Larger Regression Surface
When behavior is spread across many abstraction layers, developers may unintentionally change shared infrastructure while fixing one feature.
Configuration Errors
Generic factories and registries often rely on:
- String identifiers
- Mapping configuration
- Bean names
- Feature flags
Incorrect configuration creates failure modes that simpler code would not have.
Maintenance Problems
The most common production impact is not application downtime.
It is increased difficulty changing production code safely.
15. Common Developer Mistakes
Interface for Every Service
Example:
UserService
UserServiceImplThis is not automatically wrong.
But if the interface exists only because:
Every service should have an interface.
then the abstraction may provide little value.
Abstract Class with One Child
Example:
AbstractPaymentValidator
-> PaymentValidatorIf no reusable template behavior or inheritance relationship exists, the base class may be unnecessary.
Factory for One Implementation
Example:
CustomerMapperFactory.getMapper()when there is only one mapper.
Strategy Pattern for One Condition
A developer sees:
if (customer.isPremium())and immediately introduces multiple strategy classes.
A simple conditional may be clearer.
Generic Type Too Early
Example:
Processor<T, R, C, E>created for one concrete processing flow.
Generic designs should solve repeated type-level variation, not anticipated complexity.
Building an Internal Framework
Feature code turns into reusable infrastructure before a second consumer exists.
Removing All Duplication Immediately
Two similar methods are merged too early even though their business requirements are different.
Abstraction Based on Similar Syntax
Code that looks similar is not necessarily conceptually the same.
Future-Proofing Without Evidence
Developers design for:
- Five databases
- Ten notification vendors
- Multiple cloud providers
- Plugin architectures
- Dynamic workflows
without confirmed requirements.
Wrapping Framework APIs Without Value
A team may create:
CustomJdbcTemplate
CustomRestTemplate
CustomLogger
CustomObjectMappereven when the wrapper provides no meaningful policy or boundary.
16. Edge Cases
External Integration Boundary
Even with one implementation, an abstraction may be justified around an external provider.
Example:
CreditScoreProvidermay be useful even if only Experian is currently supported because the business concept is "obtain credit score," not "call Experian."
The abstraction isolates unstable external details.
Expensive Replacement Risk
If a provider is contractually likely to change, abstraction may be reasonable before a second implementation exists.
The decision should be based on real project knowledge.
Public Library API
Library code may require stable abstractions earlier because changing a published API later can break consumers.
Compliance Requirement
Banking or healthcare systems may require provider-independent boundaries for regulatory, audit, or data-separation reasons.
Testing Requirement
Sometimes an interface exists because the dependency represents an external capability that must be substituted during testing.
However, Mockito can mock concrete non-final classes as well, so "we need an interface for mocking" is not always sufficient justification.
Multiple Similar Implementations
If two real implementations already exist, abstraction may be appropriate.
The key is whether they share a stable conceptual contract.
Duplicate Code
Do not automatically leave large duplication forever.
Avoiding premature abstraction does not mean avoiding abstraction entirely.
The correct approach is:
- Observe duplication
- Understand why it exists
- Wait until the common concept becomes clear
- Extract when confidence is high
17. Performance Considerations
Premature abstraction usually has little meaningful effect on application performance.
The main cost is developer complexity.
Indirect Calls
Interface dispatch and normal Spring bean calls are not generally performance concerns in enterprise applications.
Registry Lookup
Example:
strategies.get(type)adds a map lookup.
This is normally insignificant.
Reflection
Some generic frameworks use reflection heavily.
If performed repeatedly on high-volume code paths, reflection may affect performance.
However, this should be measured rather than assumed.
Excessive Object Creation
A highly generalized pipeline may create unnecessary wrapper, context, command, and result objects.
Example:
ProcessingContext
ProcessingRequest
ProcessingCommand
ProcessingResult
ProcessingMetadatafor a trivial operation.
Again, the primary problem is often complexity rather than memory usage.
Database and API Calls
Premature abstraction does not automatically increase database or external API calls.
If extra abstractions hide those calls, however, performance problems may become harder to notice during code review.
Practical Rule
Do not reject premature abstraction primarily because of CPU cost.
Reject it when complexity is higher than the value it provides.
18. Security Considerations
Premature abstraction is not automatically a security problem.
However, overly generic designs can sometimes weaken security clarity.
Generic Authorization Hooks
A framework may introduce optional callbacks such as:
AuthorizationStrategywithout making authorization mandatory.
This could allow a new implementation to forget security checks.
Over-Generalized Request Processing
A generic processor that accepts arbitrary attributes or maps may reduce type safety.
Example:
Map<String, Object> contextcan make it unclear which sensitive values are being passed between components.
Security Logic Hidden in Base Classes
If authorization is implemented in an abstract superclass, developers may not clearly see whether subclasses correctly execute it.
Sensitive Data Exposure
Generic logging or serialization frameworks may automatically include fields that should not be exposed.
Important Point
Avoiding premature abstraction does not itself make an application secure.
Security should remain explicit and enforceable regardless of abstraction level.
19. Testing Considerations
Tests should focus on real business behavior rather than artificial architecture.
For the simplified delivery service:
@Test
void shouldReturnFreeDeliveryForOrderAtThreshold() {
DeliveryFeeService service =
new DeliveryFeeService();
Order order =
new Order(BigDecimal.valueOf(1000));
BigDecimal result =
service.calculate(order);
assertEquals(
BigDecimal.ZERO,
result);
}Positive Test
Order above threshold:
1500 -> 0Boundary Test
Order exactly at threshold:
1000 -> 0Negative Boundary Test
Order below threshold:
999.99 -> 80Invalid Input
If null orders are not allowed, validation behavior should be defined and tested.
Why Simpler Tests Matter
With premature abstractions, developers may write tests for:
- Factory registration
- Strategy lookup
- Abstract base behavior
- Resolver configuration
- Strategy keys
even though none of those concepts exist in the business requirement.
Tests should protect required behavior, not unnecessary architecture.
When Abstraction Appears Later
If a second delivery model is introduced, add tests proving:
- Correct implementation selection
- Shared contract behavior
- Different rules
- Fallback or unsupported cases
at that point.
20. Refactoring Guidelines
When removing premature abstraction, preserve business behavior.
Step 1: Identify Real Implementations
Check how many implementations actually exist.
Example:
DeliveryFeeStrategymay have only:
StandardDeliveryFeeStrategyStep 2: Check Consumers
Search for all consumers of:
- Interface
- Factory
- Registry
- Abstract base
- Resolver
Make sure the abstraction is not used by another module.
Step 3: Preserve Existing Tests
Run existing tests before changing structure.
Step 4: Move Behavior to the Simplest Appropriate Class
Example:
StandardDeliveryFeeStrategy.calculate()can move into:
DeliveryFeeService.calculate()Step 5: Remove Runtime Selection
If the application always selects one implementation, remove unnecessary registry lookup.
Step 6: Remove Dead Abstraction Layers
Delete classes such as:
DeliveryFeeStrategy
AbstractDeliveryFeeStrategy
DeliveryFeeStrategyRegistryonly after all callers have migrated.
Step 7: Keep Configuration That Has Real Value
Do not hardcode values simply because abstractions are being removed.
Business configuration may still belong in:
@ConfigurationPropertiesStep 8: Run Unit and Integration Tests
Verify:
- Business outputs remain unchanged
- Spring context still starts
- Bean wiring remains correct
Step 9: Keep the Refactoring Focused
Do not change business rules while simplifying architecture unless required.
21. Best Practices
Solve Today's Requirement Clearly
Build the simplest correct implementation for the current business need.
Wait for Evidence
Introduce abstraction when real variation demonstrates what should be common.
Abstract Stable Concepts
Good abstraction:
PaymentGatewaywhen payment processing is a stable business capability and provider details should remain isolated.
Weak abstraction:
GenericProcessor<T>created because several classes contain methods called process().
Keep Interfaces Small
If an interface is justified, expose only the capability consumers actually need.
Prefer Composition over Deep Inheritance
When behavior genuinely varies, injected collaborators are usually easier to reason about than large inheritance hierarchies.
Keep Abstraction Near Boundaries
Useful abstraction often appears around:
- External APIs
- Message brokers
- Storage
- Payment providers
- Authentication providers
- Infrastructure services
Allow Small Duplication Temporarily
Duplication is sometimes cheaper than the wrong abstraction.
Refactor When the Pattern Becomes Clear
Do not avoid abstraction forever.
Extract it when multiple implementations reveal stable common behavior.
22. Practices to Avoid
Interface Without a Contractual Purpose
Avoid interfaces created only to satisfy naming conventions.
Abstract Base Classes for Code Reuse Alone
Inheritance creates coupling.
Do not introduce a base class just to share two helper methods.
Factories That Always Return One Type
Example:
return new DefaultProcessor();A normal constructor dependency is probably simpler.
String-Based Registries Without Dynamic Requirements
Example:
processors.get("DEFAULT");This creates runtime complexity without real selection.
Generic Frameworks for One Feature
Avoid building reusable engines before multiple consumers prove the reuse requirement.
Deep Inheritance Trees
Example:
BaseProcessor
-> AbstractValidatedProcessor
-> AbstractDatabaseProcessor
-> CustomerProcessorThese hierarchies are difficult to understand and modify.
Unnecessary Wrapper Classes
Do not wrap stable framework APIs unless the wrapper enforces a meaningful policy or isolates an important boundary.
Hypothetical Extension Points
Avoid adding empty hooks such as:
protected void beforeProcess() {
}
protected void afterProcess() {
}when no subclass needs them.
Design Pattern by Default
Strategy, Factory, Template Method, Builder, Visitor, Chain of Responsibility, and similar patterns are tools.
They should solve actual problems rather than act as architectural decoration.
23. Code Review Checklist
- Does this abstraction solve a current requirement?
- How many real implementations of this interface exist?
- Is a second implementation actually planned or only hypothetical?
- Does this interface represent a stable business or architectural capability?
- Could this implementation be simpler as one concrete service?
- Does this abstract class have more than one meaningful subclass?
- Is inheritance providing real polymorphic behavior or only sharing a few lines?
- Does this factory actually select between multiple implementations?
- Is this registry needed at runtime?
- Is the caller always requesting the same strategy?
- Are string identifiers introducing unnecessary runtime failure paths?
- Has a simple conditional been replaced with several classes without clear benefit?
- Is this generic type solving real type variation?
- Is this code creating a reusable framework before a second consumer exists?
- Does the abstraction make the business rule harder to find?
- Will developers need to modify several abstraction layers for a simple requirement change?
- Are tests primarily testing architecture rather than business behavior?
- Could temporary duplication be safer than this abstraction?
- Does external-provider isolation justify the abstraction?
- Does the abstraction protect an important module boundary?
- Is the abstraction based on actual variation or guessed future change?
- Would removing the abstraction make the implementation easier to understand without reducing required flexibility?
- Is the design violating YAGNI by implementing unrequested flexibility?
- Are public APIs being generalized beyond current consumer needs?
- Is complexity proportional to the problem being solved?
24. Common Pull Request Review Comments
- We currently have only one implementation of this interface. What current variation requires this abstraction? If there is none, could we keep the service concrete for now?
- This factory always returns
DefaultPaymentProcessor. Could we inject that dependency directly until multiple processor implementations actually exist?
- The new registry introduces string-based runtime lookup, but the caller always requests
"STANDARD". A direct dependency would be simpler and safer.
- This abstract class has only one subclass and does not appear to define a meaningful template. Could we move the behavior into the concrete service?
- The proposed generic processor adds several type parameters for one current use case. Could we start with the concrete business model and generalize when another use case proves the common contract?
- This change introduces six classes around a single validation rule. Could we keep the rule explicit and extract a strategy when multiple validation policies actually exist?
- I see the goal is future extensibility, but we do not yet know how future implementations will differ. Keeping this simpler now may help us extract the correct abstraction once that variation exists.
- This interface appears to duplicate the implementation API without protecting an external or module boundary. Could we remove it unless there is another consumer requirement?
- The abstraction makes the business rule difficult to trace during review. Could we keep the current behavior together and avoid introducing extension hooks until they are needed?
- These two implementations look similar, but their business rules appear different. I would avoid extracting a shared base class until we know the duplication represents the same concept.
25. Code Review Exercise
Review the following Spring Boot code.
The current business requirement is:
Send an account-lock notification by email when a user account is locked.
No SMS, push notification, or other channel is currently required.
public interface NotificationPayload {
String getRecipient();
String getMessage();
}
public interface NotificationChannel<T extends NotificationPayload> {
void send(T payload);
String channelName();
}
public abstract class AbstractNotificationChannel<T extends NotificationPayload>
implements NotificationChannel<T> {
protected void validate(T payload) {
if (payload == null) {
throw new IllegalArgumentException(
"Payload cannot be null");
}
}
protected abstract void doSend(T payload);
@Override
public void send(T payload) {
validate(payload);
doSend(payload);
}
}
public record EmailNotificationPayload(
String recipient,
String message)
implements NotificationPayload {
@Override
public String getRecipient() {
return recipient;
}
@Override
public String getMessage() {
return message;
}
}
@Component
public class EmailNotificationChannel
extends AbstractNotificationChannel<EmailNotificationPayload> {
private final EmailClient emailClient;
public EmailNotificationChannel(
EmailClient emailClient) {
this.emailClient = emailClient;
}
@Override
protected void doSend(
EmailNotificationPayload payload) {
emailClient.send(
payload.recipient(),
"Account locked",
payload.message());
}
@Override
public String channelName() {
return "EMAIL";
}
}
@Component
public class NotificationChannelRegistry {
private final Map<String, NotificationChannel<?>> channels;
public NotificationChannelRegistry(
List<NotificationChannel<?>> channels) {
this.channels = channels.stream()
.collect(Collectors.toMap(
NotificationChannel::channelName,
Function.identity()));
}
public NotificationChannel<?> get(
String channel) {
return channels.get(channel);
}
}
@Service
public class AccountLockNotificationService {
private final NotificationChannelRegistry registry;
public AccountLockNotificationService(
NotificationChannelRegistry registry) {
this.registry = registry;
}
@SuppressWarnings("unchecked")
public void notifyUser(
User user) {
NotificationChannel<EmailNotificationPayload> channel =
(NotificationChannel<EmailNotificationPayload>)
registry.get("EMAIL");
channel.send(
new EmailNotificationPayload(
user.getEmail(),
"Your account has been locked."));
}
}Learner Task
Identify:
- Unnecessary abstractions
- Generic complexity
- Runtime risks
- Type-safety problems
- Maintenance problems
- Which parts provide real value
- How the code could be simplified
- When the abstraction might become justified later
Do not reveal the solution until completing your review.
26. Exercise Solution
The code creates a generalized notification framework for one current requirement.
Issue 1: Generic Notification Contract
NotificationChannel<T extends NotificationPayload>introduces generics even though only one payload exists.
There is no demonstrated requirement for multiple payload types.
Issue 2: Abstract Base Class
AbstractNotificationChannelcontains common validation for one subclass.
A normal private validation method inside the service would be simpler.
Issue 3: Registry with One Channel
The application currently has only:
EMAILThe map-based registry provides no useful selection.
Issue 4: String-Based Lookup
registry.get("EMAIL");moves a compile-time dependency into runtime configuration.
A typo could return null.
Issue 5: Unsafe Cast
(NotificationChannel<EmailNotificationPayload>)requires an unchecked cast because the registry loses generic type information.
The abstraction has introduced a type-safety problem that did not exist before.
Issue 6: Hidden Business Intent
The real requirement is:
Send account-lock email.
But the code is designed around a generic notification framework.
Issue 7: More Failure Modes
Possible new failures include:
- Missing
"EMAIL"registry entry - Wrong channel type
- Null channel
- Invalid cast
These do not add business value.
Useful Dependency
EmailClient is a legitimate external dependency and should remain injected.
It may represent an actual integration boundary.
Improved Code
@Service
public class AccountLockNotificationService {
private final EmailClient emailClient;
public AccountLockNotificationService(
EmailClient emailClient) {
this.emailClient = emailClient;
}
public void notifyUser(
User user) {
if (user == null) {
throw new IllegalArgumentException(
"User cannot be null");
}
emailClient.send(
user.getEmail(),
"Account locked",
"Your account has been locked.");
}
}Why This Is Better
The service directly represents the current business use case.
Dependencies are clear:
AccountLockNotificationService -> EmailClientThere is:
- No registry
- No generic hierarchy
- No unsafe cast
- No string-based lookup
- No unnecessary abstract class
When to Refactor
Suppose a real requirement later adds:
- SMS
- Push notification
and the business wants runtime channel selection.
At that point, the team can compare the actual implementations.
They may discover that the useful abstraction is:
AccountLockNotifierwith implementations such as:
EmailAccountLockNotifier
SmsAccountLockNotifierOr they may decide the service should publish an event and allow independent listeners.
The correct design becomes clearer once the real requirement exists.
27. Interview Perspective
Avoiding premature abstraction commonly appears in senior Java, system design, code review, and architecture interviews.
Interviewers may show a highly generalized implementation and ask:
Is this good SOLID design?
A strong candidate should not automatically praise the use of interfaces and design patterns.
The candidate should examine:
- Current requirements
- Number of implementations
- Change history
- External boundaries
- Testability needs
- Complexity introduced
- Likelihood and cost of future variation
Weak Interview Answer
Interfaces are always better because they reduce coupling.
That is too simplistic.
Better Answer
Interfaces are useful when they represent a meaningful contract or variation point. If there is one stable internal implementation and no boundary to protect, introducing an interface may add unnecessary complexity.
Senior-Level Perspective
Senior developers should understand the tension between:
- YAGNI
- DRY
- SOLID
- Simplicity
- Extensibility
These principles are not mechanical rules.
For example:
DRY does not mean:
Every duplicate line must be extracted immediately.
SOLID does not mean:
Every class requires an interface.
Open/Closed Principle does not mean:
Design every feature for unlimited future extension.
Good design requires judgment.
28. Interview Questions and Answers
Basic Question
Question: What is premature abstraction?
Answer:
Premature abstraction occurs when code is generalized before there is enough real variation or business need to justify the abstraction.
Examples include creating:
- Interfaces with one implementation
- Factories for one class
- Strategies for one rule
- Generic frameworks for one use case
- Abstract classes with one subclass
The main problem is unnecessary complexity.
Intermediate Question
Question: Is an interface with one implementation always a premature abstraction?
Answer:
No.
An interface with one implementation can still be useful when it represents a meaningful boundary.
Examples include:
- Payment provider boundary
- External credit-score provider
- File storage provider
- Message broker adapter
- Public library contract
The important question is what architectural problem the interface solves.
If the answer is only:
We always create interfaces for services.
then the abstraction should be questioned.
Advanced Question
Question: How do you decide when to introduce an abstraction?
Answer:
Useful signals include:
- Two or more real implementations
- Stable shared behavior has emerged
- Multiple consumers require the same capability
- An external implementation needs isolation
- Change history shows a volatile dependency
- Testing requires substitutable behavior
- A clear module boundary exists
The abstraction should represent a real concept rather than coincidental code similarity.
Scenario-Based Question
Question: Two Spring Boot services contain 20 lines of similar code. Should you immediately extract a common abstract class?
Answer:
Not necessarily.
First determine whether the code represents the same business concept.
Similar code can evolve differently.
For example, customer validation and merchant validation may currently look similar but have different business ownership and future rules.
Prematurely placing both in one base class can create coupling.
It may be safer to tolerate small duplication until the stable shared concept becomes clear.
Code-Review Question
Question: What would you review in this code?
public interface DiscountStrategy {
BigDecimal calculate(Order order);
}
@Component
public class DefaultDiscountStrategy
implements DiscountStrategy {
@Override
public BigDecimal calculate(Order order) {
return BigDecimal.ZERO;
}
}There are no other discount strategies.
Answer:
I would ask what purpose the interface currently serves.
If the application genuinely expects multiple discount policies, isolates a module boundary, or requires pluggable behavior, the interface may be justified.
If it exists only for hypothetical future flexibility, a concrete DiscountService may be simpler.
The decision should be based on actual project requirements rather than the rule that every service requires an interface.
Real-Project Question
Question: Give an example where premature abstraction caused problems in a real project.
Answer:
A common example is building a generic integration framework for one external API.
The initial implementation introduces:
- Generic client interface
- Request adapter
- Response adapter
- Factory
- Registry
- Resolver
- Abstract base client
Later, a second external provider arrives and has completely different:
- Authentication
- Request flow
- Error handling
- Pagination
- Retry semantics
The original abstraction does not match the actual variation.
The team must either force the new provider into an incorrect model or rewrite the framework.
Starting with a clear provider-specific adapter and extracting shared contracts after observing real variation would have reduced rework.
29. Quick Rule to Remember
Do not abstract for a future you are guessing about; abstract when real variation shows you what the common contract actually is.
30. Final Takeaway
Avoiding premature abstraction means choosing the simplest design that satisfies current requirements while keeping the code refactorable.
A developer should remember:
- Abstraction has a maintenance cost.
- Interfaces are not automatically better than concrete classes.
- A factory with one implementation usually deserves questioning.
- An abstract class with one subclass may be unnecessary.
- Generic frameworks should be created only for genuine reusable requirements.
- Small duplication can sometimes be safer than the wrong abstraction.
- External boundaries may justify abstraction even before multiple implementations exist.
- Real variation should guide design.
During Pull Request review, check:
- What current problem does this abstraction solve?
- How many implementations exist?
- Is the variation real or hypothetical?
- Does the abstraction represent a stable business capability?
- Does it improve testability or boundary isolation?
- Is the business rule now harder to understand?
- Has compile-time simplicity been replaced by runtime lookup?
- Are unsafe casts or string identifiers appearing because of over-generalization?
- Are developers maintaining infrastructure that has only one consumer?
- Would a simpler concrete design remain easy to refactor later?
Avoid production code that introduces:
- Interfaces by convention only
- Abstract classes without meaningful inheritance
- Factories with one output
- Registries with one entry
- Generic frameworks with one use case
- Strategy patterns for trivial conditions
- Extension hooks without consumers
- Deep hierarchies built for hypothetical future requirements
Good Java design is not about maximizing the number of abstractions.
It is about placing the right abstraction at the right boundary at the right time.
A simple concrete implementation that clearly solves today's business problem is often better than a flexible architecture designed around assumptions about tomorrow.