Software engineering culture values abstraction. Interfaces, layers, generalization, frameworks — these are treated as inherently good. The more abstract your code, the more flexible and reusable it is.
This is partially true and partially a way to build systems that are harder to understand than they need to be.
Abstraction has real benefits. It also has real costs. The question is whether the benefits outweigh the costs in a specific context — not whether abstraction is good in the abstract.
What Abstraction Costs
Indirection: every layer of abstraction is a level of indirection. To understand what code does, you need to follow the indirection chain. An interface with five implementations requires understanding all five implementations to have a complete mental model. A direct call to a concrete class requires understanding one.
Mental load on readers: abstractions require readers to hold the abstract concept in mind and understand how it maps to the concrete reality. This is cognitive work. When the abstraction is well-chosen, it reduces total cognitive work. When it’s not, it increases it.
Debugging difficulty: when something goes wrong, you debug through abstraction layers. “Something failed in the payment processing” → which PaymentProcessor implementation? → what configuration was active? → which environment are we in? A concrete call to StripePaymentProcessor tells you exactly what you’re looking at.
Maintenance overhead: abstractions need to remain valid as the system evolves. An interface that was designed to support three implementations but only one is ever built still needs to be maintained. If the system changes in a way that the abstraction doesn’t accommodate, you pay refactoring costs to change both the abstraction and its implementations.
The Premature Abstraction Problem
The most common form: building a generalized solution for a problem that doesn’t require generality.
// You have one payment provider. You build this:
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
}
@Service("stripe")
public class StripePaymentProcessor implements PaymentProcessor { ... }
@Service("paypal")
public class PayPalPaymentProcessor implements PaymentProcessor { ... }
@Qualifier("stripe") // Or from config — who knows?
private final PaymentProcessor paymentProcessor;
If you actually have two payment providers and need to switch between them, this abstraction pays for itself. If you have one payment provider and “might add a second someday,” this abstraction adds complexity for a benefit that may never materialize.
The direct version:
// One provider, direct dependency
private final StripePaymentProcessor stripePayments;
When you add a second provider, you introduce the interface. The refactoring is mechanical. You haven’t deferred a hard problem — you’ve deferred a simple refactoring.
The rule of thumb attributed to Martin Fowler: don’t create an abstraction until you have three concrete examples that share the pattern. Two is suggestive; three demonstrates it.
Interface Everywhere Syndrome
A symptom of over-abstraction: every class has a corresponding interface, every service has an XxxService interface implemented by XxxServiceImpl.
public interface UserService {
User createUser(CreateUserRequest request);
Optional<User> findById(UserId id);
}
@Service
public class UserServiceImpl implements UserService {
// The only implementation that ever existed or will exist
}
The interface adds no value here. It’s just noise. If you need a test double, Mockito can mock a concrete class. If you need to swap implementations, you add the interface when you have the second implementation.
The test doubles argument is often used to justify this pattern. But test doubles work with concrete classes:
// Mockito can mock a class directly
@MockBean
UserService userService; // Works whether UserService is class or interface
Create interfaces when:
- You have multiple implementations
- You need to define a contract that multiple implementations will fulfill
- You’re defining a boundary that will be crossed (API, plugin system, external adapter)
- You need to break a compile-time dependency (hexagonal architecture)
Don’t create interfaces because “it might be useful someday” or “interfaces are good practice.”
Leaky Abstractions
A leaky abstraction is one that exposes the underlying detail it was supposed to hide. Joel Spolsky’s Law of Leaky Abstractions: all non-trivial abstractions leak.
The practical consequence: when the abstraction fails, you need to understand what’s underneath it anyway. The abstraction gave you the illusion of simplicity without the reality.
Common examples:
ORM as SQL abstraction: Hibernate/JPA abstracts SQL. Until you have an N+1 query problem, a poorly-performing HQL query, or a locking issue. Then you need to understand the SQL that Hibernate is generating, the transaction isolation levels, and how the second-level cache is behaving. The abstraction leaked.
Cloud abstraction: “provider-agnostic” cloud architecture that hides whether you’re on AWS or GCP. Until you need a DynamoDB stream, a Lambda layer, or a specific VPC configuration. The abstraction didn’t remove the complexity — it deferred it.
The issue isn’t that these abstractions are bad. They’re very useful. The issue is treating them as if they eliminated the need to understand the layer below. You still need to know enough SQL to understand what your ORM is doing. You still need to understand AWS to build reliable AWS infrastructure.
Leaky abstractions require that you know both the abstraction and what it abstracts. The best response is to learn both, not to pretend the lower layer doesn’t exist.
Dependency Injection Abuse
Dependency injection frameworks like Spring make it trivially easy to create and wire beans. The result is sometimes applications where dependencies between components are impossible to trace without running the application.
@Service
public class ComplexService {
@Autowired private ServiceA serviceA;
@Autowired private ServiceB serviceB;
@Autowired private ServiceC serviceC;
@Autowired private ServiceD serviceD;
// 12 more dependencies
// 500 lines of mixed concerns
}
Dependency injection is the mechanism for wiring. The problem here is not DI — it’s that ComplexService has 16 dependencies, which is a strong signal that it’s doing too much.
The fix is not removing the DI abstraction. It’s designing the components correctly so each has a small number of focused dependencies.
A related pattern: ApplicationContext.getBean() calls scattered through business logic. This bypasses DI entirely and creates implicit dependencies:
// DON'T: bypasses DI, creates implicit runtime dependency
@Service
public class OrderService {
@Autowired ApplicationContext ctx;
public void processOrder(Order order) {
// Which processor? Depends on runtime config. Unclear.
PaymentProcessor processor = ctx.getBean(order.paymentMethod().name() + "Processor");
}
}
The strategy pattern with an explicit map is clearer:
@Service
public class OrderService {
private final Map<PaymentMethod, PaymentProcessor> processors;
public OrderService(List<PaymentProcessor> processorList) {
this.processors = processorList.stream()
.collect(toMap(PaymentProcessor::supports, identity()));
}
public void processOrder(Order order) {
PaymentProcessor processor = processors.get(order.paymentMethod());
// Explicit, traceable, testable
}
}
When Abstraction Is Worth It
This isn’t an argument against abstraction. It’s an argument for deliberate abstraction.
Good abstractions:
- Have demonstrable benefits (multiple implementations, testability, boundary crossing)
- Actually hide complexity rather than just adding a layer
- Remain stable as the underlying implementation changes
- Make code easier to understand for readers who don’t need to know the details
The questions to ask before adding an abstraction:
- What specific problem does this solve today?
- What would the code look like without it?
- Is the code with it easier to understand and change?
If the answers are “nothing specific today,” “simpler,” and “no” — skip the abstraction. You can add it when it’s warranted. Removing one is harder.