Every module has the same shape
Each module is a small application with its own schema, events and capabilities. The folders, layers and rules are the same in every module and in both backends, so once you know one module you can find your way around all of them.
One shape, two languages
Account, shown here, is typical. The Nest twin has the same folders with kebab-case file names, and a few extra transport folders because Nest registers jobs, startup hooks and operator commands through its own container.
backend/go/internal/account/ ├── build.go Schema const, Build(rt) → module.Unit ├── module.go wiring: handlers, routes, capabilities ├── admin.go operator command facades ├── domain/ account, membership, invitation, events ├── app/ │ ├── command/ accept_invitation.go, create_account.go… │ ├── query/ list_members.go, personal_data.go… │ ├── ports.go interfaces the module owns │ └── errors.go ├── infra/ │ ├── postgres/ store.go: state + outbox │ ├── events/ producer.go, identity_consumer.go │ └── capabilities/ clients.go └── transport/ ├── http/v1/ └── capability/v1/
backend/nest/src/modules/account/ ├── schema.ts SCHEMA const ├── account.module.ts ├── domain/ ├── app/ │ ├── command/ accept-invitation.ts… │ ├── query/ │ ├── ports.ts │ └── errors.ts ├── infra/ │ ├── postgres/ account.store.ts │ ├── events/ producer.ts, identity-event.consumer.ts │ ├── capabilities/ clients.ts │ └── security/ └── transport/ ├── http/v1/ account.controller.ts ├── capability/v1/ ├── jobs/ account.jobs.ts ├── startup/ declare-roles.hook.ts └── admin/v1/
Layers
Dependencies point inward. The domain knows nothing about storage or transport, the application layer defines the ports it needs, and only the outer adapters touch HTTP, events, other modules or the database.
| Layer | Holds | May import |
|---|---|---|
domain | Values, invariants, internal facts and domain errors. No I/O. | Itself, plus pkg/{errors,id,clock,provenance,secret} |
app/command, app/query | One handler per operation: Handle in Go, execute in TypeScript. | Domain, its own ports and the same small set of shared packages |
app/ports, app/errors | Interfaces the module owns, in its own types, and application errors. | Domain |
infra/* | Adapters: postgres, events, capabilities, and where needed s3, cache, catalog, mail, security, models, broadcast. | App, domain and shared packages; never transport |
transport/* | HTTP v1 routes and capability/v1 handlers. Nest also has jobs, startup and admin. | App and domain; never infra |
The module unit
In Go, a module's Build(rt module.Runtime) returns a module.Unit, which tells the process everything it needs to run the module. The runtime hands each module a database scoped to its own schema, a capability invoker, a principal resolver, the broadcast bus, a cache in its own namespace, IDs, a clock and provenance. Nest expresses the same pieces as a module with providers wired throughmoduleDatabase(SCHEMA).
| Field | Purpose | Nest equivalent |
|---|---|---|
Name | The module's schema name. | SCHEMA in schema.ts |
Routes | Registers public HTTP routes, if any. | Controllers |
Outbox | The module produces events from its schema's outbox. | OUTBOXES in app.module.ts |
Subscribe | Registers event consumers. | Consumer providers |
Capabilities | Capabilities it provides, each with the callers allowed to use it. | transport/capability/v1 |
Requires | Capabilities it calls. The process starts only when each has a local or remote provider. | Capability clients |
Start | Runs once before serving, for example Account declaring its roles. | transport/startup |
Jobs | Periodic housekeeping. One replica at a time unless Shared, for workers that claim rows with FOR UPDATE SKIP LOCKED. | JobRegistry.register in transport/jobs |
Close | Releases what the module owns, after its subscriptions close. | Lifecycle hooks |
Schema, role and migrations
Own schema
All module SQL is schema-qualified. No joins, views, triggers or foreign keys across schemas. Authorization's schema is authz because authorization is reserved.
Own role
Each module connects as bento_rt_<schema>, which can use only its own schema. Only the migrator changes schemas.
Own history
Migrations live in migrations/<schema>/, start from 0000_<schema>_baseline.sql and are tracked in <schema>.schema_migrations.
Same files twice
Go and Nest ship identical migration folders, and CI fails if they differ.
The runner, scripts/migrate-postgres.sh (make db-migrate), records a SHA-256 of each applied file and refuses edited ones. Databases created before the per-schema split keep a frozen shared history, migrations/0001 to 0018, and adopt the baselines; new databases run the baselines only. Each connection can move to its own database with<SCHEMA>_DATABASE_URL.
Events: outbox, delivery, inbox
- ProducerWrites its state change and an
event_outboxrow in one transaction. - DispatcherOne per producing schema. Leases rows, publishes them, retries failures and quarantines rows it cannot decode.
- TransportIn memory in a monolith, NATS JetStream in a split. Delivery is at least once.
- ConsumerTracks progress in its own
event_deliverytable and records the event in itsevent_inboxin the same transaction as its state change, so a redelivery is ignored.
Eight modules produce events: Identity, Account, Authorization, Audit, Billing, Files, Privacy and AI. Event payloads are versioned JSON Schemas in contracts/events; a changed payload gets a new.vN and the old version stays listed until it is retired. Every module also gets an automatic<schema>.prune_event_tables job.
HTTP conventions
Problem responses
Failures are application/problem+json with a module-qualified type such as identity.sign_in_denied. Go maps ordinary errors to status codes in httpx; Nest returns Result values and a problem filter. See Errors.
Idempotent requests
Creates and workspace writes take an optional Idempotency-Key (1–128 characters), scoped to the principal and operation, stored with a fingerprint and honoured for at least 24 hours. A retried write returns its recorded response with Idempotency-Replayed: true, before If-Match is checked; GET /v1/receipts/{module}/{key} says whether a key landed. Reusing a key for different input returns 409 <module>.idempotency_key_reused. See contracts/http/idempotency.md.
Optimistic concurrency
Mutable resources return an ETag and accept If-Match. A stale version returns 409 <module>.version_conflict.
One contract
Both backends serve exactly the routes in contracts/http/openapi.yaml. See Contracts.
Caching
Each module gets a cache in its own namespace, in memory or on Redis (CACHE_PROVIDER), with a short local tier in front of Redis. A cache failure counts as a miss and never grants anything. Only three reads are cached today:
| Module | Caches | Lifetime |
|---|---|---|
| Identity | Session lookups | 10 s |
| Authorization | Permission sets, invalidated through a generation key | 30 s |
| Entitlements | Resolved features and limits | 60 s |
Enforcement
The rules above are checked, not just written down. Both checks run in make check and in CI.
internal/architecture
TestModuleBoundaries reads go list output, including test imports. It rejects cross-module imports, outward layer imports, pkg/ importing internal/, and cmd/ importing anything but module roots. TestSchemaIsolation checks that SQL and event calls name only the module's own schema.
dependency-cruiser
npm run architecture:check applies no-cross-module-imports and the layer rules to src and test, and scripts/check-schema-isolation.mjs checks schema names in SQL.
Adding a module
- Create the folders and a schema name, then add its baseline migration and runtime role to both migration folders.
- Register it: the
moduleslist incmd/server/main.goandModuleSchemasin Go;MODULES,OUTBOXESandMODULE_SCHEMASin Nest. - Add its events and capabilities to
contracts/, its routes to the OpenAPI spec, and its personal data tocontracts/privacy/inventory.yamlwith a<module>.personal_data.v1provider. - Run
make checkin both backends and the end-to-end suites.
The full checklist is backend/ADDING-A-MODULE.md.
In the repository
backend/MODULE-AUTHORING.mdLayers and what goes where.backend/ADDING-A-MODULE.mdThe checklist for a new module.backend/CONVENTIONS.mdNaming, errors and code style.backend/EVENT-RULES.mdOutbox, delivery and inbox rules.backend/HTTP-V1.mdHTTP behaviour behind the OpenAPI spec.backend/CACHE.mdThe cache port and its uses.