Express + Prisma — house pattern
Manual Clean Architecture, not a framework's DI container: every layer is a plain TS interface, wired by hand in a factory function. Confirmed by reading real code in dream-book-api and simple-management-api (independent codebases, different Prisma majors, same controller/usecase/repository/factory shape) — the shell below is non-negotiable, do not invent a NestJS-style module system on top of it. The two repos diverge on where zod validation runs (see rule 4) — that's the one confirmed exception to "same shape", not an oversight.
Per-feature structure
src/
├── controllers/<domain>/<action>-controller.ts # implements Controller. Parses HttpRequest, calls the usecase, returns HttpResponse via helpers. Never imports Prisma.
├── usecases/<domain>/<action>-usecase.ts # Business logic. execute(...). Depends on the repository INTERFACE, injected via constructor.
├── interfaces/
│ ├── controllers/controller.ts # shared Controller contract
│ ├── http/http-request.ts, http-response.ts # shared request/response shape
│ └── repositories/<domain>-repository.ts # repository contract + domain types
├── repository/<domain>/<domain>-repository.ts # Prisma<Domain>Repository implements <Domain>Repository
└── main/factories/<domain>-factory.ts # make<Action>Controller(): wires repository → usecase → controller by hand
The shell (non-negotiable)
// interfaces/controllers/controller.ts
export interface Controller<T = any> {
handle(request: HttpRequest): Promise<HttpResponse<T>>;
}
// controllers/area/area-controllers.ts
export class ListAreasController implements Controller {
constructor(private readonly usecase: ListAreasUsecase) {}
async handle(_request: HttpRequest): Promise<HttpResponse<AreaComContagem[]>> {
return ok(await this.usecase.execute());
}
}
// main/factories/area-factory.ts — manual composition root, no DI container
const repository = new PrismaAreaRepository();
export const makeListAreasController = (): ListAreasController =>
new ListAreasController(new ListAreasUsecase(repository));
Response shaping always goes through the shared helpers (ok, badRequest, unauthorized, serverError), never a raw res.json() inside the controller. Every handle() wraps its body in try/catch → serverError(e) — an uncaught throw inside a controller is a bug.
The business-logic layer — one correct shape, one tolerated legacy shape
- UseCase class (
usecases/<domain>/<action>-usecase.ts,execute()method, repository INTERFACE injected via constructor) — the only shape for new code. Matchessimple-management-api'sListAreasUsecase. - Legacy service function (
services/<domain>/<domain>-service.ts) — exists in olderdream-book-apicode (e.g.alarm-service.ts), and it is not a lighter version of the same pattern: it imports the Prisma client directly, with no repository interface at all. This is debt, not a second accepted form — never model new code after it, and touching a neighboring line doesn't obligate rewriting it.
Non-negotiable rules
- Controller never imports Prisma. Not even the db client — if you're tempted, the logic belongs in the usecase or repository.
- Repository is always interface + implementation, in separate files (
interfaces/repositories/<x>.tscontract,repository/<x>/<x>-repository.tswithPrisma<X>Repository implements <X>Repository). No controller or usecase importsPrismaClient/the db client directly. - Wiring happens in
main/factories/, by hand. No DI container, no decorators — a plainmake<X>Controller()function thatnews the chain. - Validate with
zod(already a dependency in every repo on this stack) as the first thing that runs onrequest.body, before any business logic — confirmed in two different spots depending on the repo: in the controller before calling the usecase (simple-management-api'starefa-controllers.ts), or as the first step insideusecase.execute()(dream-book-api'sCreateDreamUsecase). Match whichever placement the project already uses — check an existing feature before picking one — never split validation across both layers for the same feature. Manualif (!field) return badRequest(...)chains are legacy, not the model to copy into new code. - Tenant/workspace isolation at the repository query — same bar as
security-baseline.md. Being Express instead of NestJS is not an exception.
Source of truth for the model
Same rule as nestjs-crud-pattern: exact fields come from the project's data model document. If it's not there, stop and ask before inventing fields.
Definition of done (per feature)
- Model in Prisma + migration applied
- Controller/usecase/repository/factory files created, wired through the factory
- Response goes through the shared http helpers, try/catch → serverError
- Validation via zod (not manual ad-hoc checks) as the first thing that runs, in whichever layer this project already validates at (controller or usecase — see rule 4)
- Lint and build clean
- Route registered in
main/routes/