Module Anatomy
The five-layer shape every resource module follows — Entity, Repository, Service, Controller, DTO — and the user-discriminator model.
Almost every feature is a resource built from the same five layers. Learn the shape once; every module reads identically. A request flows in at the top, data flows up from the bottom.
DTO ←→ Controller → Service → Repository → Entity → (database table)
↑
BaseRepository<T> (generic CRUD, from @app/shared)Logic only flows down one level: a controller never calls a repository; a service never reads the HTTP request. Reaching past a layer means the code belongs elsewhere.
The five layers
| Layer | Job | Builds on |
|---|---|---|
| Entity | maps a table, defines columns | DatabaseEntity (UUID PK + soft-delete) |
| Repository | data access, custom queries only | BaseRepository<T> (create/findAll/findOne/update/remove) |
| Service | business logic, permission scope, domain errors | injected repositories |
| Controller | HTTP routes, validation, response shaping, Swagger | injected service |
| DTO / types | request/response shapes + the entity's interface | class-validator, @ApiProperty |
Entity ↔ *.types.ts interface ↔ DTO is one triad defined once → Entities, Tables & Types. DTO decorator rules → API Conventions and DTOs & Validation.
Standard file layout
Every module lives under apps/server/src/app/<module>/:
<module>.module.ts # Nest module: wires entities, repos, services, controllers
<module>.controller.ts # route controller
<module>.service.ts # route service
entities/
<resource>.entity.ts # extends DatabaseEntity, implements the type
repositories/
<resource>.repository.ts # extends BaseRepository<Entity>
dto/
<module>.types.ts # the core interface(s) the entity implements
<resource>.dto.ts # response DTO
create-<resource>.dto.ts # input DTO
tests/
<something>.spec.ts # co-located unit testsThe <module>.module.ts registers the entity with TypeORM, lists repos/services as providers, declares controllers, and exports what other modules import. Add it to the root app.module.ts imports array.
@Module({
imports: [TypeOrmModule.forFeature([CampusEntity])],
controllers: [CampusController],
providers: [CampusService, CampusRepository],
exports: [CampusService, CampusRepository],
})
export class CampusModule {}Campus (apps/server/src/app/campus/) is the pattern with no extra moving parts — one of each layer. User (apps/server/src/app/user/) is the same five layers scaled out: controllers/services split per profile type under controllers/ and services/, DTOs grouped under dto/<type>/. Use User as the reference once a module grows past a single resource.
Entities are @TenantScoped() so queries auto-filter by school → The Database and Multi-Tenancy. Campus-operational entities additionally carry a required campusAcademicYearId — that scope is explicit, not automatic → Academic-Year Scoping.
User discriminator model
A single UserEntity (table user) holds everything common to all users — email, password hash, auth provider — plus a type discriminator enum (staff | student | guardian | guest | platform_admin).
@Entity('user')
@WithTimestamps()
export class UserEntity extends DatabaseEntity implements User {
@Column({ unique: true }) email: string;
@Column({ type: 'enum', enum: UserType }) type: UserType;
@Column({ name: 'password_hash', nullable: true }) passwordHash?: string;
@Column({ name: 'auth_provider', type: 'enum', enum: UserAuthProvider }) authProvider: UserAuthProvider; // LOCAL | MICROSOFT_365
@OneToOne('StaffProfileEntity', 'user', { lazy: true }) staffProfile?: Promise<StaffProfileEntity>;
@OneToOne('StudentProfileEntity', 'user', { lazy: true }) studentProfile?: Promise<StudentProfileEntity>;
// ...one per type
}Type-specific fields live on a per-type profile entity (StaffProfileEntity, StudentProfileEntity, GuardianProfileEntity, GuestProfileEntity) — a staff member's department, a student's class. Each profile joins the user one-to-one and lazy.
profileEntityForTypeswitch inUserServicemaps aUserTypeto its profile class.- Creating a typed user writes the
UserEntityand its profile in one transaction — owned by the per-type controllers/services (StaffController,StaffService). This is why User splits per type. platform_adminis the cross-tenant SaaS operator: minimal profile, no owning school, static abilities → Auth & Permissions.
Lazy relations are Promises — await them
staffProfile is typed Promise<StaffProfileEntity>. Reading user.staffProfile.firstName reads a property off a Promise; do const profile = await user.staffProfile first.
Join tables
Many-to-many links are explicit join entities named <primary>-x-<secondary> — table, controller, and service share that name so they group alphabetically. Examples: guardian-x-student, department-x-staff, class-x-teacher.
@Entity('guardian_x_student')
@Unique(['guardianId', 'studentId'])
@WithTimestamps()
export class GuardianXStudentEntity extends DatabaseEntity implements GuardianXStudent {
@Column({ name: 'guardian_id', type: 'uuid' }) guardianId: string;
@Column({ name: 'student_id', type: 'uuid' }) studentId: string;
@Column({ type: 'varchar', length: 20 }) relationship: string; // data ABOUT the link
@ManyToOne(() => GuardianProfileEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'guardian_id' }) guardian: GuardianProfileEntity;
@ManyToOne(() => StudentProfileEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'student_id' }) student: StudentProfileEntity;
}- Two FK columns + a
@Uniquepair constraint + any data about the relationship. - Class names follow suit: linking students to classes →
student-x-class,StudentClassController,StudentClassService.