Simplified hexagonal rules
The exact rules we keep from hexagonal architecture, the ones we dropped, and why each was dropped. Decided in October 2026, after two years of running the full version. Read it before your first pull request on an API.
How it works
Kept
| Rule | Why we keep it |
|---|---|
Feature modules. Each feature is a folder under modules/ with its own tables | Features stay apart. A new engineer finds everything about orders in one place |
| Thin entry points. Routes and resolvers only parse, authorise, call, map | One place for business rules; entry points can change (REST, GraphQL, queue) without touching them |
Use-cases hold the rules and return Result<T, AppError> | Expected failures are values, so callers must handle them |
| Ports for outside services. Payments, storage, email, AI, push, other APIs | These change, fail, and need fakes in tests. A port makes all three cheap |
| A module owns its tables | No hidden coupling through shared tables |
| Side effects after commit go through events | An email or callback never fires for a transaction that rolled back |
Dropped
| Full hexagonal rule | What we do instead | Why |
|---|---|---|
| The core imports no framework or database code | Use-cases may call withTransaction and receive a tx | The rule had no answer for transactions. It was broken about 190 times |
| Every database access goes through a port interface | Repositories are plain modules with functions; no interface | We never swap Postgres. The interface was a second copy of every signature |
| Separate command and query repositories | One repository per table group. Split only if reads use a replica | Two files per table doubled the work for no gain |
| Separate inbound DTO interfaces | The Zod schema is the DTO: type CreateOrder = z.infer<typeof CreateOrder> | One definition instead of two that drift |
| Modules talk only through an in-process bridge | Import another module's public index.ts functions. Events for side effects | The bridge was slower to write than an import, so people imported internals instead. Allow the safe import and forbid the unsafe one |
| Hand-written fakes for every port, including the database | Tests run against real Postgres. Fakes only for outside services | Fakes hid real bugs: a date-binding error broke every webhook in production while all tests passed |
How to apply it
A new action in a module is usually four files:
modules/orders/
api/create-order.route.ts # parse, authorise, call, map
use-cases/create-order.ts # rules, transaction, Result
repositories/orders.repo.ts # SQL (add a function if the file exists)
use-cases/create-order.test.ts # against real Postgres
Add a port and an adapter only when the use-case calls something outside the process:
ports/payments.port.ts # interface PaymentsPort { charge(...): Promise<Result<...>> }
adapters/stripe-payments.ts # implements PaymentsPort
Rules
If you cannot follow a rule here, change the rule in this page by pull request. Do not work around it in code. The full version failed because everyone worked around it quietly.
No deep imports between modules. import { getPlan } from '@/modules/billing' is fine;
import { plans } from '@/modules/billing/repositories/plans.repo' is not. Enforce it with
a lint rule (for example eslint-plugin-boundaries or no-restricted-imports).
Checklist
- New action: route, use-case, repository function, test.
- Port and adapter only for outside services.
- Transactions through
withTransaction. - Cross-module calls only through the other module's
index.ts. - Lint rule blocks deep imports.