Naalya Handbook

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.

the request/response path
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

LayerJobBuilds on
Entitymaps a table, defines columnsDatabaseEntity (UUID PK + soft-delete)
Repositorydata access, custom queries onlyBaseRepository<T> (create/findAll/findOne/update/remove)
Servicebusiness logic, permission scope, domain errorsinjected repositories
ControllerHTTP routes, validation, response shaping, Swaggerinjected service
DTO / typesrequest/response shapes + the entity's interfaceclass-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>/:

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 tests

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

apps/server/src/app/campus/campus.module.ts
@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).

apps/server/src/app/user/entities/user.entity.ts
@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.

  • profileEntityForType switch in UserService maps a UserType to its profile class.
  • Creating a typed user writes the UserEntity and its profile in one transaction — owned by the per-type controllers/services (StaffController, StaffService). This is why User splits per type.
  • platform_admin is 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.

apps/server/src/app/user/entities/guardian-x-student.entity.ts
@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 @Unique pair constraint + any data about the relationship.
  • Class names follow suit: linking students to classes → student-x-class, StudentClassController, StudentClassService.

Where to go next

On this page