Clean Architecture is a great concept. It’s meant to help you write maintainable, modular, and scalable software.
But too many developers treat it like a religion. They follow it blindly, stuffing projects with unnecessary layers, abstractions, and patterns. All in the name of “clean code.”
Let’s be honest, Clean Architecture is often overused, not because the ideas are bad, but because people overengineer the implementation.
A Quick Joke (That Some Devs Write Unironically)
Here’s an example of multiplying two numbers implemented with an absurd number of patterns:
// overengineered-multiplier.ts
// Interface
interface IMultiplier {
multiply(a: number, b: number): number;
}
// Singleton
class MultiplierService implements IMultiplier {
private static instance: MultiplierService;
private constructor() {}
static getInstance(): MultiplierService {
if (!MultiplierService.instance) {
MultiplierService.instance = new MultiplierService();
}
return MultiplierService.instance;
}
multiply(a: number, b: number): number {
return a * b;
}
}
// Adapter
interface IMultiplierInput {
x: number;
y: number;
}
class InputAdapter {
constructor(private readonly rawInput: [number, number]) {}
adapt(): IMultiplierInput {
return { x: this.rawInput[0], y: this.rawInput[1] };
}
}
// Decorator
class LoggingMultiplierDecorator implements IMultiplier {
constructor(private readonly inner: IMultiplier) {}
multiply(a: number, b: number): number {
console.log(`Logging: Multiplying ${a} * ${b}`);
const result = this.inner.multiply(a, b);
console.log(`Logging: Result is ${result}`);
return result;
}
}
// Proxy
class MultiplierProxy implements IMultiplier {
constructor(private readonly target: IMultiplier) {}
multiply(a: number, b: number): number {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Invalid input types');
}
return this.target.multiply(a, b);
}
}
// Abstract Factory
interface IMultiplierFactory {
create(): IMultiplier;
}
class RealMultiplierFactory implements IMultiplierFactory {
create(): IMultiplier {
const singleton = MultiplierService.getInstance();
const withLogging = new LoggingMultiplierDecorator(singleton);
const withProxy = new MultiplierProxy(withLogging);
return withProxy;
}
}
// Usage
const rawInput: [number, number] = [6, 9];
const adapted = new InputAdapter(rawInput).adapt();
const factory = new RealMultiplierFactory();
const multiplier = factory.create();
const result = multiplier.multiply(adapted.x, adapted.y);
console.log(`Final Result: ${result}`);
All of this just to calculate 6 * 9
. This is what happens when design patterns become cosplay.
The Real Problem: Over-Abstraction
Clean Architecture promotes decoupling. That’s good. But too many devs interpret that as "abstract everything."
Example:
interface IUserRepository {
findById(id: string): Promise<UserDTO>;
}
class UserRepositoryImpl implements IUserRepository {
constructor(private db: PrismaClient) {}
async findById(id: string): Promise<UserDTO> {
return await this.db.user.findUnique({ where: { id } });
}
}
Then you add a use case on top:
class GetUserByIdUseCase {
constructor(private repo: IUserRepository) {}
async execute(id: string): Promise<UserDTO> {
return this.repo.findById(id);
}
}
All of this for one database call. This isn’t clean it’s waste.
Common Overengineering Patterns
1. Interfaces and Implementations for Everything
Blindly creating an interface and a class for every service adds friction without real value.
Use it when:
- You actually have multiple implementations
- You’re building a plugin system or SDK
Don’t use it when:
- You only have one implementation
- You’re doing it "just in case"
2. Use Case Classes for Simple Logic
class CreatePostUseCase {
execute(input: CreatePostDTO): Promise<PostDTO> {
return this.postRepo.create(input);
}
}
This is a one-line call. Wrapping it in a class adds nothing. Use a service or method directly unless business logic really requires separation.
3. DTO Explosion
type Post = { id: string; title: string; content: string };
type CreatePostDTO = { title: string; content: string };
type PostResponseDTO = { id: string; title: string; content: string };
If the structure is the same across layers, reuse the object. You don’t need a new DTO for every transition unless there’s a reason.
4. Domain Models with No Behavior
class User {
constructor(public id: string, public name: string) {}
}
If your "domain entity" is just a data wrapper, it’s not a domain model. Add behavior or don’t abstract.
5. Dependency Injection Overkill
@Module({
providers: [
{ provide: IUserService, useClass: UserServiceImpl },
{ provide: IUserRepository, useClass: UserRepositoryImpl }
]
})
DI containers are great. But if you’re not benefiting from polymorphism or dynamic injection, just instantiate the class.
What You Should Do Instead
- Start simple. Build complexity only when needed.
- Abstract with intent. Not everything needs to be swappable.
- Refactor later. Let the structure grow organically as the app gets more complex.
- Optimize for clarity. Not for pleasing architecture diagrams.
Clean Code Is Not More Code
Good code is:
- Easy to read
- Easy to test
- Easy to change
It’s not defined by how many layers, decorators, or design patterns it uses.
Final Thoughts
Clean Architecture should serve your code, not the other way around.
It’s a tool, not a rulebook. Use it to solve complexity not to create it.
TLDR
Avoid This | Do This Instead |
---|---|
Interface and Impl for everything | Use one class until you actually need two |
Use cases for CRUD logic | Use simple services or methods |
Separate DTOs for same shapes | Reuse types where possible |
DI for everything | Instantiate directly when practical |
Layers for layers’ sake | Let structure grow with real needs |
Build software, not monuments.
Of course, if your goal is job security, then by all means abstract everything. Write five layers per feature, name nothing clearly, and inject interfaces into factories into services into use cases. Once the codebase hits 100,000 lines, no one will know what’s going on but you. Congratulations, you’re now unfireable LOL.
Top comments (1)
I think this is the most important sentence of the article.
The application only needs to be as complex as the current requirements.
Those requirements can warrant design patterns, and by thinking about how to structure the code it will be written more purposeful.
There is one thing where I differ from the post, and that is the use of dependency injection. By having a list of dependencies in the constructor or as method arguments it gives a indication of the tasks that are handled. It makes it easier to spot when code does too much, even when you haven't written the actual code.