When Abstraction Helps - and When It Just Adds Noise
By Manaswini De • Software Engineer & Instructor at CodeKerdos
“The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise.”
- Edsger Dijkstra
The Reflex We Need to Question
There is a deeply ingrained reflex in enterprise software development, particularly in the Java world: the moment you write a service class, you also write an interface for it. It feels almost automatic. UserService gets UserServiceImpl. OrderService gets OrderServiceImpl. Repeat for every domain, every feature, every team.
It feels like good practice. It looks like solid architecture. And if you picked it up early in your career, it probably feels like the professional way to write code.
But here is the question most teams never stop to ask: if there is only one implementation today, and there is realistically no plan for a second, what problem is the interface actually solving?
This isn’t an argument against interfaces. Interfaces are one of the most powerful tools in object-oriented design. This is an argument for using them intentionally – understanding their real cost and their real benefit – rather than applying them by default because a rule said so.
The Interface Inflation Problem
The pattern starts innocently. One interface-implementation pair seems harmless. But multiply it by every service in a medium-sized project, and you end up with hundreds of files – half of which exist only to declare method signatures that are immediately duplicated in the class below.
This solves three problems that make raw LLMs impractical for most real-world business applications:
The diagram above illustrates the problem directly. Four services, four classes – clean, direct, understandable. Apply the ‘every service needs an interface’ rule, and you immediately double the file count with zero change in behaviour. The codebase grows larger. The mental model grows more complex. The actual product is unchanged.
📐 Codebase size is not a proxy for quality. A smaller, focused file count that accurately reflects your domain is almost always preferable to one inflated by structural ceremony.
The Hidden Tax: Developer Navigation
When you add an interface that has only one implementation, you are not just adding a file – you are adding an extra stop on every code navigation journey. Every time a developer wants to understand what UserService.getUser() actually does, their IDE takes them to the interface declaration first. Then they must navigate again to the implementation.
This extra hop sounds trivial in isolation. Multiplied by dozens of services, by dozens of developers, by hundreds of code-reading sessions per week – it becomes a meaningful drag on productivity and comprehension. New developers especially pay this cost heavily during onboarding.
The extra layer also obscures intent. When you read a concrete class, you see exactly what it does. When you read an interface first, you see what it promises – and must then find the implementation to understand how that promise is fulfilled. For a single implementation that will never change, this is pure overhead.
Addressing the Testing Argument
The most common justification for blanket interface creation is testability. The argument goes: we need the interface so we can mock the service in unit tests.
This was a compelling argument in the early 2000s. It is less compelling today.
// ✓ Modern mocking - no interface required
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
UserService userService; // concrete class, no interface
@InjectMocks
OrderService orderService;
@Test
void shouldCalculateTotal() {
when(userService.getUser(1L)).thenReturn(testUser);
// Mockito mocks the concrete class directly. No interface needed.
}
}
Modern mocking frameworks – Mockito (Java), pytest-mock (Python), Jest (JavaScript) – can all mock concrete classes directly. The testing argument for mandatory interfaces has largely dissolved. There are still specific cases where interfaces make mocking cleaner, particularly with final classes or framework proxies, but these are situations to evaluate case by case, not a blanket reason to create interfaces everywhere.
🧪 Testing alone is rarely a sufficient reason to create an interface. If your concrete class is hard to test in isolation, the root cause is almost always tight coupling – not the absence of an interface.
Fix the coupling. Don’t paper over it with an interface.
When Interfaces Genuinely Earn Their Place
None of this means interfaces are wrong. They are exactly right in the situations they were designed for. Here are the three scenarios where an interface isn’t just useful – it’s the correct design choice.
Scenario 1 - Genuine Polymorphism
When multiple implementations of the same contract exist – or are clearly imminent – an interface is the natural expression of that relationship. A payment system that supports Stripe, Razorpay, and PayPal doesn’t just benefit from an interface. It requires one. The rest of the system should be entirely indifferent to which payment provider is in use at runtime.
// The interface expresses the business contract
public interface PaymentGateway {
PaymentResult charge(Money amount, PaymentMethod method);
RefundResult refund(String transactionId);
}
// Each implementation is independently testable and swappable
public class StripeGateway implements PaymentGateway { ... }
public class RazorpayGateway implements PaymentGateway { ... }
public class PayPalGateway implements PaymentGateway { ... }
Scenario 2 - Framework Requirements
Some frameworks genuinely need interfaces to function correctly. Spring’s AOP proxying, for example, creates dynamic proxies around interfaces to intercept method calls for cross-cutting concerns like transaction management, caching, and security. If your service is @Transactional, Spring may require an interface to generate the proxy correctly – depending on your proxy configuration. Here, the interface is a technical requirement, not a stylistic preference.
Scenario 3 - Public APIs and Extension Points
If you are building a library, SDK, or any code that external consumers will depend on, interfaces become essential. They are your public contract – the stable surface you expose while retaining the freedom to change your internal implementation. Any class that external code is expected to extend, implement, or substitute should be expressed as an interface.
| ✓ Justified: SDK Extension Point | X Unnecessary: Internal-Only Service |
|---|---|
// You ship this interface
public interface StorageProvider {
void save(String key, byte[] data);
byte[] load(String key);
}
// Consumers provide their own:
// S3StorageProvider
// AzureStorageProvider
// LocalFileStorageProvider
|
// Single implementation, never exposed
public interface UserNotificationService {
void notify(Long userId, String msg);
}
// Only one impl exists:
public class UserNotificationServiceImpl
implements UserNotificationService {
// Will never be swapped.
// Interface adds nothing.
}
|
The Onboarding Argument Nobody Talks About
There is a cost to interface inflation that rarely appears in technical discussions: the experience of a new developer joining the codebase.
Imagine joining a project and trying to trace how a user’s profile is updated when they change their email address. You find the controller. It calls UserService – but that is an interface. You navigate to find UserServiceImpl. It calls a validator – another interface, another navigation. Then a repository – also an interface. By the time you have traced one simple operation, you have visited six files and navigated three layers of indirection, all of which lead to exactly one concrete path.
Now imagine the same codebase where interfaces exist only where they are genuinely needed. You open the controller. It calls UserService – a concrete class. You follow the method. You understand the feature. You’re done in two files.
🧭 Architecture should reduce the time it takes for a developer to understand the system.
Every interface that introduces indirection without introducing flexibility makes the codebase harder to read, not easier to change.
A Better Mental Model: Interfaces Are Commitments
One of the most useful reframings is to think of an interface not as a structural convenience, but as a commitment – a promise to consumers that this contract will remain stable regardless of what changes underneath.
That commitment has weight. When you define an interface, you are telling every caller: you may depend on this surface. Changing it later is expensive – it requires updating every implementation and every mock in your test suite. An interface that was created ‘just in case’ still carries all of this maintenance cost even if the flexibility it was supposed to enable never materialises.
This is why experienced engineers often say: let the interface emerge from the need, not the other way around. Write the concrete class first. When you genuinely find yourself needing to substitute a second implementation, introduce the interface at that point. Modern IDE refactoring tools make this extraction trivially easy – often a single keypress.
“Good engineers know design patterns. Great engineers know when not to use them.”
The Cost-Benefit Reality
Let’s make the trade-offs concrete. The table below summarises what you actually gain and lose in each scenario.
The takeaway is clear: when an interface is justified – multiple implementations, framework requirements, or a public API contract – it pays back its cost many times over. When it is not justified, it is a pure tax: more files, more cognitive load, slower navigation, and harder onboarding, for zero additional flexibility.
The Decision Framework
Before creating any interface, run through this flowchart. It takes thirty seconds and prevents months of unnecessary complexity.
The key insight the flowchart encodes: interfaces should be pulled by genuine need, not pushed by convention. If you reach the end of the flowchart without a yes, write the concrete class and revisit when requirements actually change.
Before You Create That Interface - A Quick Checklist
Run through these questions before writing any new interface:
- 1. Can I name two or more concrete implementations that exist today (not 'might exist someday')?
- 2. Does my framework specifically require an interface for a feature I am using (proxying, AOP, dynamic binding)?
- 3. Will external consumers of my library need to substitute their own implementation?
- 4. Is the cost of extracting an interface later genuinely high for this particular class?
If you answered yes to at least one: the interface is warranted. If the answer to every question is ‘no’ or ‘maybe someday’: write the class directly. The interface can wait.
'But Refactoring Later Is Expensive' - Is It?
A common objection to the ‘writes the class first’ approach is the fear that introducing an interface later will be expensive. In practice, for a concrete class that is already dependency-injected and unit tested, the extraction is almost always straightforward.
// Step 1: You have this working class
public class UserNotificationService {
public void notify(Long userId, String message) { ... }
}
// Step 2: A second implementation becomes necessary (SMS vs Email)
// Modern IDE: Right-click → Refactor → Extract Interface
// Time cost: under 2 minutes.
public interface NotificationService {
void notify(Long userId, String message);
}
public class EmailNotificationService implements NotificationService { ... }
public class SmsNotificationService implements NotificationService { ... }
The extraction is fast because the class was already written with clear separation of concerns, which is what you should focus on first. Clean method boundaries, cohesive responsibilities, injected dependencies: these are the properties that make a class easy to abstract when the time comes. The interface itself is almost an afterthought.
Conclusion: Maximise Clarity, Not Abstraction
Interfaces are not good or bad. They are contracts, and contracts are valuable precisely because they are not issued carelessly. A contract that exists for every interaction, regardless of whether any party benefits from it, is bureaucracy, not architecture.
The goal of software design is not to maximise abstraction. It is to maximise clarity – the ability of any developer, at any experience level, to read your code and understand what it does, why it does it, and how to change it safely.
The next time you’re about to create UserService and UserServiceImpl in the same breath, pause. Ask one simple question: what problem does this interface actually solve? If the answer is convincing, create it. If the answer is ‘because that’s how we do things,’ the interface can wait.
Write the class. Add the abstraction when the problem demands it. 🚀