Avoiding Premature Abstraction

26 min read

Object-Oriented Design and SOLID Review — review Java code for interfaces, factories, and layers added before a real requirement exists and remove unnecessary indirection.

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:

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

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

JAVA
public interface MessageChannel {
    void send(Message message);
}

Then adds:

JAVA
EmailMessageChannel

followed by:

JAVA
MessageChannelFactory
MessageChannelRegistry
MessageChannelResolver
AbstractMessageChannel
MessageChannelConfiguration

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

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

JAVA
paymentService.refund(payment);

a developer may need to trace:

JAVA
RefundProcessor
    -> RefundProcessorFactory
    -> RefundProcessorRegistry
    -> DefaultRefundProcessor
    -> AbstractRefundProcessor
    -> RefundExecutionTemplate

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

JAVA
CustomerCsvExporter

and later receives a requirement for:

JAVA
OrderCsvExporter

A developer may immediately create:

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

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

JAVA
DeliveryFeeStrategy

does not yet protect a meaningful variation point.

Artificial Abstract Base Class

JAVA
AbstractDeliveryFeeStrategy

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

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

JAVA
Unknown strategy

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

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

JAVA
@ConfigurationProperties(
        prefix = "delivery")
public record DeliveryFeeProperties(
        BigDecimal freeDeliveryThreshold,
        BigDecimal standardFee) {
}

Then:

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

JAVA
DeliveryFeeService

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

JAVA
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

AreaPremature AbstractionSimple Current Design
Number of conceptsInterface, base class, strategy, registry, serviceService
ReadabilityBusiness rule spread across filesRule easy to locate
MaintainabilityMore contracts to maintainSmall change surface
TestabilityMultiple units and wiring to testDirect business-rule tests
Runtime failure pathsStrategy lookup can failNo unnecessary lookup
FlexibilitySupports hypothetical variationsSupports current requirement
DebuggingMore indirectionDirect call path
Refactoring laterAlready generalized without evidenceCan generalize when variation appears
PerformanceSmall unnecessary lookup overheadDirect execution
ReliabilityAdditional wiring/configurationFewer 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:

JAVA
StatementGenerator<T>
AbstractStatementGenerator<T>
StatementGenerationStrategy<T>
StatementGeneratorFactory
StatementGeneratorResolver
StatementFormatRegistry
StatementOutputAdapter<T>

Only one implementation exists:

JAVA
PdfStatementGenerator

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

JAVA
UserService
UserServiceImpl

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

JAVA
AbstractPaymentValidator
    -> PaymentValidator

If no reusable template behavior or inheritance relationship exists, the base class may be unnecessary.

Factory for One Implementation

Example:

JAVA
CustomerMapperFactory.getMapper()

when there is only one mapper.

Strategy Pattern for One Condition

A developer sees:

JAVA
if (customer.isPremium())

and immediately introduces multiple strategy classes.

A simple conditional may be clearer.

Generic Type Too Early

Example:

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

JAVA
CustomJdbcTemplate
CustomRestTemplate
CustomLogger
CustomObjectMapper

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

JAVA
CreditScoreProvider

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

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

JAVA
ProcessingContext
ProcessingRequest
ProcessingCommand
ProcessingResult
ProcessingMetadata

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

JAVA
AuthorizationStrategy

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

JAVA
Map<String, Object> context

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

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

JAVA
1500 -> 0

Boundary Test

Order exactly at threshold:

JAVA
1000 -> 0

Negative Boundary Test

Order below threshold:

JAVA
999.99 -> 80

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

JAVA
DeliveryFeeStrategy

may have only:

JAVA
StandardDeliveryFeeStrategy

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

JAVA
StandardDeliveryFeeStrategy.calculate()

can move into:

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

JAVA
DeliveryFeeStrategy
AbstractDeliveryFeeStrategy
DeliveryFeeStrategyRegistry

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

JAVA
@ConfigurationProperties

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

JAVA
PaymentGateway

when payment processing is a stable business capability and provider details should remain isolated.

Weak abstraction:

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

JAVA
return new DefaultProcessor();

A normal constructor dependency is probably simpler.

String-Based Registries Without Dynamic Requirements

Example:

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

JAVA
BaseProcessor
    -> AbstractValidatedProcessor
        -> AbstractDatabaseProcessor
            -> CustomerProcessor

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

JAVA
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

  1. 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?
  1. This factory always returns DefaultPaymentProcessor. Could we inject that dependency directly until multiple processor implementations actually exist?
  1. The new registry introduces string-based runtime lookup, but the caller always requests "STANDARD". A direct dependency would be simpler and safer.
  1. 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?
  1. 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?
  1. 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?
  1. 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.
  1. 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?
  1. 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?
  1. 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.

JAVA
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

JAVA
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

JAVA
AbstractNotificationChannel

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

JAVA
EMAIL

The map-based registry provides no useful selection.

Issue 4: String-Based Lookup

JAVA
registry.get("EMAIL");

moves a compile-time dependency into runtime configuration.

A typo could return null.

Issue 5: Unsafe Cast

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

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

JAVA
AccountLockNotificationService -> EmailClient

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

  • Email
  • 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:

JAVA
AccountLockNotifier

with implementations such as:

JAVA
EmailAccountLockNotifier
SmsAccountLockNotifier

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

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