⚠️ Deprecated: no active Wibi backend uses NestJS — the 3 real backends (
dream-book-api,dream-book-api-agent,simple-management-api) are Express + Prisma in a manual Clean Architecture shape. Kept for historical reference, or in case a future project explicitly picks NestJS. For the real production stack, useexpress-prisma-pattern.
NestJS CRUD — house pattern
Every CRUD domain follows exactly the same shape. The project's first module is the mold; the rest copy its form. Do not invent variations.
Per-domain structure
src/modules/<domain>/
├── <domain>.controller.ts # HTTP only: receives request, returns response. Zero business logic.
├── <domain>.service.ts # Business logic. Depends on the repository ABSTRACTION, never on Prisma directly.
├── <domain>.repository.ts # Abstract class (contract) + Prisma implementation in the same file.
├── dto/
│ ├── create-<domain>.dto.ts
│ └── update-<domain>.dto.ts
└── <domain>.module.ts # Wiring: binds the contract to the implementation via provider token.
Cross-cutting concerns (PrismaService, guards, context decorators) live in src/shared/.
The repository contract (DIP in practice)
// <domain>.repository.ts
export abstract class TaskRepository {
abstract create(workspaceId: string, data: CreateTaskDto): Promise<Task>;
abstract findAll(workspaceId: string): Promise<Task[]>;
abstract findById(workspaceId: string, id: string): Promise<Task | null>;
abstract update(workspaceId: string, id: string, data: UpdateTaskDto): Promise<Task>;
abstract softDelete(workspaceId: string, id: string): Promise<void>;
}
@Injectable()
export class PrismaTaskRepository extends TaskRepository {
constructor(private readonly prisma: PrismaService) { super(); }
// ... implementation: EVERY query filters by workspaceId
}
// <domain>.module.ts
@Module({
controllers: [TaskController],
providers: [
TaskService,
{ provide: TaskRepository, useClass: PrismaTaskRepository },
],
})
export class TaskModule {}
The service injects TaskRepository (the contract). Swapping the implementation or mocking in tests becomes trivial.
Non-negotiable rules
workspaceIdalways comes from the authenticated context (JWT), never from the request body. If Auth doesn't exist yet, use a marker constant (FAKE_WORKSPACE_ID) with a// TODO: extract from JWTcomment — and register it as a visible pending item.- Tenant isolation enforced at the data layer: every repository query filters by
workspaceId. No exceptions, not even for "internal" queries. - Permissions validated on the server (guard/decorator), never trusted to the frontend.
- Dates always in UTC.
- DTOs with
class-validatorand a globalValidationPipeenabled inmain.ts(whitelist: true). - Delete is soft delete by default (
deletedAt), unless an explicit decision says otherwise.
SOLID, feet on the ground
- SRP: one responsibility per layer. The controller doesn't validate business rules; the service doesn't build SQL; the repository doesn't decide policy.
- DIP: service ↔ repository contract, as above.
- OCP/ISP without excess: do NOT create a generic
BaseRepository<T>, do NOT build hexagonal ports/adapters, do NOT abstract what isn't needed yet. Abstraction only where the cost pays off.
Source of truth for the model
The exact fields of each entity come from the project's data model document (UML/diagram in docs/). If the document doesn't exist or the entity isn't in it, stop and ask before inventing fields. Never run a production migration without checking the schema against the source of truth.
Definition of done (per domain)
- Model in Prisma + migration applied (table actually created)
- Complete module: POST, GET list, GET by id, PATCH, DELETE responding
-
workspaceIdfrom context, filtered queries, DTOs validating - Lint and build clean, project boots locally
- Registered in
AppModule - Documented: the work goes into the day's delivery log (see
git-workflowrule)