How we organise an API
Our APIs are modular monoliths: one deployable service, split inside into feature modules. Each module has a simplified hexagonal shape: thin entry points, use-cases that hold the business rules, repositories for the database, and ports only where code talks to something outside the process. Read this before you add a module or a feature.
How it works
Read it left to right. Each box has one job, and the arrows only go right. A route never talks to the database. A repository never calls Stripe.
Why this shape
We tried two extremes:
| Plain layered (routes, services, repos at the top level) | Full hexagonal (framework-free core, ports for everything, CQRS, bridge-only module calls) | Simplified hexagonal (ours) | |
|---|---|---|---|
| Small API | Fine | Too much ceremony | Fine |
| Large API | One services/ folder with 80 files; features tangled | Works on paper; rules broken in practice | Features stay apart |
| New feature cost | Low | 8 to 10 files before any logic | 4 to 5 files |
| Swapping an outside service | Hard | Easy | Easy, because those still have ports |
| Testing | Mocks everywhere | Fakes for everything, including the database | Real Postgres, fakes only for outside services |
What happened with full hexagonal. Our largest API was written with strict rules: the core must not import any framework or database code, every database access through a port, separate command and query repositories, modules talk only through an in-process bridge. Two years later the rules were broken at scale:
- About 190 imports in "framework-free" use-cases reached straight into the database layer, almost all to open a transaction. The rule had no answer for transactions, so people worked around it.
- Modules imported each other's tables directly, despite the bridge-only rule, because the bridge was slower to write than an import.
- Module wiring files grew past 1,500 lines, because every port needed wiring.
- A new feature needed 8 to 10 files before the first line of business logic.
The structure was not the problem. The ceremony was. Simplified hexagonal keeps the structure and drops the rules nobody could follow. See Simplified hexagonal rules.
The pages in this module
| Page | Read it when |
|---|---|
| Simplified hexagonal rules | Always, first |
| Module layout | You add a module or a file |
| Between modules | One feature needs another module's data |
| Errors, validation and config | You add an endpoint or a setting |
| Tests and migrations | You add a table or a test |
| Code mistakes | Your first week |
Rules
Routes and resolvers are thin: parse input, check access, call one use-case, map the result. No SQL and no business rules in them.
Business rules live in use-cases, and a use-case returns a Result, it does not throw for
expected failures.
A module owns its tables. No other module reads or writes them directly.
Checklist
- The feature lives in one module folder.
- Route or resolver is thin.
- Use-case holds the rules and returns a
Result. - SQL only in the module's repositories.
- Outside services behind a port.