Defeating Interface Hallucination in Architecture

Interface hallucination is a structural anti-pattern where developers create abstractions for classes that possess only a single implementation. This practice is prevalent in ecosystems like Java and TypeScript, where dogmatic interpretations of Clean Architecture often override practical engineering needs. It creates a one-implementation interface that complicates the system without providing any real polymorphism or future-proof flexibility. By identifying these traps, developers can focus on writing code that delivers actual value rather than maintaining redundant structural monuments.


// The Hallucination: A phantom interface
interface IUserRepository {
findById(id: string): Promise<User>;
}

class UserRepository implements IUserRepository {
async findById(id: string) {
return db.users.find({ id });
}
}

Decoding the One Implementation Interface Anti-Pattern

The one implementation interface occurs when a developer creates a contract that only ever has a single concrete class. This form of overengineering results in a redundant abstraction that mirrors the implementation signature in two separate files, doubling the effort for every change. Instead of decoupling, it adds a layer of synchronization that provides no protection against logic changes. This preemptive strike against simplicity creates a rigid structure that is harder to refactor because every modification requires updating both the contract and the implementation simultaneously.

This approach introduces a heavy navigational tax. When using an IDE, clicking Go to Definition on a service method frequently lands on a hollow interface instead of the actual logic. This extra step breaks the developers focus and forces a manual hunt for the implementation, wasting mental energy. Statistically, the vast majority of these over-engineered abstractions are never swapped during the projects lifecycle, remaining as purely syntactic noise that disguises tight coupling as modularity.

The Cognitive Tax of Architectural Indirection

Excessive layering decreases developer productivity by increasing the cognitive load required to understand a single feature. When business logic scattered across controllers, services, and repositories with phantom interfaces, the execution flow becomes fragmented. Developers must keep a complex mental map of the folder structure just to fix minor bugs. This fragmentation often leads to architectural fatigue, where the team follows established boilerplate without questioning its utility. New developers spend more time learning the house style of custom abstractions than learning the actual domain logic.

Onboarding time increases significantly in over-engineered environments. A new engineer should not need a map to find where a simple database write happens. If your architecture forces them to navigate through five clean layers to find a single query, your abstractions are failing to provide real value. We must prioritize delete-ability over extensibility to maintain a healthy, fast-moving project that can pivot when required by business reality.


// Pragmatic alternative: Direct class usage
class UserRepository {
async findById(id: string) {
return db.users.find({ id });
}
}

Future Proofing as a Technical Debt Catalyst

The myth of swappable implementations is one of the most expensive forms of speculative abstraction. Developers spend hours building Storage Interfaces to support switching from S3 to Azure, a transition that rarely happens. This speculative generality results in a codebase that is generic, hollow, and difficult to optimize for specific platform features that could improve performance. When you abstract away the specific features of your tools, you end up with a lowest-common-denominator API.

You cannot use PostgreSQL-specific features because your Generic Repository doesnt support them. This self-imposed limitation makes the system less flexible, as you are now a prisoner of your own abstraction rather than a master of your chosen technical stack. Final engineering decisions should be based on measured pain, not theoretical perfection. If the lack of an interface is causing actual friction, add it; if the presence of one is causing cognitive load, remove it immediately.

The Testing Delusion and Mocking Fatigue

Many developers believe that interfaces are mandatory for testability, creating a testing anti-pattern known as the Testing Delusion. This myth suggests that you cannot mock dependencies unless they are hidden behind a contract. In reality, modern testing frameworks can easily mock concrete classes or use fakes, rendering the interface for testing argument obsolete. Relying on interfaces for every dependency leads to brittle test suites where files become bloated with setup code rather than functional verification logic.

You end up testing the interactions between components rather than the actual behavior of the system. This results in tests that break the moment you rename a method, even if the business logic remains correct, because the structural mock is no longer valid. Developers prioritize structural isolation over functional verification, leading to a false sense of security. It is often better to use social tests that exercise multiple concrete classes together, providing higher confidence and allowing you to delete useless interfaces without breaking the suite.


// Brittle test focused on structure
it('saves user', async () => {
const mockRepo = { save: jest.fn() };
const service = new UserService(mockRepo);
await service.create(data);
expect(mockRepo.save).toBeCalled();
});

Behavioral Verification vs Structural Mocking

