Skip to main content

On This Page

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

6 min read
Share

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:

  1. User Entity: The core business object.
  2. CreateUser Use Case (Interactor): The business logic handler.
  3. UserRepository Interface: Define how the use case interacts with storage.
  4. UserRepository Adapter (Postgres): The actual database queries.
  5. UserController: Receives the HTTP request and calls the Use Case.
  6. UserPresenter: Formats the use case output for the web response.
  7. UserDTO (Data Transfer Object): To pass data between the controller and use case.
  8. 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