Draft / Scheduled Content
This article is a draft or scheduled for future publication. The content is subject to change.
Why Clean Architecture is a Maintainability Nightmare
The Dream of Framework Independence
The diagrams are beautiful. Concentric circles of blue, green, red, and yellow, with arrows pointing strictly inward.
This is Clean Architecture (or Hexagonal, or Onion Architecture). The core idea is elegant: your business rules (Entities and Use Cases) should sit at the very center of your system, completely isolated from external concerns like databases, web frameworks, UI components, and third-party APIs.
The database is just a detail. The web framework is just a detail.
If you want to switch your database from PostgreSQL to MongoDB, or swap your web framework from Express to Fastify, you should be able to do it by writing a new adapter, without touching a single line of your core business logic.
It is a beautiful dream.
It is also an expensive, over-engineered fantasy that makes codebases incredibly painful to read, navigate, and modify.
For 99% of projects, Clean Architecture is not a solution; it is a maintainability nightmare.
The Boilerplate Explosion
To achieve this absolute separation of concerns, Clean Architecture requires you to write a massive amount of boilerplate code.
Let’s trace a simple operation: creating a user. In a typical Clean Architecture codebase, you need:
- User Entity: The core business object.
- CreateUser Use Case (Interactor): The business logic handler.
- UserRepository Interface: Define how the use case interacts with storage.
- UserRepository Adapter (Postgres): The actual database queries.
- UserController: Receives the HTTP request and calls the Use Case.
- UserPresenter: Formats the use case output for the web response.
- UserDTO (Data Transfer Object): To pass data between the controller and use case.
- UserDatabaseModel: The representation of the user in the database.
And because you are not allowed to pass a database model to the use case, or a use case entity to the view, you must write mappers to convert between these objects at every boundary:
HttpRequest -> UserController -> [Map request to InputDTO] -> CreateUserUseCase
CreateUserUseCase -> [Map InputDTO to Entity] -> PostgresAdapter -> [Map Entity to DBModel] -> DB
DB -> PostgresAdapter -> [Map DBModel to Entity] -> CreateUserUseCase
CreateUserUseCase -> [Map Entity to OutputDTO] -> UserController -> [Map OutputDTO to Response] -> HttpResult
You are writing four different classes representing a “User” and four different mapping functions to move data three inches to the left.
If you want to add a single field (like phoneNumber) to the user, you have to modify ten different files. You have to update the controller, the DTO, the use case, the entity, the database model, the migration, the presenter, and all the mappers.
This is not “easy to change.” This is a maintenance tax.
The Pass-Through Abuse
In any application, a large percentage of your features are simple. They are just CRUD (Create, Read, Update, Delete).
When you apply Clean Architecture dogmatically to CRUD operations, you end up with classes that do nothing but pass data straight through to the next layer.
You write a GetProductByIdUseCase that has exactly one line of code:
class GetProductByIdUseCase {
constructor(private productRepository: ProductRepository) {}
execute(id: string) {
return this.productRepository.getById(id);
}
}
This class has no business logic. It has no validation. It is a glorified forwarder.
Yet, it requires a separate file, a separate test, a separate interface, and mock dependencies. The actual work is done by the database adapter. By forcing every simple read through three layers of abstraction, you hide the simple nature of the code and increase the cognitive load for anyone trying to understand the system.
The Mythical Database Swap
Let’s address the elephant in the room: How often do you actually swap your database?
The entire design of Clean Architecture is optimized to make the database pluggable. You write interfaces so your use cases don’t know you are using PostgreSQL.
But in the real world, you almost never swap your database. You choose PostgreSQL, and you stay on PostgreSQL for the lifetime of the project. If you do migrate (e.g., to DynamoDB or MongoDB), the differences in data modeling, consistency guarantees, transaction boundaries, and querying models are so fundamental that your “business logic” will have to change anyway.
By optimizing for a database swap that will never happen, you pay a daily tax on every feature you write.
You are buying insurance for an asteroid impact, while ignoring the rain leaking through your roof.
graph TD
A[Dogmatic Clean Architecture] --> B[4+ Classes representing 'User']
A --> C[Infinite Mapping Functions]
A --> D[Pass-Through Use Cases]
B --> E[High Boilerplate Tax]
C --> E
D --> E
E --> F[Adding 1 field takes hours & touches 10 files]
F --> G[Developer Burnout & Slow Feature Delivery]
A Pragmatic Alternative: Vertical Slices
Instead of dividing your application by technical layer (controllers, use cases, repositories), divide it by feature. This is known as Vertical Slice Architecture.
In a vertical slice architecture, each feature is treated as an independent slice. The code that handles the HTTP request, the business logic, and the database queries for a specific feature lives together in a single file or directory.
For example, the CreateUser feature might look like this:
// features/create-user.ts
import { db } from '../db';
import { z } from 'zod';
export const RequestSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function handleCreateUser(req: Request, res: Response) {
const data = RequestSchema.parse(req.body);
// Business logic: check if email exists
const existing = await db.user.findFirst({ where: { email: data.email } });
if (existing) {
return res.status(400).json({ error: 'Email already in use' });
}
// Database query directly in the feature handler
const user = await db.user.create({ data });
return res.status(201).json({ id: user.id, email: user.email });
}
This file is 22 lines long. It is self-contained. It contains the validation, the business rules, and the database persistence.
If you need to change how users are created, you look at this file and only this file. You don’t have to navigate through repositories, use cases, and entities.
If this specific feature grows complex, you can extract the business logic into a helper function. But you do it on demand, not as an architectural mandate.
YAGNI: You Aren’t Gonna Need It
Clean Architecture is designed for large enterprises with hundreds of developers working on core domains that remain stable for decades.
If you are a startup, a small team, or building a product that is changing rapidly, Clean Architecture will slow you to a crawl. It forces you to build abstractions before you have the context to know if they are correct.
Keep your code simple. Keep your boundaries flexible. Write code that is easy to delete, not code that is easy to swap.
Related Content
Hexagonal Architecture: Why Your Domain Logic Shouldn't Know About Your Database
Stop letting frameworks dictate your architecture. Learn how Hexagonal Architecture (Ports & Adapters) isolates business logic from infrastructure, makes testing trivial, and lets you swap databases without rewriting code.
Software Architecture Is Mostly About Boundaries
A practical guide to drawing boundaries that survive contact with reality: APIs, modules, ownership, and the uncomfortable fact that most bugs are boundary bugs wearing a fake mustache.
Clean Code: The Cult of Dogma and Why Your Abstractions Are Probably Wrong
Robert C. Martin's Clean Code shaped a generation of developers, but its dogmatic rules about tiny functions, obsessive DRY, and terrible example code have caused more harm than good. Here's what the book got right, what it got catastrophically wrong, and what to read instead.