A significant pitfall is focusing on how a method is called rather than what the result is. When you adopt behavior-driven testing, you reduce the need for phantom interfaces. Testing against a concrete service that uses an in-memory database verifies the full logic chain. This approach provides refactoring-safe tests that dont break when you change internal wiring. By moving toward behavioral verification, you verify the what rather than the how, ensuring tests stay relevant even if you move logic between private methods.

Testing against concrete classes verifies the external behavior of the system, not the internal wiring of its components. This shift drastically reduces the number of interfaces you need to maintain. Mocking interfaces also hides architectural rot; if a service has twenty dependencies, it is easy to hide that complexity by mocking twenty interfaces. If you were forced to use concrete implementations, the pain of setting up that test would scream that your service has too many responsibilities.

The Trap of Isolated Unit Testing Dogma

The dogma of pure unit testing forces developers into creating unnatural abstractions. We are told that unit means a single class, which leads to mocking everything else. This definition is too narrow; a unit should be a unit of behavior. A behavior might involve three classes working together to achieve a goal without needing interfaces between them. Micro-test obsession produces thousands of tests that pass even when the system is fundamentally broken because we lose the big picture of how data flows.

By rejecting the mandatory interface for every dependency, we can write broader tests that catch real bugs and allow us to refactor the internal class structure with total confidence. The cumulative effect of this dogma is velocity degradation over time. Developers stop refactoring because they dont want to fix hundreds of broken mocks. Breaking the cycle requires moving back to concrete dependencies and verifying state over interactions in your suite, prioritizing boring code that is easy to read.


// Robust: Testing behavior with concrete dependencies
it('processes order', async () => {
const processor = new OrderProcessor(new Inventory(), new Logger());
await processor.execute(orderId);
expect(inventory.getStock(item)).toBe(initial - 1);
});

Pragmatic Recovery and Functional Abstraction

Recovery starts by embracing the YAGNI principle: You Aint Gonna Need It. Senior developers recognize that code is a liability, not an asset. The most flexible architecture is the one that is easy to delete or rewrite, not the one that is buried under layers of generic wrappers. Start simple and add complexity only when the pain is real and measurable. You should stop using patterns when they no longer solve a tangible problem or when they increase the number of files without reducing logic complexity.

To fix an existing hallucination, perform in-place simplification by merging interfaces back into their implementations. This process reduces the yo-yo effect during navigation and simplifies dependency injection configuration. It restores the direct relationship between the caller and the logic, making the system transparent and much easier to debug during production incidents. Use concrete classes by default and only extract an interface when you have a second, real implementation requiring polymorphic behavior.


// Simplify by merging logic
class FileUploader {
async upload(file: Buffer) {
// Concrete logic is easy to trace
return s3.upload(file);
}
}

When Interfaces Are Justified

Interfaces are highly effective when you have multiple real implementations that exist simultaneously. A classic example is a payment gateway system where you must support Stripe, PayPal, and Adyen using the same logic. In this scenario, the interface serves as a true polymorphic boundary that allows the core system to remain agnostic of the specific provider details. This is where abstraction earns its keep by preventing breaking changes in distributed systems or shared libraries.

Another valid use case is defining external contracts for public APIs. When you cannot control the consumers of your code, an interface provides a stable promise. Interfaces are also useful at major plugin boundaries, such as database drivers or logging facilities, where the cost of indirection is justified by the need to isolate the entire application from low-level infrastructure. Writing interfaces for simple business services, however, is almost always a sign of overengineering.

Escaping Premature Abstraction

Avoid premature abstraction by following the rule of three: dont generalize until you have repeated the same logic in three different places. This prevents you from building leaky abstractions based on incomplete information. It allows the natural boundaries of the system to emerge through evolutionary design rather than forced structural dogmas. A healthy project is one where refactoring is cheap and frequent; too many abstractions make it expensive and rare.

By keeping the code concrete and flat, you make it easy to move logic around as the domain evolves. This refactoring agility is the true meaning of Clean, not the number of interfaces in your folder structure. The less code you have, the faster you can change it. Dont be afraid of boring code that uses direct calls; it is much easier to turn a concrete class into an interface later than it is to untangle a web of unnecessary hallucinations.

Choosing to avoid interface overengineering is a mark of architectural maturity. By opting for concrete classes and direct logic, you reduce accidental complexity and ensure that your codebase remains a tool for delivery. Senior engineering is the art of knowing when to stop abstracting and start solving. Focus on the actual requirements of today, keep your abstractions earned, and remember that the best architecture is the one that stays out of your way.

Written by: