
Clean architecture, hexagonal architecture, ports and adapters, onion architecture -- whatever you call it, the core idea is the same: separate your business logic from your infrastructure so that you can change databases, frameworks, and external services without rewriting your domain code. In theory, it is beautiful. In practice, it means writing three files where one would suffice, creating interfaces that have exactly one implementation, and adding layers of abstraction that nobody on the team fully understands.
We know this because we have done it both ways, many times. We have built projects with strict clean architecture, projects with no architecture, and projects with everything in between. Here is our honest assessment.
Strict clean architecture is overkill for most projects. There. We said it. If you are building a CRUD application for a small business, you do not need a domain layer with entities and value objects, an application layer with use cases and DTOs, an infrastructure layer with repository implementations, and an interface layer with controllers and presenters. You need a route handler that validates input, calls the database, and returns a response. The extra abstraction layers add development time, increase the learning curve for new developers, and solve a problem (switching databases or frameworks) that you will almost certainly never have.
We tracked our projects over a ten-year period. Of forty-seven projects that reached production, exactly two changed their database engine after launch (both went from MySQL to PostgreSQL during a major rewrite), and zero changed their web framework. The core promise of clean architecture -- that it protects you from framework and database changes -- solved a problem that occurred in four percent of our projects.
So why do we still use it? Because the alternative is worse.
Projects with no architectural boundaries inevitably devolve into what we call "spaghetti monoliths" -- applications where business logic, database queries, validation, and HTTP handling are all tangled together in the same files. These projects are fast to build initially. They are nightmares to modify six months later. When a client says "we need to add multi-tenancy" or "we need to add an approval workflow," the developer opens the codebase and discovers that the logic for creating an order is spread across the API route handler, two middleware functions, a utility file, and a database trigger. Changing anything requires understanding everything.
Our pragmatic middle ground is a simplified three-layer architecture that captures eighty percent of clean architecture's benefits with twenty percent of its ceremony. The three layers: routes (HTTP handling, input validation, response formatting), services (business logic, orchestration, domain rules), and repositories (data access, database queries, external API calls).
That is it. Three layers. No entities, no value objects, no use case classes, no DTOs, no ports, no adapters. Just a clean separation between "how data gets in and out," "what the business rules are," and "where the data lives."
Here is what this looks like in practice with a TypeScript/Node.js stack. A route handler receives the HTTP request, validates the input using Zod, calls the service, and formats the response. The service contains the business logic: check if the user has permission, apply the business rules, call the repositories, return the result. The repository handles the database query and returns typed data.
The rules are simple. Routes can call services but never repositories directly. Services can call repositories and other services but never access HTTP request/response objects. Repositories never contain business logic and never access HTTP objects. These rules are enforced by code review, not by abstract interfaces.
The key difference from strict clean architecture: we do not create interfaces for our repositories. The UserRepository is a concrete class, not an implementation of an IUserRepository interface. If we someday need to swap PostgreSQL for MongoDB (we will not), we can add the interface then. Until that day, the interface is pure overhead: an extra file that mirrors the implementation exactly and adds a layer of indirection that makes "go to definition" in the IDE take an extra step.
We also skip the DTO (Data Transfer Object) pattern. In strict clean architecture, you are supposed to map between different representations at each layer boundary: HTTP request to input DTO, input DTO to domain entity, domain entity to output DTO, output DTO to HTTP response. In our approach, we define a single TypeScript type for each data shape and use it across layers. The route handler receives a Zod-validated input type, passes it to the service, and the service passes it to the repository. Same type, no mapping. If the types diverge in the future, we introduce a mapping then.
The places where we do invest in architectural rigor: error handling (every service returns typed results using a Result pattern, not thrown exceptions), validation (input validation happens once, at the route layer, using Zod schemas that serve as the source of truth for types), and dependency injection (services receive their repository dependencies as constructor parameters, making testing straightforward without mocking frameworks).
Testing is where our simplified approach really pays off. With strict clean architecture, testing the business logic requires setting up the entire dependency chain: mock the repository interface, inject it into the use case, create the right DTOs. With our approach, testing the service layer means passing in a mock repository (a plain object with the same method signatures) and calling the service method. No framework, no setup ceremony, just direct function calls.
We write three kinds of tests. Unit tests for services: mock the repositories, test the business logic. Integration tests for repositories: test against a real database (we use Docker-based PostgreSQL for test runs). End-to-end tests for critical paths: test the full HTTP request lifecycle. This gives us high confidence with a manageable test suite. A typical Tier 2 project has eighty to one hundred twenty tests that run in under thirty seconds.
The pragmatist's checklist for when to add more architecture: if your team is larger than five developers, add interfaces for cross-team boundaries. If your domain has complex invariants (financial calculations, scheduling algorithms, state machines), invest in a proper domain layer with entities and value objects. If you are building a platform that multiple teams will build on, add a formal application layer with well-defined use cases. If none of these apply, our three-layer approach will serve you well.
The honest truth about software architecture is that it is a spectrum, not a binary choice. The right amount of architecture is the minimum that keeps your codebase maintainable at your current team size and complexity level. Too little and you drown in spaghetti. Too much and you drown in abstraction. We have been on both sides, and the middle is where we do our best work.
About the Author
Fordel Studios
AI-native app development for startups and growing teams. 14+ years of experience shipping production software.
We love talking shop. If this article resonated, let's connect.
Start a ConversationTell us about your project. We'll give you honest feedback on scope, timeline, and whether we're the right fit.
Start a Conversation