@duro/* packages they share, and a Postgres/Redis/R2 data plane. No Docker in the hot path — services run under PM2 on a VPS.
The whole system
Why three services, not one
A clean separation of trust boundaries and runtime shapes:| Service | Talks to | Auth | Why it’s separate |
|---|---|---|---|
| core-api | the dashboard + Duro staff | session cookies, staff key | First-party surface. Holds the keys to identity, KYC, and tenant config. Never exposed to merchant servers. |
| public-api | merchant servers, the SDK, customers | sk_/pk_ keys, OAuth tokens, identity tokens | The internet-facing API. Multi-auth, rate-limited, idempotent. Its blast radius is one tenant’s data. |
| billing-worker | nothing inbound (a health port) | none | A pure background process. No HTTP surface for business logic — it only consumes queues and runs scanners on a timer. Scaling it never touches request latency. |
The packages do the real work
Controllers are thin. The intelligence lives in framework-agnostic packages so it’s testable in isolation and reusable across services:@duro/billing
Pure logic, zero IO. The subscription state machine, proration math, the dunning decision engine, payday windows, retry schedules, billing-cycle date math. 35 unit tests. This is the brain.
@duro/db
Two generated Prisma clients (core + data), a
RepositoryContext that hands a controller exactly the tenant-scoped repositories it needs, version-bump cache invalidation, cursor pagination.@duro/http-kit
BaseController, responders, the PermissionGuard, tenant-scope and API-key middleware, idempotency, pagination parsing. Write a controller in twenty lines.@duro/payments / queue / whatsapp / email
Thin abstractions over the outside world — a
ChargeGateway interface (simulated now, a live rail in July), BullMQ factories, the WhatsApp Cloud client, the SMTP mailer. Swap the implementation, keep the callers.A request’s worth of moving parts
A merchantPOST /v1/subscriptions touches a precise, short chain:
The key move: by the time the controller runs, req.repos is already a RepositoryContext bound to the right tenant and the right schema. The controller cannot accidentally read another tenant’s data — it has no client that can. See multi-tenancy →
What runs on a timer
The worker is the only thing in the system that acts without a caller. Four repeatable scanners, each a cross-tenant sweep that enqueues per-item jobs: Each scanner runs across both test and live schemas — a background process has no “current mode,” so it must process everyone. Request-scoped code never does this; only the worker. See queues & workers →Conventions, enforced
These aren’t style preferences — they’re checked in CI and they shape how the code reads.- OOP everywhere. Zero free
export functions in business logic. Behaviour lives in classes with static or instance methods. Presenters, services, gateways, guards — all classes. - No
any, ever.strictTypeScript withexactOptionalPropertyTypes,noUncheckedIndexedAccess,verbatimModuleSyntax. - No comments. The code is named to be read. The explanation lives here, in the docs, not in the source.
- Exact version pins. No
^/~. Lockfile is law. - Every mutation is verified live. The integration suite runs the real money loop against Postgres on every push.