apps/core-api/src/modules/<name>/ and is built from five files. Dependencies point one way:
Everything is a factory function that returns an object of functions. There are no classes and no decorators. The type of each layer is derived with
ReturnType:
Worked example: products
modules/products/ is a small, complete module. It manages the plans a studio sells (the Product model).
Schema
products.schema.ts holds one schema per input and exports the inferred type next to it.
- Query strings use
z.coercebecause Express gives strings. Bodies do not coerce. nullhas a meaning. Anullprice means the coach decides per trainee. It is not0(a free plan) and not absent, so that clearing a value on an existing plan can be expressed. The comment in the file explains this.
Repository
products.repository.ts is the only file in the module that imports Prisma types. Every read takes studioId.
byId uses findFirst with { id, studioId }, not findUnique with { id }. That is the tenancy check. update and remove take only id, and rely on the service having called get(studioId, id) first.
The include object is declared once with satisfies Prisma.ProductInclude, and a shape function turns _count.clients into clientCount. Response shaping that depends on the query belongs here.
Service
products.service.ts receives the repository and, optionally, another module’s service.
studioIdis always the first argument.- An onboarding or update form linked to a plan must exist in the same studio (
repo.formInStudio) and have the right type (assertAutomaticFormType). assignBulkloops over clients, catchesAppErrorper client and reports{ clientId, assigned, reason }, wherereasonis the error code. Any other error is rethrown. One trainee failing a rule does not fail the batch.
Controller
products.controller.ts is thin on purpose. Each handler parses, calls the service and responds.
studioIdOf is redefined at the top of each controller. It is the one place the studio is read, and it comes from req.auth, never from the body or the path.
Routes
products.routes.ts wires the layers and declares the route table.
/active-by-client is registered before /:id, otherwise Express would treat it as an id.
Mount
modules/index.ts mounts it on the web lane:
Variations you will meet
The five-file split is the default, not a law the code follows everywhere. Read the module before assuming.Cross module dependencies
When a service needs another module, it receives that module’s service or a single function as a constructor argument. It does not import the other module’s repository to run its own queries. Side effects that cross modules are passed as hooks.createProgramsService takes a PlanActivatedHook, createFormsService takes an onFormSent hook, createClientSubscriptionsService takes a subscriptionAssignedHook(ctx). The router that owns the wiring decides what the hook does. This keeps services testable with plain fakes and stops import cycles.
A notifier is a dependency like any other. createTraineeNotifier({ prisma, logger }) returns { send, notify }. The automation lane passes a silent implementation of the same shape when a request asks for no notification.