Skip to main content
Read this once at altitude, then drill into any box. Duro is a pnpm + Turborepo monorepo. Three deployable Node services, a constellation of @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:
ServiceTalks toAuthWhy it’s separate
core-apithe dashboard + Duro staffsession cookies, staff keyFirst-party surface. Holds the keys to identity, KYC, and tenant config. Never exposed to merchant servers.
public-apimerchant servers, the SDK, customerssk_/pk_ keys, OAuth tokens, identity tokensThe internet-facing API. Multi-auth, rate-limited, idempotent. Its blast radius is one tenant’s data.
billing-workernothing inbound (a health port)noneA 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 dashboard and the public API are different audiences with different threat models, so they’re different processes. The worker has a completely different runtime shape — long-running jobs, no request/response — so it’s isolated where a slow charge can never block a checkout.

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 merchant POST /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. strict TypeScript with exactOptionalPropertyTypes, 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.
Next: the data model — how the three schemas are laid out and why.