Bento docs
Architecture

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.

Go and NestJSChecked against the current v1 readiness tracker, 16 October 2026

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.

Inbound adapters (HTTP routes, capability handlers, event consumers) call commands and queries in the application layer, which uses the domain and its own ports. Outbound adapters (the Postgres store, capability clients and the event relay) implement those ports.Inbound adaptersApplicationOutbound adaptersHTTP routestransport/http · v1Capability handlerstransport/capabilityEvent consumersinfra/events · inboxCommands and queriesapp/command · app/queryDomainValues, invariants, facts.No I/O, no other modules.PortsInterfaces the module owns,in its own types.Postgres storestate + outbox, one txCapability clientsinfra/capabilitiesEvent relayoutbox → transportimplementscalls
The module's schema holds its state, outbox, inbox and delivery tables. A state change and its outbox row commit in one transaction.
LayerHoldsMay import
domainValues, invariants, internal facts and domain errors. No I/O.Itself, plus pkg/{errors,id,clock,provenance,secret}
app/command, app/queryOne handler per operation: Handle in Go, execute in TypeScript.Domain, its own ports and the same small set of shared packages
app/ports, app/errorsInterfaces 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).

FieldPurposeNest equivalent
NameThe module's schema name.SCHEMA in schema.ts
RoutesRegisters public HTTP routes, if any.Controllers
OutboxThe module produces events from its schema's outbox.OUTBOXES in app.module.ts
SubscribeRegisters event consumers.Consumer providers
CapabilitiesCapabilities it provides, each with the callers allowed to use it.transport/capability/v1
RequiresCapabilities it calls. The process starts only when each has a local or remote provider.Capability clients
StartRuns once before serving, for example Account declaring its roles.transport/startup
JobsPeriodic housekeeping. One replica at a time unless Shared, for workers that claim rows with FOR UPDATE SKIP LOCKED.JobRegistry.register in transport/jobs
CloseReleases 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

  1. ProducerWrites its state change and an event_outbox row in one transaction.
  2. DispatcherOne per producing schema. Leases rows, publishes them, retries failures and quarantines rows it cannot decode.
  3. TransportIn memory in a monolith, NATS JetStream in a split. Delivery is at least once.
  4. ConsumerTracks progress in its own event_delivery table and records the event in its event_inbox in 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:

ModuleCachesLifetime
IdentitySession lookups10 s
AuthorizationPermission sets, invalidated through a generation key30 s
EntitlementsResolved features and limits60 s

Enforcement

The rules above are checked, not just written down. Both checks run in make check and in CI.

Go

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.

Nest

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

  1. Create the folders and a schema name, then add its baseline migration and runtime role to both migration folders.
  2. Register it: the modules list in cmd/server/main.go and ModuleSchemas in Go; MODULES, OUTBOXES and MODULE_SCHEMAS in Nest.
  3. Add its events and capabilities to contracts/, its routes to the OpenAPI spec, and its personal data to contracts/privacy/inventory.yaml with a <module>.personal_data.v1 provider.
  4. Run make check in both backends and the end-to-end suites.

The full checklist is backend/ADDING-A-MODULE.md.

Shared packages

Modules share mechanics, never vocabulary. backend/go/pkg and backend/nest/src/sharedhold the same set of packages:

eventsEnvelope, dispatcher, memory and NATS transports.
persistence/postgresPools, transactions, outbox, delivery, inbox, retention.
capabilityDeadline-bounded calls, in-process or over HTTP.
httpxMux, JSON, problems, cursors, CORS, rate limits.
edgeThe public gateway and route discovery.
principalSession or edge assertion.
serviceauthService-to-service signed tokens.
broadcastAt-most-once fan-out, memory or NATS.
cacheMemory or Redis, with single-flight loads.
jobsThe periodic job scheduler.
telemetryOpenTelemetry traces, metrics and logs.
errorsKinds, types and the public view of failures.
configRuntime settings and their validation.
id · clock · secretUUIDv7 IDs, time and redacted secrets.

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.