Layered architecture is the default arrangement of most applications, and hexagonal architecture — also called ports and adapters, and closely related to clean architecture — is a specific fix for the one thing layering usually gets wrong.
The fix is a single rule: dependencies point inwards, towards the business logic, never outwards. Everything else in this topic follows from it.
Where plain layering leaks
The conventional stack — presentation over application over domain over data — looks disciplined and usually is not.
// In the domain layer
import { OrderRow } from '../infrastructure/database/models';
class Order {
static fromRow(row) { /* … */ }
async save(db) { await db.orders.update(this.id, this.toRow()); }
}The domain now imports the database. That single arrow has consequences out of proportion to its size:
- The business rules cannot be tested without a database. Every test of pricing logic needs a schema and a connection.
- Changing the persistence library changes the domain. The rules were never coupled to the rules of business; they are coupled to a driver's API.
- The domain speaks the database's vocabulary. Rows, columns and nullable fields leak into a model that should speak about orders and customers.
Layering says the domain sits above data. Nothing enforces it, and a single import inverts it.
The dependency rule
Diagram, as text
- HTTP handlers leads to Use cases
- CLI leads to Use cases
- Use cases leads to Domain model and rules
- Use cases leads to Port: OrderRepository (interface, defined inside)
- Use cases leads to Port: PaymentGateway (interface, defined inside)
- Postgres repository leads to Port: OrderRepository (interface, defined inside) (implements)
- Payment gateway client leads to Port: PaymentGateway (interface, defined inside) (implements)
A port is an interface the inside defines and the outside implements. That is the whole trick, and the direction is the point: OrderRepository is declared in the domain, in the domain's vocabulary, and a Postgres adapter satisfies it. The domain depends on nothing but itself.
An adapter is the outside implementation — a Postgres repository, an HTTP handler, a payment client. Adapters may import the domain; the domain may not import them.
Two kinds of adapter, and the asymmetry matters:
- Driving adapters call in: HTTP handlers, CLI commands, message consumers, scheduled jobs.
- Driven adapters are called out to: repositories, gateways, notification senders.
Only driven adapters need ports. A driving adapter simply calls a use case, because the dependency already points the right way.
What it buys, and what it costs
Buys: the domain is testable with no infrastructure. This is the benefit collected daily. A test constructs an in-memory repository and exercises real business rules in milliseconds. Teams that adopt this consistently report the change in test suite speed as the largest practical effect.
Buys: infrastructure choices become reversible. Not free — the adapter still has to be written — but scoped to one directory.
Buys: the domain says what it needs. OrderRepository.findUnpaidOlderThan(days) is a domain question. A generic data-access layer forces the domain to assemble queries and leaks storage concepts upward.
Costs: more files and more indirection. An interface, an implementation and a wiring line where there was one class. For a small application this is real overhead with little return.
Costs: mapping. Domain objects and persistence rows are different shapes, and something must translate. That mapping is genuine work and a genuine source of bugs.
Costs: it is easy to do halfway. A codebase with ports whose interfaces are shaped like the ORM's API has the file count without the independence.
Enforcing it
The rule is not self-enforcing, and a single import undoes it. Three levels of enforcement, in increasing order of reliability:
- Convention and review. Works until the team grows or a deadline arrives.
- Module boundaries. Package-private visibility, or a module that exports only its ports, so the wrong import cannot resolve.
- A lint rule on import paths, failing the build when the domain imports infrastructure. Cheap, mechanical, and the only one that holds under pressure.
Most codebases that have this architecture successfully have the third. Most that describe themselves as having it and do not have only the first.
When it is not worth it
Small applications with no meaningful domain logic. A service that validates a request and writes a row has no business rules to protect. The ports would wrap a single database call each.
Genuinely data-centric software. Reporting tools, admin panels and ETL pipelines are about the data. Hiding the database behind a domain model adds mapping and removes the ability to write the query the job needs.
Prototypes. The architecture protects against change over years. Code that may not survive the month should not pay for it.
The honest heuristic: if you cannot name three business rules that must not depend on infrastructure, you do not need this yet.
Check your understanding
Common mistakes
Copying the folder structure without the dependency rule. All the cost, none of the independence.
Ports shaped like the database. The storage model enters the domain through an interface.
Anaemic domain with logic in the adapters. Business rules end up in repositories and handlers, which is the arrangement the architecture was meant to prevent.
No enforcement. One import inverts the rule and nothing notices.
Applying it to data-centric software. Reporting and ETL are about the data; the mapping is pure overhead.
A port per database table. Ports express what the domain needs, not what the schema contains.
Mapping code with no tests. The translation between domain and persistence is real logic and a real source of bugs.
Practice
An existing service has business logic spread across HTTP handlers and ORM models. Design an incremental path to the dependency rule, and say what you would do first and what you would not attempt.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
A rewrite is not available. What single change delivers the most benefit?
Show a strong answer
Do not start with the folder structure. Moving files gives no benefit on its own, produces an enormous unreviewable diff, and makes every subsequent change conflict. Start with the arrows, in the place where they are most expensive today.
First move: extract one use case with real business rules, with a port for its persistence. Pick the most-tested, highest-value flow — pricing, or eligibility, or whatever the team fears changing. Concretely:
- Define the port from what the use case needs, phrased as a domain question, and implement it with the existing ORM behind it. The ORM stays; only the direction of the arrow changes.
- Move the rules out of the handler into the use case. The handler becomes parsing, calling, and rendering.
- Write the test with an in-memory implementation of the port. This is the moment the benefit becomes visible: a business-rule test that runs in milliseconds with no database. One of those does more to persuade a team than any architecture document.
Then: stop the bleeding. A lint rule forbidding new imports from the domain package into infrastructure, with existing violations grandfathered. Without this, the migration races against feature work and never converges — the same lesson as any other incremental migration.
Then: repeat, opportunistically. Convert flows as they are touched for other reasons rather than scheduling dedicated architecture work, which loses to features.
What I would not attempt:
- A big-bang restructure. Unreviewable, unbisectable, and it will sit behind feature branches until it conflicts irreparably.
- Extracting ports for everything. Some parts of the service have no business rules to protect — a health endpoint, an admin listing — and giving them ports produces interfaces that wrap one call each. The architecture is for logic worth isolating.
- Replacing the ORM at the same time. Two changes at once means no way to tell which one broke something. The point of the port is that the ORM swap becomes possible later, scoped to one directory.
- Renaming everything to match a book's vocabulary. Arguing about whether it is a use case, an interactor or an application service consumes review time and changes nothing.
How I would know it is working: the number of business-rule tests that need no database, tracked over time. That number rising is the benefit. If it is flat while files move, the restructure is cosmetic.
A team applies hexagonal architecture to a reporting service whose job is to run complex analytical queries and produce dashboards. Six months later the ports have grown a method per report. Diagnose it.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask what the domain logic actually is in this system.
Show a strong answer
The diagnosis is that the architecture was applied to a system with no domain to protect. A reporting service's value is in the queries — the aggregations, the joins, the window functions. That is not business logic sitting above the database; it is database work. There is no set of rules that would survive a change of store, because a change of store would rewrite every query.
Why it degenerated into a method per report, which is the symptom worth explaining: a port is meant to express what the domain needs in terms the domain understands. Here, what the "domain" needs is precisely one specific query per report — there is no generalisation available. So each report adds getMonthlyRevenueByRegion(), getChurnCohorts(), and so on. The interface is a list of reports because the system is a list of reports.
The cost they are paying:
- Every new report touches three files — port, adapter, use case — where one would do.
- The port prevents the thing that would actually help. Analytical work benefits from composing filters, groupings and time ranges at the call site. A fixed method per report forbids that, so parameters accumulate on each method until they are query builders in disguise.
- The abstraction protects nothing. No test uses a second implementation, because writing an in-memory version of a windowed aggregation is harder than the query itself and would test nothing real.
What I would do:
- Remove the ports for the reporting paths and let report modules use the database directly, with the query as the unit. Test them against a real database with seeded data, because that is the only test that verifies what matters.
- Keep the boundary at the edges that genuinely have rules — access control on which reports a user may run, tenant scoping, caching policy. Those are real logic, they are independent of the store, and they deserve isolation.
- Keep the queries in one module so they are findable and reviewable, and give them names. Structure is still valuable; it is the inversion that is not.
The general principle to take from this: hexagonal architecture protects business rules from infrastructure churn. Where the infrastructure is the product, there is nothing to protect and the indirection is pure cost. The honest test in this topic — name three business rules that must not depend on infrastructure — would have returned nothing here, and that answer should have been allowed to settle it.
Key takeaways
- The defining idea is the dependency rule: the domain depends on nothing, and adapters depend on the domain.
- A port is an interface defined by the inside, in the inside's vocabulary, implemented by the outside.
- Only driven adapters need ports; driving adapters already point the right way.
- The benefit collected daily is business rules testable with no infrastructure, in milliseconds.
- Test a port by asking whether a completely different store could implement it without changing the signature.
- Enforce with a lint rule on import paths. Convention and review do not survive a deadline.
- Costs are file count, indirection and mapping — and mapping code is real logic that needs tests.
- Skip it when there is no domain to protect: small services, reporting, ETL, prototypes. If you cannot name three rules worth isolating, you do not need it yet.
Further reading
- Alistair Cockburn, "Hexagonal Architecture" (2005) — the original formulation of ports and adapters.
- Robert C. Martin, Clean Architecture (Prentice Hall, 2017) — the dependency rule stated as a general principle.
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — the layered architecture the rule corrects.
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — service layer, domain model and the transaction script alternative.
- Jeffrey Palermo, "The Onion Architecture" (2008).
- Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013).