Skip to main content

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​

RuleWhy we keep it
Feature modules. Each feature is a folder under modules/ with its own tablesFeatures stay apart. A new engineer finds everything about orders in one place
Thin entry points. Routes and resolvers only parse, authorise, call, mapOne 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 APIsThese change, fail, and need fakes in tests. A port makes all three cheap
A module owns its tablesNo hidden coupling through shared tables
Side effects after commit go through eventsAn email or callback never fires for a transaction that rolled back

Dropped​

Full hexagonal ruleWhat we do insteadWhy
The core imports no framework or database codeUse-cases may call withTransaction and receive a txThe rule had no answer for transactions. It was broken about 190 times
Every database access goes through a port interfaceRepositories are plain modules with functions; no interfaceWe never swap Postgres. The interface was a second copy of every signature
Separate command and query repositoriesOne repository per table group. Split only if reads use a replicaTwo files per table doubled the work for no gain
Separate inbound DTO interfacesThe 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 bridgeImport another module's public index.ts functions. Events for side effectsThe 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 databaseTests run against real Postgres. Fakes only for outside servicesFakes 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​

Rule

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.

Rule

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.