Why I adopted it
I did not choose Hexagonal Architecture because it was fashionable. I chose it because the delivery model had a concrete problem: business logic was expensive to change and hard to test without bringing infrastructure along for the ride.
The problem was not architectural elegance. It was delivery risk. Every business change depended on a database, a mailer, a storage provider, or an HTTP server being available in the test path. The domain was coupled to its infrastructure, and every change carried an invisible cost.
Ports and Adapters offered a clear proposition: separate what the application does from how it does it. The domain expresses business rules. Infrastructure delivers them. Nothing in the domain should know whether it is talking to PostgreSQL, S3, a mail provider, or a test double.
I applied this across two consecutive missions over eighteen months: a Node.js authentication platform for a banking group, and a fleet management SaaS built from scratch.
This is what held up, what created friction, and what I would enforce earlier next time.
What genuinely worked
Swapping infrastructure without touching the domain
This is where the architecture proved its value.
On both missions, the file storage provider and mailing provider changed several times. Different vendors, different APIs, different operational constraints. Each change stayed inside the adapter layer. The domain did not move. The use cases did not move. The business tests did not change.
When a provider swap takes place behind a port and the domain test suite still passes untouched, the pattern stops being theory.
Business rules stayed where the team could reason about them
In a coupled architecture, rules scatter. Some live in service classes, some leak into controllers, some hide inside database queries, and some are enforced only by validation code near the transport.
With Hexagonal Architecture, the rule became stricter: business invariants belong in the domain. Entities and value objects either accept a valid state or reject it. Aggregates either accept an operation or return a domain error. The application layer coordinates the use case, but it does not smuggle persistence or transport decisions into the rule itself.
That made review conversations clearer. A pull request that changed a business rule had to change the domain model or use case. A pull request that changed a vendor API had to stay in infrastructure.
The separation was not cosmetic. It gave the team a way to ask the right question during review: are we changing the business, or are we changing how the business is delivered?
Testing became faster and more meaningful
The biggest testing gain came from in-memory adapters.
Before implementing a real adapter, the team could define the port, create an in-memory implementation, and cover the business cases against the use case. The tests were fast, deterministic, and independent from database state, network latency, or third-party APIs.
The workflow became:
- Define the port the use case needs.
- Implement an in-memory adapter for tests.
- Cover the business scenarios.
- Implement the real infrastructure adapter.
- Add a smaller set of integration tests for the adapter itself.
By the time the database or external provider entered the picture, the business behavior was already proven. Infrastructure tests still mattered, but they became a targeted surface rather than the only way to test the application.
The same use case could be exposed through several interfaces
Because use cases were plain application classes with no framework dependency, the same behavior could be exposed through REST, tRPC, a CLI, and a REPL.
That mattered in daily delivery. During development, calling a use case directly from a REPL made debugging faster than reproducing everything through HTTP. During testing, driving behavior through application ports kept tests focused on the application instead of the transport.
It also kept the team honest. If a use case only worked when called through one specific framework controller, it was not really a use case. It was framework code with a business name.
Error handling became easier to reason about
Domain errors and infrastructure errors stopped mixing.
A domain error means a business rule was violated. It has a name, a meaning, and belongs to the domain vocabulary. An infrastructure error means something external failed: a network timeout, a database connection issue, a storage provider error, or a third-party API returning an unexpected response.
When these are separate, error handling becomes intentional. You stop writing catch blocks that do not know what they caught. You stop exposing infrastructure stack traces as business failures. Each error tells the team where to look.
The ubiquitous language paid off outside the codebase
The architecture also changed conversations.
When code is organized around domain concepts rather than framework modules or database tables, product and business discussions map more naturally to the implementation. A product manager can describe a rule, and the team can place it in the model.
That is one of the underrated benefits of this style. Hexagonal Architecture is not only a testing technique. When paired with DDD, it gives teams a stronger link between product language, architectural boundaries, and code ownership.
What was harder than expected
The boilerplate is real
Hexagonal Architecture has a cost. Ports, adapters, use cases, domain services, value objects, and presenters create more files than a conventional layered structure.
On a small feature, the overhead is visible. On a larger product, it often pays back through clearer boundaries, faster tests, and safer integration changes. But the initial investment is not imaginary, and teams should not pretend otherwise.
The question is not whether the pattern adds structure. It does. The question is whether the product has enough durable business rules and integration change to justify that structure.
Team alignment does not happen automatically
This was the hardest part.
Everyone had a slightly different mental model of what Hexagonal Architecture meant in practice. Where does validation live? Is a repository a port or an adapter? Can one use case call another use case? Should a domain object throw, return a result, or collect violations?
Without explicit conventions, the implementation diverges. And divergence is expensive in an architecture that depends on consistency.
We had some of those conversations too late. They should have happened before the first feature.
The framework boundary drift problem
Frameworks are useful. They should also stay at the edge.
On the banking platform mission, two patterns appeared that showed the boundary was starting to drift.
The first was framework decorators on use cases. A use case is an application-layer object. It should not need a framework dependency to exist, to be constructed in a test, or to be called by another adapter. Once the use case depends on a framework container, the application layer becomes harder to reuse and harder to test in isolation.
The second was ORM or framework decorators on domain entities. Entities are supposed to express business concepts and protect invariants. When they carry database mapping metadata, they become persistence artifacts dressed as domain objects.
NestJS was the concrete framework in this case, but the issue is not specific to NestJS. The same drift can happen with any framework or ORM that makes it convenient to annotate the core model.
The fix is a boundary rule, not a tool:
Framework code belongs at the edge.
Domain and application code must remain plain TypeScript.
A clean boundary looked like this:
src/
domain/
user/
user.ts
user-errors.ts
application/
user/
create-user.ts
ports.ts
infrastructure/
nestjs/
user.module.ts
user.controller.ts
persistence/
user.repository.ts
The important part is not the folder names. It is the dependency direction: infrastructure can know the application, the application can know the domain, but the domain does not know the framework exists.
What I would do differently
Write the conventions before writing the code
The next time I introduce this architecture with a team, I write the ADR first.
Not a wiki page. Not a diagram in a slide deck. A short Architecture Decision Record committed to the repository and reviewed like code.
The minimum ADR should answer these questions:
- What is a port in this codebase?
- What is an adapter?
- Where does input validation live?
- Where do business invariants live?
- Can use cases call other use cases?
- How are domain errors represented?
- How are infrastructure errors represented?
- Which imports are forbidden across layers?
- Where does dependency injection happen?
- Which tests prove the boundary is working?
This document does not need to be long. It needs to be explicit. Without it, the architecture relies on memory and taste. That does not scale across a delivery team.
Enforce the boundary from day one
Boundary drift is easier to prevent than to remove.
The enforcement mechanism can be simple: lint rules, import restrictions, folder conventions, or a review checklist. The important point is that the boundary is not treated as a suggestion.
For example:
domain/cannot import frominfrastructure/.application/cannot import web framework packages.- domain entities cannot carry ORM decorators.
- adapters translate external concerns into application commands.
- presenters translate application results into transport responses.
These rules are not bureaucracy. They are how the architecture stays useful when delivery pressure increases.
Is it worth it?
Yes, when the product has durable business rules, multiple integrations, and a team willing to enforce the boundary.
The initial time investment is real. You feel it in the first sprint. But after a few months, the benefits compound: provider swaps stay contained, business tests run without infrastructure, product conversations use the same vocabulary as the code, and errors tell the team whether the failure is business logic or an external system.
It is not the right default for every project. A prototype, a script, or a short-lived service may not need it. In those contexts, the ceremony can outweigh the benefit.
But for a product that will evolve, with integrations that will change and business rules that matter, the domain deserves protection from infrastructure decisions.
Build the boundary. Write the rules. Enforce them early.
The framework is a delivery mechanism, not the architecture.