Skip to main content

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 APIFineToo much ceremonyFine
Large APIOne services/ folder with 80 files; features tangledWorks on paper; rules broken in practiceFeatures stay apart
New feature costLow8 to 10 files before any logic4 to 5 files
Swapping an outside serviceHardEasyEasy, because those still have ports
TestingMocks everywhereFakes for everything, including the databaseReal 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​

PageRead it when
Simplified hexagonal rulesAlways, first
Module layoutYou add a module or a file
Between modulesOne feature needs another module's data
Errors, validation and configYou add an endpoint or a setting
Tests and migrationsYou add a table or a test
Code mistakesYour first week

Rules​

Rule

Routes and resolvers are thin: parse input, check access, call one use-case, map the result. No SQL and no business rules in them.

Rule

Business rules live in use-cases, and a use-case returns a Result, it does not throw for expected failures.

Rule

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.