How we build it
For contributors: how the code inside api is organized. The whole backend follows CleanSlice (NestJS + Prisma), in an agentfy flavor — a predictable structure so any feature looks like every other feature.
The short version: code is split into slices (one self-contained feature each), slices are stacked into layers (groups), and dependencies only ever point downward. New here? The Glossary defines every term below.
Slice anatomy
slices/<group>/<slice>/
├── <slice>.module.ts # NestJS module
├── <slice>.controller.ts # @Controller('plural') — REST
├── <slice>.prisma # Prisma model at the slice root (if it has data)
├── <slice>.tool.ts # optional: expose via MCP (@Tool)
├── domain/ # WHAT it does (contracts + logic)
│ ├── index.ts
│ ├── <slice>.types.ts # IXxxData, ICreateXxxData, XxxTypes (enum)
│ ├── <slice>.gateway.ts # abstract IXxxGateway ← DI token
│ ├── <slice>.service.ts
│ └── errors/ # domain errors (extends BaseError)
├── data/ # HOW (implementation)
│ ├── <slice>.gateway.ts # concrete impl (via PrismaService)
│ └── <slice>.mapper.ts # transforms (sync, no promises)
└── dtos/
├── <slice>.dto.ts · create<Slice>.dto.ts · update<Slice>.dto.ts · filter<Slice>.dto.tsFlow: Controller → IXxxGateway (abstract) → XxxGateway (impl) → Mapper → Prisma.
Rules
- Gateway pattern — abstract
IXxxGatewayindomain/, concrete indata/. Prisma IS the repository — no*Repositoryclasses. - Singular slice names; camelCase DTO files;
I-prefix interfaces;Types-suffix enums;#aliasimports;@scope/@slice/@layer/@typeheader tags. - Prisma models live at the slice root (
<slice>.prisma), assembled byinfra/prisma. Generate withbun run prisma:generate, which assembles first — a barenpx prisma generateexits zero against a stale assembly and hands you a client that does not know your new model. - Every response is enveloped. A controller returns raw domain data; a response interceptor wraps it as
{ success, data }. Declare that in Swagger with the envelope decorator — never by typing the wrapper into a DTO, which would produce two descriptions of one shape. - An invariant belongs on the write path, not on the DTO. A DTO is the edge: it makes a bad request fail fast over HTTP, and it never sees an importer, a background job or a tool. A rule that only lives there is not enforced — it is merely announced.
- A constant is declared once and imported. A bound written into a DTO, a service and a form drifts at the first change; this has already been paid for more than once.
- Another team's resource answers not-found, never forbidden. A 403 tells the caller the id exists; across tenants that is itself a leak. An id belonging to a team the caller is not a member of is indistinguishable from an id that never existed.
- A controller may not reach the data layer. No
*Gateway, no import fromdata/— a controller depends on a service, and the service on the gateway's interface. This one is not advice: the boundary check refuses it. - A tenant id is minted by authentication, and by nothing else. The type a tenant-scoped gateway accepts cannot be built out of a plain string, so "take the team from the request body" does not compile. That is a guard against haste, not against malice — a cast still defeats it — and haste is the one that actually happens.
- Specs live in a
tests/folder beside the code, at the slice root, indomain/and indata/, and only at those three levels. Co-location is still the rule; what changed is that a slice root no longer holds fourteen spec files between the module and the controller. Jest finds them at any depth.
The layering invariant
The single most important structural rule:
Dependencies point only downward across groups. A higher layer may depend on lower layers; a lower layer must never import a higher one.
When placing a slice, check it only depends downward. If a back-edge appears, invert it (define a port/interface, emit an event, or read via Prisma) rather than break the layer. See Layered slices.
What the gate actually checks
make check is lint + the boundary check + a build, across all four apps — api, app, admin and worker. Two things about it are worth knowing before you conclude that your change broke something:
- It compares what is installed against the lockfile. A dependency somebody added yesterday and you never installed used to be invisible here and surfaced a day later as a startup failure in somebody's terminal. Now it is named. A
depscomplaint on a change that does not touchpackage.jsonis your environment, not your diff — run the install. - Specs are excluded from the boundary check and from the build, by filename. So a green gate says nothing about whether the specs type-check; run
tscseparately when a change could reach them, such as a path rewrite.
The boundary check itself takes the group order from a cleanslice.json in each app rather than from the script, which is why one script serves api's eight groups and app's and admin's different sets.
MCP-first (project rule)
Before writing or modifying code, consult the CleanSlice MCP (get-started, search with 2+ queries, read-doc). This is strict.