Skip to content

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.ts

Flow: Controller → IXxxGateway (abstract) → XxxGateway (impl) → Mapper → Prisma.

Rules

  • Gateway pattern — abstract IXxxGateway in domain/, concrete in data/. Prisma IS the repository — no *Repository classes.
  • Singular slice names; camelCase DTO files; I-prefix interfaces; Types-suffix enums; #alias imports; @scope/@slice/@layer/@type header tags.
  • Prisma models live at the slice root (<slice>.prisma), assembled by infra/prisma. Generate with bun run prisma:generate, which assembles first — a bare npx prisma generate exits 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 from data/ — 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, in domain/ and in data/, 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 appsapi, 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 deps complaint on a change that does not touch package.json is 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 tsc separately 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.