Skoolsewa - School Management System Docs
Developer ResourcesSubjects

Subjects Backend Documentation

Backend architecture, data model, services, cache, runtime rules, and operational behavior for the school's subject catalogue and its per-class teaching assignments.

Subjects Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/academic-structure/academic-structure.module.tsImports, providers, controller composition, why the subject surfaces are declared here and not in a module of their own.
Controllerssubjects/subjects.controller.ts, class-subjects/class-subjects.controller.tsRoute ownership, permission decorators, thin-controller boundaries.
Servicessubjects/subjects-read.service.ts, subjects/subjects-write.service.ts, class-subjects/class-subjects-read.service.ts, class-subjects/class-subjects-write.service.tsBusiness logic, validation order, transactions, cache, response mapping.
DTOsdto/subject.dto.ts, dto/class-subject.dto.tsRequest/response contracts and validation.
Schemapackages/db/src/schema/school/subjects.tsTables, enums, constraints, indexes, triggers.
Migrationspackages/db/src/migrations/0017_subject_module.sql, 0018_subject_permission_grants.sqlDDL history and the permission-grant backfill for an already-deployed database.
Cacheapps/api/src/common/utils/cache-key.util.ts, apps/api/src/services/redis/redis.service.tsKey construction, TTLs, fail-soft invalidation.
Authorizationapps/api/src/modules/people/shared/people-access.service.ts, apps/api/src/modules/mobile/shared/portal-audience.ts, packages/db/src/seed/seed-auth.ts, packages/db/src/authorization/permission-catalog.tsRow scope, portal-audience assertion, per-role grants, the module's place in the permission catalogue.

2. Backend Scope and Boundaries

Owns

  • The subjects table: the school-wide catalogue of what is taught, independent of any class.
  • The class_subjects table: one class teaching one subject, with its own teacher and its own effective mark scheme.
  • Admin CRUD on both tables (SubjectsController, ClassSubjectsController), gated by the Subjects and ClassSubjects permission modules.
  • Two audience-scoped consumer reads that sit in a different NestJS module (mobile/student, mobile/teacher) but consume this module's DTOs and tables directly: a pupil's and a teacher's own "my subjects" list.
  • The Redis cache for the subject catalogue (list, options, single-row reads).

Does Not Own

  • classes, grades, sections, rooms — owned by the rest of AcademicStructureModule; this surface only reads them, to join and to validate a classId at assignment time.
  • staff, designations, users — owned by PeopleModule; read-only, to validate and render a teacherId.
  • academic_sessions — owned by the school-profile/session module; read-only, to resolve "the current session" for the two consumer reads.
  • Row-level access scoping logic itself — PeopleAccessService in PeopleModule owns the scopeFor/applyScope mechanism this module calls.
  • Portal-audience assertion logic — portal-audience.ts in modules/mobile/shared owns assertPortalAudience/assertTeacherStaffRow.
  • Permission catalogue definition — packages/db/src/authorization/permission-catalog.ts is the single source for module/action names; this surface only consumes Subjects_*/ClassSubjects_* codes from it.

Source of Truth

ConcernSource of TruthNotes
A subject's identity and defaultssubjects tablepublicId is the only externally-addressable identifier; the integer id never leaves the process.
An assignment's effective configurationclass_subjects tabletype/maxMarks/passMarks are copied at assignment time — never re-derived from the parent subject on read.
Optimistic-concurrency tokensubjects.version / class_subjects.version, each server-owned and trigger-maintainedNever accepted or computed by the API; a client only ever echoes back the value it last read.
"Is this the current academic session"academic_sessions.is_currentConsulted by both consumer reads when no session id is supplied; this module does not own that table.
"May this actor see this row"PeopleAccessService.scopeFor(actor, "classSubjects", heldPermissions) for the two admin list reads; assertPortalAudience/assertTeacherStaffRow for the two consumer readsTwo different mechanisms for two different audiences — see 11.
Live per-request count of a subject's active assignmentsA correlated SQL subquery against class_subjects, computed on every read, never storedSubjectDto.classCount.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
AcademicStructureModuleAggregate/leaf hybridapps/api/src/modules/academic-structure/academic-structure.module.tsGradesController, SectionsController, RoomsController, ClassesController, ClassEnrollmentsController, SubjectsController, ClassSubjectsControllerAll eight services listed belowClassesReadServiceComposes grades/sections/rooms/classes/enrolments/subjects/class-subjects. Declared as the leaf so Swagger's ADMIN_MODULES.include (which does not recurse) sees the subject routes.
StudentPortalModuleLeafapps/api/src/modules/mobile/student/student-portal.module.tsStudentPortalController, StudentSubjectsPortalControllerStudentPortalService—Mounted at /api/mobile/student; its subjects controller reads subjects/class_subjects directly without importing anything from AcademicStructureModule.
TeacherPortalModuleLeafapps/api/src/modules/mobile/teacher/teacher-portal.module.tsTeacherPortalController, TeacherSubjectsPortalControllerTeacherPortalService—Mounted at /api/mobile/teacher; same pattern as the student portal.

AcademicStructureModule imports EnrollmentsCoreModule, PeopleModule, RoleModule and ActivityModule. The subject surfaces add no new import: ClassSubjectsReadService needs PeopleAccessService and PeoplePermissionsService, both of which already arrive through the existing PeopleModule import — so the subject controllers introduce no new edge to the module dependency graph.

4. File and Directory Map

apps/api/src/modules/academic-structure/
  academic-structure.module.ts
  subjects/
    subjects.controller.ts
    subjects-read.service.ts
    subjects-write.service.ts
  class-subjects/
    class-subjects.controller.ts
    class-subjects-read.service.ts
    class-subjects-write.service.ts
  dto/
    subject.dto.ts
    class-subject.dto.ts
    index.ts
  __tests__/
    subjects-read.service.integration.spec.ts
    subjects-write.service.integration.spec.ts
    class-subjects-read.service.integration.spec.ts
    class-subjects-write.service.integration.spec.ts
    class-subjects-authorization.integration.spec.ts

apps/api/src/modules/mobile/
  student/
    student-portal.controller.ts   (StudentSubjectsPortalController)
    student-portal.service.ts      (listMySubjects)
    student-portal.module.ts
  teacher/
    teacher-portal.controller.ts   (TeacherSubjectsPortalController)
    teacher-portal.service.ts      (listMySubjects)
    teacher-portal.module.ts
  shared/
    portal-audience.ts
  __tests__/
    portal-subjects.integration.spec.ts

packages/db/src/schema/school/subjects.ts
packages/db/src/migrations/0017_subject_module.sql
packages/db/src/migrations/0018_subject_permission_grants.sql
FilePurposeKey ExportsNotes
subjects/subjects.controller.tsAdmin CRUD routes for the subject catalogueSubjectsControlleroptions declared ahead of :publicId deliberately, for route-matching order.
subjects/subjects-read.service.tsList/options/single-row reads, cachingSubjectsReadService, SubjectRowloadDto is public so the write service can re-read inside its own open transaction.
subjects/subjects-write.service.tsCreate/update/delete, constraint translationSubjectsWriteServiceSplits pre-write application checks (reactivation collisions) from post-write constraint translation (name/code taken).
class-subjects/class-subjects.controller.tsAdmin routes for assignments, under three different path prefixesClassSubjectsControllerOne controller, @Controller() with no base path, because the swagger gate forbids one tag shared by two controllers.
class-subjects/class-subjects-read.service.tsPer-subject and per-class assignment lists, single-row loadClassSubjectsReadService, ClassSubjectRowApplies PeopleAccessService scope before every query is built.
class-subjects/class-subjects-write.service.tsBatch assign, PATCH, deleteClassSubjectsWriteService, ResolvedItem, ExistingRowassign() alone is over the repo's 500-line soft cap and is documented as split-exempt at the top of the file.
dto/subject.dto.tsSubject request/response/query shapesSubjectDto, SubjectOptionDto, SubjectOptionsPayloadDto, CreateSubjectDto, UpdateSubjectDto, ListSubjectsQueryDto, SubjectOptionsQueryDto, SUBJECT_TYPES, SubjectTypeField-level validators mirror the CHECK constraints in JavaScript regex spelling.
dto/class-subject.dto.tsAssignment request/response/query shapes, plus the two consumer DTOsClassSubjectDto, ClassSubjectClassDto, AssignSubjectItemDto, AssignSubjectToClassesDto, AssignmentResultDto, AssignmentItemErrorDto, UpdateClassSubjectDto, ListClassSubjectsQueryDto, MySubjectDto, MySubjectsPayloadDto, MySubjectsQueryDto, ASSIGNMENT_BATCH_MAXMySubjectDto is written field-by-field, deliberately not a Pick<> of ClassSubjectDto.
mobile/student/student-portal.controller.tsStudentSubjectsPortalController, a second controller class sharing @Controller("student")StudentSubjectsPortalControllerSplit out purely so its swagger tag does not concatenate with StudentPortalController's.
mobile/teacher/teacher-portal.controller.tsTeacherSubjectsPortalController, same patternTeacherSubjectsPortalControllerSame reasoning as the student side.
mobile/shared/portal-audience.tsAudience assertions for every consumer portal routeassertPortalAudience, assertTeacherStaffRow, assertStaffRow, PortalAudienceDeliberately does NOT delegate to PeopleAccessService.scopeFor — see 11.

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/subjects.ts
  subjectTypeEnum          -- "compulsory" | "optional"
  subjects                 -- the catalogue
  classSubjects             -- one class teaching one subject
  subjectsRelations
  classSubjectsRelations

5.2 Tables and Collections

subjects

Column/FieldTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNoauto-incrementPKreferenced by class_subjects.subject_idInternal only, never leaves the process.
public_iduuidNouuid7() (app-generated)subjects_public_id_unique (full unique)—The only externally-addressable identifier.
nametextNo—subjects_name_unique (partial, case-folded, WHERE is_active), subjects_name_format CHECK—Max 128 chars, no surrounding whitespace, no control characters.
codetextNo—subjects_code_unique (partial, WHERE is_active), subjects_code_format CHECK—^[A-Z0-9_]{1,16}$, case-sensitive (already restricted to one case, so no lower() expression index needed). Stable across a rename.
typesubject_type enumNo———compulsory or optional; the school's default for new assignments.
default_max_marksintegerNo100subjects_default_marks_bounded CHECK (with default_pass_marks)—1 <= value <= 1000.
default_pass_marksintegerNo40same CHECK—0 <= value <= default_max_marks.
is_activebooleanNotruesubjects_is_active_idx (btree)—Retirement flag; both unique indexes above are partial on this column.
versionbigint (mode number)No0never indexed—Optimistic-concurrency token, maintained ONLY by the subjects_bump_row_version trigger. Never written by the ORM or accepted from a client.
created_attimestamptzNonow()———
updated_attimestamptzNonow()——Owned by the same trigger that owns version — $onUpdateFn is deliberately absent from the Drizzle column definition, because a raw-SQL write would never trigger an ORM-level default while the database trigger fires unconditionally.

Money/marks units: plain integers, no currency, no fractional marks. Timezone: updated_at/created_at are timestamptz, stored and compared in UTC. Soft delete: none — this table hard-deletes.

class_subjects

Column/FieldTypeNullableDefaultIndex/ConstraintRelationNotes
iduuid (uuidv7 PK)Noapp-generatedPK—The uuid IS the public identifier — no separate public_id, matching student_class_enrollments.
class_idintegerNo—FK → classes.id, ON DELETE restrictmany-to-one classesRestrict, not cascade: a class with assignments cannot be deleted out from under them.
subject_idintegerNo—FK → subjects.id, ON DELETE restrictmany-to-one subjectsSame reasoning; this is also what makes Subjects_DELETE refuse a subject in use.
teacher_iduuidYesNULLFK → staff.id, ON DELETE set nullmany-to-one staffNullable because an admin often assigns a subject to five classes and fills teachers in afterwards. set null, not restrict, because staff soft-deletes and ON DELETE never fires on that path — the read side reports employmentStatus instead.
typesubject_type enumNo———The EFFECTIVE type for this class, copied from the subject at assignment time — not kept in sync afterward.
max_marksintegerNo—class_subjects_marks_bounded CHECK (with pass_marks)—1 <= value <= 1000.
pass_marksintegerNo—same CHECK—0 <= value <= max_marks.
is_activebooleanNotrueclass_subjects_class_active_idx (partial WHERE is_active)—Suspension flag — DELETE removes the row; this stops the class running the subject while keeping the configuration.
versionbigint (mode number)No0never indexed—Same trigger, same rules as subjects.version.
created_attimestamptzNonow()———
updated_attimestamptzNonow()——Owned by class_subjects_bump_row_version, same $onUpdateFn-absence reasoning as above.

No JSON columns on either table. No denormalized copy of academic_session_id on class_subjects — deliberately: the table's one invariant ("one assignment per class+subject") does not span the session join the way student_class_enrollments does, so there is nothing a copy would need to keep true.

Index rationale

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
subjects_name_uniquelower(name)unique, partial WHERE is_activeNo two ACTIVE subjects share a name, case-insensitively; a retired name is reusableWrite overhead on every insert/update touching name; the expression form means Postgres cannot use it for a plain equality lookup on the raw column
subjects_code_uniquecodeunique, partial WHERE is_activeNo two ACTIVE subjects share a codeSame as above, no case-folding needed since code is already restricted to one case
subjects_is_active_idxis_activebtreeThe catalogue's own active/archived filterSmall write overhead; the column has low cardinality so this mainly helps the partial-index planner, not a standalone filter scan
class_subjects_identity_uniqueclass_id, subject_idunique, NOT partialPrevents two rows for one (class, subject) pair existing concurrently, in ANY state — what makes the assign endpoint's upsert target well-definedA suspended row occupies the identity slot until deleted or revived; a genuinely-new assignment for the same pair must revive it
class_subjects_class_active_idxclass_idbtree, partial WHERE is_activeThe class detail view's "what does this class currently teach" scanExcludes suspended rows by design — a separate full scan is needed for history
class_subjects_subject_idxsubject_idbtree, NOT partialBoth the subject detail's assigned-classes list and SubjectDto.classCount's underlying count-all pattern (ON DELETE restrict also counts every row)Slightly larger index than a partial one would be, traded for correctness of the delete pre-check
class_subjects_teacher_idxteacher_idbtree, partial WHERE teacher_id IS NOT NULLThe teacher portal's "my subjects" read (WHERE teacher_id = :staffId)Skips the common case of an unassigned teacher slot

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 SubjectsReadService

MethodCalled ByReadsWritesSide EffectsErrors
findAll(query)SubjectsController.findAllsubjects filtered/sorted, correlated class_subjects count—Cache read/write (getSoft/setSoft, 300s)None thrown
findOptions(query)SubjectsController.findOptionssubjects, active-only by default—None (deliberately uncached — a bounded reference-table scan is already cheap)None thrown
findOne(publicId)SubjectsController.findOnesubjects by publicId, correlated count—NoneSUBJECT_NOT_FOUND
loadDto(executor, publicId)findOne, and SubjectsWriteService after every writeSame as above, but against the caller's DbExecutor (pooled connection or open transaction)—NoneSUBJECT_NOT_FOUND

Input normalization: search terms are escaped with escapeLikePattern before being wrapped in %...% for ILIKE, so a literal % or _ in a subject name cannot turn a filter into an unintended wildcard. Validation order: none at this layer — validation is the DTO pipe's job; this service only reads. Transaction boundaries: none — every read here is a single statement or a single correlated query; loadDto accepts an executor so a caller inside a transaction can pass its tx instead of racing a second pooled connection against an uncommitted write. Idempotency: reads are naturally idempotent. Response mapping: toDto is field-by-field, never a row spread — an added column on subjects cannot leak into the response by accident. Fail-open/fail-closed: cache failures fail OPEN to the database (a miss, never an error); a genuinely missing row fails CLOSED with 404. Logger usage: none directly — Redis failures are logged inside RedisCacheService itself.

6.2 SubjectsWriteService

MethodCalled ByReadsWritesSide EffectsErrors
create(dto)SubjectsController.createnone before insert (uniqueness is index-enforced)subjects (insert)Cache prefix invalidatedSUBJECT_MARKS_INVALID, SUBJECT_NAME_TAKEN, SUBJECT_CODE_TAKEN, SUBJECT_NAME_INVALID, SUBJECT_CODE_INVALID (backstop), WRITE_RETURNED_NO_ROW
update(publicId, dto)SubjectsController.updatesubjects (locked inside tx), reactivation-collision pre-checkssubjects (update, version-gated)Cache prefix invalidatedSUBJECT_NOT_FOUND, SUBJECT_MARKS_INVALID, SUBJECT_NAME_TAKEN_ON_REACTIVATE, SUBJECT_CODE_TAKEN_ON_REACTIVATE, SUBJECT_VERSION_CONFLICT
remove(publicId)SubjectsController.removesubjects (existence), class_subjects (total/active counts)subjects (delete)Cache prefix invalidatedSUBJECT_NOT_FOUND, SUBJECT_IN_USE

Input normalization: name is trimmed server-side on both create and update, independent of the DTO's own no-surrounding-whitespace validator (belt and braces — the validator refuses a padded value before it reaches the service at all, but the service still trims defensively). Validation order: "not found" is checked first (no constraint could turn an unknown publicId into anything better than a zero-row update); "already taken" is left to the database's partial unique indexes and translated after the fact, which is what makes that check atomic under a race; reactivation collisions are checked application-side, pre-write, because the partial indexes cannot see them yet at check time. Transaction boundaries: create and update each run inside this.db.transaction(...); remove does not need one — its own pre-check and delete are each single statements, and a TOCTOU race is still caught by the foreign key at delete time. Idempotency: none claimed — a second identical create produces a second row (subject to the uniqueness constraint) or a conflict. Retry behavior: none built in; a 409 is the caller's signal to reload and retry with a fresh version. Response mapping: after every write, the service re-reads the row through SubjectsReadService.loadDto, passing its own transaction handle so the response reflects the just-written, not-yet-committed state. Fail-open/fail-closed: writes fail closed — any constraint violation or business-rule failure aborts the transaction. Logger usage: none directly.

6.3 ClassSubjectsReadService

MethodCalled ByReadsWritesSide EffectsErrors
findForSubject(actor, subjectPublicId, query)ClassSubjectsController.findForSubjectsubjects (resolve), row-scope predicate, class_subjects joined to classes/grades/sections/staff/users/designations—NoneSUBJECT_NOT_FOUND, PAGINATION_LIMIT_INVALID
findForClass(actor, classPublicId, query)ClassSubjectsController.findForClassclasses resolved THROUGH the scope predicate, then the same joined class_subjects query—NoneCLASS_NOT_FOUND, PAGINATION_LIMIT_INVALID
loadDto(executor, id)Controller callers via the write service, and internallySame joined query, single row—NoneCLASS_SUBJECT_NOT_FOUND

Input normalization: none beyond what the DTO pipe already did. Validation order: assertPaginated runs FIRST in both list methods, before any query is built — a pagination-disabled request never reaches the database. Transaction boundaries: none of its own; loadDto accepts an executor so the write service can call it from inside an open transaction. Idempotency: reads are naturally idempotent. Retry behavior: none. Response mapping: toDto/toClassDto are both field-by-field; teacher maps to null when teacherId is absent, never an empty object. Fail-open/fail-closed: row scope fails CLOSED — default and every unhandled scopeKind in PeopleAccessService.scopeFor resolve to selfOnlyScope, which for classSubjects is sql\false`(no rows), notall`. Logger usage: none.

6.4 ClassSubjectsWriteService

MethodCalled ByReadsWritesSide EffectsErrors
assign(actor, subjectPublicId, items)ClassSubjectsController.assignsubjects, classes (batch), staff+designations (batch), class_subjects (label collisions, existing rows for the identity pair)class_subjects (batch insert-or-update)Cache prefix invalidated after commitSUBJECT_NOT_FOUND, SUBJECT_INACTIVE, CLASS_SUBJECT_BATCH_INVALID (carrying per-item errors[]), WRITE_RETURNED_NO_ROW
update(id, dto)ClassSubjectsController.updateclass_subjects (version-gated locked read), subjects (revival precondition), sibling class_subjects (label collision on revival)class_subjects (update)Cache prefix invalidatedCLASS_SUBJECT_NOT_FOUND, CLASS_SUBJECT_VERSION_CONFLICT, CLASS_SUBJECT_TEACHER_NOT_FOUND, CLASS_SUBJECT_TEACHER_NOT_TEACHING, SUBJECT_INACTIVE, CLASS_SUBJECT_DUPLICATE_LABEL, CLASS_SUBJECT_MARKS_INVALID
remove(id)ClassSubjectsController.removeclass_subjects (existence)class_subjects (delete)Cache prefix invalidatedCLASS_SUBJECT_NOT_FOUND

Input normalization: Object.hasOwn is used throughout assign/update to distinguish an ABSENT field (keep existing value) from an explicit null (clear the teacher) — a plain dto.teacherId !== undefined check cannot make that distinction because undefined covers both "not sent" and, in principle, "sent as null and then coerced", so the code deliberately uses the stricter own-property check. Validation order in assign, ten numbered steps: (1) resolve the subject, refuse if missing/inactive; (2-4) resolve every class in one query, collect not-found/inactive/duplicate-in-batch; (5) resolve every teacher in one query, collect not-found/not-teaching; (5 cont./7) default absent type/marks from the subject, validate the cross-column marks bound per item; (6) load label collisions and existing rows for version/suspension decisions; (8) if ANY item failed, throw with the full error list — nothing written; (9) upsert every surviving item; (10) invalidate the cache after commit. Transaction boundaries: assign and update both run inside this.db.transaction(...); remove does not need one. Idempotency: re-sending the identical batch body is idempotent (every field already matches, the SET clause becomes a no-op that still fires the version trigger); re-sending a SUBSET is a partial edit by design, not an idempotency violation. Retry behavior: none built in beyond the caller reloading and resubmitting with fresh versions. Response mapping: assign returns { assigned: ClassSubjectDto[], skippedSuspended: string[] }; every other method returns a single ClassSubjectDto or nothing (remove). Fail-open/fail-closed: validation collects rather than short-circuits inside assign, but the WRITE itself is fail-closed — any single collected error blocks the entire batch. Logger usage: none directly.

7. Runtime Flows

7.1 Assign a subject to classes (the module's most complex flow)

StepCode PathBehaviorFailure Case
1ClassSubjectsController.assignReceives the request, delegates with the caller's AuthUser.DTO validation error → 400.
2ClassSubjectsWriteService.assign, step 1Resolves the subject; refuses if missing or archived.SUBJECT_NOT_FOUND / SUBJECT_INACTIVE.
3steps 2-5Resolves classes and teachers in two batched queries, collecting per-item errors rather than throwing on the first.Various item-level codes, collected.
4step 6Loads label collisions and any existing row per surviving class, to decide version conflicts and suspension.CLASS_SUBJECT_DUPLICATE_LABEL, CLASS_SUBJECT_VERSION_CONFLICT, collected.
5step 8If anything was collected, throws with the full list. Nothing has been written yet.422 CLASS_SUBJECT_BATCH_INVALID.
6step 9Upserts every surviving item on the identity index; a suspended row not asked to revive is skipped and reported.WRITE_RETURNED_NO_ROW on an impossible zero-row RETURNING.
7step 10Commits, then invalidates the subjects cache prefix (fail-soft).Redis failure logged, never surfaced to the caller.

7.2 Update a subject (optimistic concurrency, reactivation)

StepCode PathBehaviorFailure Case
1SubjectsWriteService.updateOpens a transaction, reads the existing row.SUBJECT_NOT_FOUND.
2sameMerges the patch onto the row for the marks cross-check.SUBJECT_MARKS_INVALID.
3sameIf reactivating, checks name and code against every OTHER active subject.SUBJECT_NAME_TAKEN_ON_REACTIVATE / SUBJECT_CODE_TAKEN_ON_REACTIVATE.
4sameRuns the version-gated UPDATE.Zero rows → re-read to tell gone from stale.
5sameRe-reads the just-written row inside the same transaction and returns it.—

7.3 Pupil reads their own subjects

Success path, validation failures, permission failures, missing entity, duplicate request, cache miss/hit, async enqueue, race/concurrency, guest vs logged-in — covered per flow:

  • Success path: as diagrammed above for each of the three write/read families.
  • Validation failures: caught by the global ValidationPipe before any handler runs — malformed publicId (not a UUID for class-subjects/:id), out-of-range marks, bad enum values.
  • Permission failures: RoleGuard refuses before the controller method body runs, for every admin route; the two consumer routes are refused inside the service by assertPortalAudience/assertTeacherStaffRow instead, since they carry no @Permissions().
  • Missing entity: every read/write that resolves a publicId or id throws the entity's own _NOT_FOUND code first.
  • Duplicate request: the assignment upsert is the only place a duplicate submission is meaningfully idempotent — see 6.4.
  • Cache hit/miss: only SubjectsReadService.findAll is cached; every other read in this module is always live.
  • Async enqueue: not applicable — no queue exists on this module.
  • Race/concurrency: version columns plus the identity unique index are the two mechanisms; see 12.3 in the feature doc.
  • Guest vs logged-in: no guest branch exists anywhere in this module; the global JwtAuthGuard refuses every unauthenticated request before any handler runs.

8. Caching

Cache Key PatternBuilderValueTTLInvalidationCaller
academic-structure:subjects:list:q:<term-or-x>-type:<type-or-x>-active:<bool-or-x>-pagination:<0/1>-page:<n>-size:<n>CacheKeyUtil.build("academic-structure:subjects:list:", [...]){ data: SubjectDto[], totalCount: number }300s (SubjectsReadService.CACHE_TTL_SECONDS)Any write on subjects or class_subjects calls delPatternSoft("academic-structure:subjects:*")SubjectsReadService.findAll

findOptions and findOne/loadDto are deliberately NOT cached — findOptions is a bounded scan of a reference-sized table, cheap enough on its own; single-row reads are called from inside the write path's own transaction and would need to be invalidated by exactly the same writes anyway.

Deterministic segment order: CacheKeyUtil.build takes an ORDERED tuple list, so the same logical query always produces the same key string — q, type, active, pagination, page, size, in that fixed order. Cache hit behavior: returned as-is, no re-validation against the database. Cache miss behavior: full query runs, result is written back with a 300s TTL. Serialization shape: JSON.stringify/JSON.parse inside RedisCacheService.getSoft/setSoft — Date fields round-trip as ISO strings, which the response DTO's @ApiProperty() readonly createdAt!: Date typing does not itself enforce at runtime. Invalidation on mutations: EVERY writer on either table calls delPatternSoft("academic-structure:subjects:*") — SubjectsWriteService.create/update/remove AND all three ClassSubjectsWriteService methods, because SubjectDto.classCount is a live count over class_subjects and every one of those writers moves it. Failure handling: fail-soft in both directions — a cache-read failure is treated as a miss (never surfaced), a cache-write or invalidation failure is logged as a WARN and otherwise swallowed, so a Redis outage degrades this module to "every read hits Postgres", never to an error.

9. BullMQ, Schedulers, and Async Work

Not applicable. No queue, job, or scheduled task reads or writes subjects or class_subjects.

10. Realtime and Events

Not applicable. Neither table participates in the realtime/outbox event contract (modules/realtime/shared/realtime.contract.ts); the only event that surface currently carries is feedback.submitted, unrelated to this module.

11. Security, Auth, and Abuse Controls

  • Guards: JwtAuthGuard (global) and RoleGuard on every admin route in SubjectsController/ClassSubjectsController. The two consumer routes carry JwtAuthGuard + RoleGuard as well, but declare NO @Permissions() — RoleGuard returns true for a handler with no declared permission that sits on the NO_PERMISSION_ADMIN_HANDLERS allowlist, so the actual boundary for those two routes is entirely inside the service.
  • Permissions: Subjects_READ/_CREATE/_UPDATE/_DELETE/_RESTORE and ClassSubjects_READ/_CREATE/_UPDATE/_DELETE/_RESTORE — five actions each, generated automatically by buildPermissionCatalog() over PERMISSION_MODULES. _RESTORE on both is unreachable: neither table has a soft-delete column, and no controller declares a restore route.
  • Guest identity: none — no route in this module accepts an unauthenticated caller.
  • Admin identity: req.user plus activeRole, resolved by JwtAuthGuard/RoleGuard; superadmin bypasses every permission and scope check via actor.activeRole?.isSuperadmin.
  • Rate limits: none declared specifically for this module.
  • Anti-abuse rules: the 1-100 item cap on AssignSubjectToClassesDto.items (ASSIGNMENT_BATCH_MAX) bounds the size of a single write, matching the admin grid's own page size so the over-size case is unreachable from the panel and needs no dedicated error code — the global pipe's 400 covers it.
  • Input normalization: server-side name trimming on create/update; regex validators on name/code mirror the database CHECK constraints in JavaScript escape-sequence spelling (the two are deliberately NOT identical spellings — \s inside a Drizzle sql template literal does not survive, so the constraint uses POSIX character classes while the DTO uses standard JS regex).
  • Sensitive data redaction: ClassTeacherDto (embedded in ClassSubjectDto.teacher) carries an employee code and employment status — this is exactly why ClassSubjects_READ is withheld from pupils and guardians, and why the pupil/teacher consumer DTO (MySubjectDto) is written field-by-field rather than reusing or Pick<>-ing the admin shape.
  • Audit logs: every mutating admin route is recorded automatically by the global ActivityAuditInterceptor, keyed off the handler's @Permissions() code (Subjects_CREATE → module Subjects, action CREATE). No service in this module calls the audit recorder directly — nothing here needs per-field changes detail beyond what the interceptor already captures.
  • Fail-closed behavior: PeopleAccessService.isVisible THROWS for the "classSubjects" entity rather than returning true or falling through a default arm — deliberately, so that if a future single-row check is ever wired up against this entity by mistake, it fails loudly (an unhandled error) instead of silently granting access. The two list reads never call isVisible; they apply scopeFor + applyScope directly to the list query, which is the actual and only boundary for class_subjects reads today.
  • The Parent Portal incident and its fix, restated for this module: PeopleAccessService.scopeFor's "role decides scope before permission does" ordering exists specifically because the seeded staff/teacher roles hold BOTH Students_READ (scope all) AND ClassSubjects_READ. Were the ordering reversed, holding the permission would return {kind:"all"} unconditionally, defeating the whole point of scoping. The two consumer "my subjects" routes go further and never touch scopeFor at all — they build their own predicate from assertPortalAudience/assertTeacherStaffRow plus an explicit WHERE, closing the specific hole where a broadly-scoped staff session reading a route literally named /student/subjects would otherwise see every pupil's schedule.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
SUBJECT_NOT_FOUND404SubjectsReadService.loadDto, SubjectsWriteService.update/remove, ClassSubjectsReadService.resolveSubject, ClassSubjectsWriteService.assignNo subjects row with that publicId.Re-fetch the list; the id is stale or was never valid.
SUBJECT_NAME_TAKEN409SubjectsWriteService.translate (from subjects_name_unique)An active subject already has this name, case-insensitively.Choose a different name, or reactivate/edit the existing one.
SUBJECT_CODE_TAKEN409Same, from subjects_code_uniqueAn active subject already has this code.Choose a different code.
SUBJECT_NAME_TAKEN_ON_REACTIVATE409SubjectsWriteService.update (application pre-check)Reactivating a subject whose (merged) name now belongs to a DIFFERENT active subject.Rename before reactivating, or archive the holder first. Message names the holder's code.
SUBJECT_CODE_TAKEN_ON_REACTIVATE409Same, for codeAs above, for code.As above.
SUBJECT_NAME_INVALID409SubjectsWriteService.translate (from subjects_name_format)Backstop only — the DTO's regex validators refuse the same input as a 400 first.Fix the name; a consumer should map both statuses to the same UI message.
SUBJECT_CODE_INVALID409Same, from subjects_code_formatBackstop, as above.As above.
SUBJECT_MARKS_INVALID409SubjectsWriteService.create/update (application check), translate (CHECK backstop)passMarks > maxMarks, evaluated against the merged/final values. Genuinely reachable — no class-validator decorator can compare two fields of one object on a partial update.Lower passMarks or raise maxMarks.
SUBJECT_IN_USE409SubjectsWriteService.remove (pre-check), translate (FK backstop)At least one class_subjects row references this subject, active or suspended. Message names both the total and the active count.Remove or reassign every dependent assignment first, or retire instead of deleting.
SUBJECT_VERSION_CONFLICT409SubjectsWriteService.updateThe supplied version no longer matches the row's current version.Reload the row and retry with the fresh version.
SUBJECT_INACTIVE422ClassSubjectsWriteService.assign, .update (revival branch)Assigning a retired subject to a class, or reviving an assignment whose subject has since been archived.Reactivate the subject first, or choose a different one.
CLASS_SUBJECT_NOT_FOUND404ClassSubjectsReadService.loadDto, ClassSubjectsWriteService.update/removeNo class_subjects row with that id.Re-fetch the assignment list.
CLASS_SUBJECT_MARKS_INVALID409ClassSubjectsWriteService.update (application check), translate (CHECK backstop)passMarks > maxMarks on the assignment, post-defaulting. Attributed to the specific grid row.Fix the marks on that assignment.
CLASS_SUBJECT_VERSION_CONFLICT409ClassSubjectsWriteService.update, item-level inside assignOptimistic concurrency lost on one assignment.Re-fetch that one row, not the whole grid, and retry.
CLASS_SUBJECT_TEACHER_NOT_FOUND404 (item-level 404-shaped code inside assign's 422; standalone 404 from update)ClassSubjectsWriteService.assign (item-level), .update, translate (FK backstop)The teacherId does not resolve, or resolves to a soft-deleted staff row.Pick a different, live staff member.
CLASS_SUBJECT_TEACHER_NOT_TEACHING422 (item-level inside assign's 422; standalone from update)Same two methodsThe chosen staff member's designation has is_teaching = false.Pick a staff member whose designation is a teaching one.
CLASS_SUBJECT_BATCH_INVALID400 (empty batch) / 422 (item failures)ClassSubjectsWriteService.assignZero items, or one or more items failed validation. The 422 form carries a per-index errors[].Fix every named index; nothing was written.
CLASS_SUBJECT_DUPLICATE_CLASSitem-level, inside 422 CLASS_SUBJECT_BATCH_INVALIDClassSubjectsWriteService.assignThe same class appears twice in one batch, deduped on the resolved integer id.Remove the duplicate item.
CLASS_SUBJECT_DUPLICATE_LABELitem-level 422 (inside assign) / standalone 409 (from update's revival branch)Both write methodsThe class already actively teaches a DIFFERENT subject row sharing this name or code — possible because subject name/code uniqueness is partial on is_active. No database constraint can express this; it is a service-level guard only.Rename or retire the colliding subject first, or do not assign/revive this one to that class.
CLASS_SUBJECT_IN_USE409ClassSubjectsWriteService.translate — registered but currently unreachableWould fire if a future table referenced class_subjects and its FK were violated on delete. Nothing does today.N/A today; documented so the first dependent table lands as a named refusal, not an unmapped 500.
WRITE_RETURNED_NO_ROW500SubjectsWriteService.create, ClassSubjectsWriteService.assignA RETURNING clause that must always yield a row on success returned none — refused explicitly rather than asserted with !, which would be a TypeError carrying no errorCode.Not user-actionable; indicates a driver/database anomaly worth investigating server-side.
PAGINATION_LIMIT_INVALID400ClassSubjectsReadService.assertPaginated, StudentPortalService.listOwnEnrollments/TeacherPortalService.refuseUnpaginated (shared pattern, not this module's own throw site for the subjects routes)pagination=false requested on a route that embeds staff identity.Page through the result instead.
CLASS_NOT_FOUND404ClassSubjectsReadService.findForClassThe classPublicId does not resolve within the caller's scope — indistinguishable from a genuinely absent class.Re-fetch the class list; the id is stale, invalid, or out of scope.
PERMISSION_INSUFFICIENT403RoleGuard (admin routes), assertPortalAudience/assertTeacherStaffRow (consumer routes)Caller lacks the required permission code, or is acting as the wrong portal audience.Switch to a role that holds the required grant, or use the correct portal route for that audience.
AUTH_ACTIVE_ROLE_REQUIRED403RoleGuard, assertActiveRole (portal audience)Multi-role session with no active role chosen.Select an active role before retrying.
PERMISSION_ROLE_NOT_ASSIGNED403assertActiveRoleSession holds zero roles at all.Not user-fixable client-side; an administrator must assign a role.

14. Observability

SignalLocationPurpose
LogRedisCacheService WARN lines on any getSoft/setSoft/delPatternSoft failureThe only channel a fail-soft cache failure has — no exception surfaces to the caller or to any gate.
AuditMongoDB audit_logs, written by the global ActivityAuditInterceptorEvery mutating request on the admin routes — actor, module (Subjects/ClassSubjects), action, outcome, status code, duration. Consumer "my subjects" reads are not mutations and are not audited.
MetricNone named specifically for this moduleRelies on whatever process-wide metrics the platform already emits (request duration, status code counts).

15. Testing and Validation

Test TypeFilesCoverage
Integrationapps/api/src/modules/academic-structure/__tests__/subjects-read.service.integration.spec.tsList/search/filter, pagination, cache behavior, options, single-row read, classCount correctness.
Integrationapps/api/src/modules/academic-structure/__tests__/subjects-write.service.integration.spec.tsCreate, update (including reactivation collisions and version conflicts), delete (including in-use refusal), marks validation.
Integrationapps/api/src/modules/academic-structure/__tests__/class-subjects-read.service.integration.spec.tsPer-subject and per-class assignment listing, pagination refusal, scope application.
Integrationapps/api/src/modules/academic-structure/__tests__/class-subjects-write.service.integration.spec.tsBatch assign (insert, upsert, suspension skip/revive, label collisions, version conflicts, teacher resolution), update, delete.
Integrationapps/api/src/modules/academic-structure/__tests__/class-subjects-authorization.integration.spec.tsRow-scope enforcement for ClassSubjects_READ across roles.
Integrationapps/api/src/modules/mobile/__tests__/portal-subjects.integration.spec.tsThe two consumer "my subjects" reads, audience assertion, session resolution, empty-state behavior.

All of the above connect to the real test database via TEST_DATABASE_URL and run the actual Drizzle migrations — no mock of DatabaseModule, Redis, or any @skoolsewa/* package, per this repo's testing rule.

Validation commands:

pnpm --filter @skoolsewa/api test -- academic-structure/__tests__/subjects
pnpm --filter @skoolsewa/api test -- academic-structure/__tests__/class-subjects
pnpm --filter @skoolsewa/api test -- mobile/__tests__/portal-subjects
pnpm --filter @skoolsewa/api test:structure

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
SubjectsControllerControllerAdmin route surface for subjectsSubjectsReadService, SubjectsWriteServiceHTTPBoth servicesNone directlyDTO validation 400 before reaching the handler
SubjectsReadServiceServiceList/options/single-row reads, subjects cacheDATABASE, RedisCacheServiceController, SubjectsWriteServicePostgres, Redissubjects (read)SUBJECT_NOT_FOUND
SubjectsWriteServiceServiceCreate/update/delete, constraint translationDATABASE, RedisCacheService, SubjectsReadServiceControllerPostgres, Redis, SubjectsReadService.loadDtosubjects (write)Every SUBJECT_* code above
ClassSubjectsControllerControllerAdmin route surface for class_subjects, under three path prefixesClassSubjectsReadService, ClassSubjectsWriteServiceHTTPBoth servicesNone directlyDTO validation 400
ClassSubjectsReadServiceServicePer-subject/per-class assignment lists, single-row load, row scopingDATABASE, PeopleAccessService, PeoplePermissionsServiceController, ClassSubjectsWriteServicePostgresclass_subjects (read)SUBJECT_NOT_FOUND, CLASS_NOT_FOUND, CLASS_SUBJECT_NOT_FOUND, PAGINATION_LIMIT_INVALID
ClassSubjectsWriteServiceServiceBatch assign, PATCH, deleteDATABASE, RedisCacheService, ClassSubjectsReadServiceControllerPostgres, Redis, ClassSubjectsReadService.loadDtoclass_subjects (write)Every CLASS_SUBJECT_* code plus SUBJECT_NOT_FOUND/SUBJECT_INACTIVE/WRITE_RETURNED_NO_ROW
StudentSubjectsPortalControllerController (mobile)/api/mobile/student/subjects route onlyStudentPortalServiceHTTPServiceNoneNone — delegates entirely
StudentPortalService.listMySubjectsService methodA pupil's own-subjects read and session resolutionDATABASEControllerPostgresclass_subjects (read, via enrolment subquery)Empty result on no session/no enrolment (not an error)
TeacherSubjectsPortalControllerController (mobile)/api/mobile/teacher/subjects route onlyTeacherPortalServiceHTTPServiceNoneNone
TeacherPortalService.listMySubjectsService methodA teacher's own-subjects read and session resolutionDATABASEControllerPostgresclass_subjects (read, filtered on own teacherId)Empty result on no session/no assignment (not an error)
assertPortalAudienceHelper functionRefusing a session not acting as the named scopeKindAuthUser shapeStudentPortalService, both assertTeacherStaffRow-adjacent flowsThrowsNonePERMISSION_INSUFFICIENT, AUTH_ACTIVE_ROLE_REQUIRED, PERMISSION_ROLE_NOT_ASSIGNED
assertTeacherStaffRowHelper functionResolving the caller's own live staff.id, refusing any session not acting as the role named teacherDATABASETeacherPortalService (both methods, including subjects)Postgres (staff lookup)NonePERMISSION_INSUFFICIENT

16.2 UML and Architecture Diagram Pack

No BullMQ, no Socket.IO gateway, and no external API call anywhere in this module — the deployment/runtime diagram above is deliberately short.

16.3 Code Flow Narrative

See 7.1 for assign()'s full fifteen-point narrative (entry point through logs/tradeoffs) and 7.2 for update()'s. Both are also documented at length in the source files' own docblocks, which this page's tables draw from directly rather than restating differently.

16.4 Data Layer Deep Dive

Covered in full in 5.2 (field tables, business meaning, nullability, validation source, indexes, constraints, FK delete behavior, versioning, timezone) and the Index rationale table immediately after it. Neither table has JSON columns, money units, soft-delete fields, or seed-data dependencies — both are pure operational tables populated entirely through the API.

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
No two active subjects share a name (case-insensitive)subjects_name_unique, partial indexA select box with two identical labels is unusable and unreportableSUBJECT_NAME_TAKENsubjects-write.service.integration.spec.ts
No two active subjects share a codesubjects_code_unique, partial indexCode is the stable machine identity every export/report keys onSUBJECT_CODE_TAKENsame
passMarks <= maxMarks, both tablesCHECK constraints + service pre-checksAn assessment scheme where the pass bar exceeds the ceiling is meaninglessSUBJECT_MARKS_INVALID / CLASS_SUBJECT_MARKS_INVALIDboth write-service specs
At most one class_subjects row per (class, subject), in any stateclass_subjects_identity_unique, non-partialHistory (marks, attendance, timetable) must resolve to exactly one rowUpsert makes a second insert impossible; a genuine 23505 would surface as an unmapped conflict, never reached in practice because the write path always upsertsclass-subjects-write.service.integration.spec.ts
A subject cannot be hard-deleted while any assignment references itON DELETE restrict FK, pre-checked in the serviceDeleting a subject out from under a class's recorded marks would orphan historySUBJECT_IN_USEsubjects-write.service.integration.spec.ts
Reactivation cannot silently collide with a relabeled active rowApplication-only pre-write check (the partial index cannot see it)The partial-unique-index retirement pattern would otherwise move its dead end from create to reactivateSUBJECT_NAME_TAKEN_ON_REACTIVATE / _CODE_TAKEN_ON_REACTIVATEsame
ClassSubjects_READ never widens to pupils/guardians via a portal role's all scopePositive-grant switch in PeopleAccessService.scopeFor, no fallthroughThe Parent Portal incident this ordering was written to preventRow scope resolves to sql\false`for these roles onclassSubjects`class-subjects-authorization.integration.spec.ts
Cache invalidation is complete across every writer that moves classCountAll four write-service methods that touch class_subjects, plus all three on subjects, call the same delPatternSoftA one-directional invalidation would leave the subjects list serving a stale count after any assignment writeFail-soft; a missed invalidation only shows up as staleness within the 300s TTL, never an errorDocumented in code comments; not separately asserted by a test
Suspension is never implicitly reversed by a bulk operationassign() step 9's skippedSuspended branchA deliberate suspension must survive an unrelated bulk re-assign of the same gradeReported, not silently overwrittenclass-subjects-write.service.integration.spec.ts

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Subject vs class-subject as two tablesModeling "what a class is taught"Two tables, defaults copied at assignment timeOne subjects table with a teacherId columnThe normal case is one subject taught by different teachers in different classes; a single-teacher column cannot represent itSlightly more joins to render "what does this class teach"Would only reopen if a school ran exactly one section per grade forever
Optimistic concurrency via version + triggerPreventing lost updates on shared admin rowsServer-owned bigint counter, bumped by bump_row_versionA updated_at timestamp comparisonMillisecond timestamps collide under concurrent commits; a counter cannotOne more required field on every PATCH DTOWould only reopen if the trigger itself needed replacing for a performance reason, which is not observed
Partial unique indexes on subjects.name/codeAllowing retirement-and-reuse of a labelWHERE is_active partial indexA full unique indexA full index makes archiving a dead end — the name can never be reusedReactivation needs its own application-side collision check the partial index cannot expressWould only reopen if the school never needed to reuse a retired subject's name, which is not the observed case
Total (non-partial) unique index on class_subjects identityAllowing suspend-and-revive without duplicate rowsNon-partial (class_id, subject_id) unique index, upsert on writeA partial index (mirroring subjects)A partial index would let a suspended row and a new "active" row coexist for one pair — exactly the ambiguity a total index preventsThe identity slot is occupied even while suspended, so revival (not insertion) is the only path back to activeWould only reopen if two independent configurations per (class, subject) were ever wanted, which contradicts the table's own purpose
Batch assignment is one all-or-nothing transactionA grid save of up to 100 rowsCollect every error, write nothing if any existsPer-row commit with partial successA half-saved 100-row grid is a worse operator experience than a clearly-listed set of fixesA single bad row blocks 99 good ones until correctedWould only reopen if grid size grew enough that partial-failure UX became necessary
Consumer subject reads bypass PeopleAccessService.scopeForAuthorizing /student/subjects and /teacher/subjectsPurpose-built audience assertion plus a hand-written predicateReusing scopeFor with a students/classSubjects entityscopeFor can return {kind:"all"} for a staff/teacher session, which is exactly the leak these routes must not haveA second authorization pattern alongside the admin one, to keep in sync conceptually (not literally — the two are intentionally different mechanisms)Would only reopen if scopeFor grew a portal-safe mode that could not return all for these two audiences

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Cacheredis-cli KEYS "academic-structure:subjects:*" against the configured Redis instance; server log grep -nE "Redis unavailable"Keys present after a list read, cleared shortly after a writeRepeated WARN lines from RedisCacheServiceNo action needed for correctness (fail-soft to Postgres); restart/repair Redis to restore caching performance
DB constraintspsql against the target database; \d subjects, \d class_subjects to confirm indexes/constraints match 0017_subject_module.sqlAll indexes and CHECKs presentAn unmapped 500 from a constraint violation whose name is missing from translate()Add the missing constraint-name branch to the relevant translate() method
Permission grants on an existing deploymentSELECT * FROM role_permission rp JOIN permission p ON p.id = rp.permission_id WHERE p.code LIKE 'Subjects_%' OR p.code LIKE 'ClassSubjects_%'superadmin holds all ten codes; staff/teacher hold the two _READ codes; student holds Subjects_READ onlyA role renamed after 0018_subject_permission_grants.sql ran shows zero grants for the two _READ codesGrant Subjects_READ (and ClassSubjects_READ for staff/teacher) by hand through the Roles/Permissions admin screen — the migration's own DO $$ ... RAISE WARNING block names exactly this case in the deploy log

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
Renamed staff/teacher role on an already-deployed databasePermission grantsEvery staff/teacher session silently loses Subjects_READ/ClassSubjects_READ0018's DO $$ block emits a RAISE WARNING naming the missing role in the deploy logThe warning is easy to miss if nobody reads the deploy log, per this repo's own "read the server log" rule
Cache staleness window on classCountRedis cacheAn operator sees a subject's assignment count up to 300 seconds out of date after an assignment write elsewhere300s TTL bounds the window; every writer invalidates the prefixNo push-based invalidation across horizontally-scaled Redis instances beyond what delPatternSoft's SCAN already provides
CLASS_SUBJECT_IN_USE unreachable todayForward compatibilityNone currently — registered for a future FK that does not exist yetNamed ahead of time specifically to avoid an unmapped 500 laterWhoever adds the first table referencing class_subjects must remember to add its constraint name to ClassSubjectsWriteService.translate

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented or explicitly marked non-runtime.
  • Every controller, service, DTO, enum, and schema is documented.
  • Every method with business behavior has a code-flow narrative.
  • Every table has field-level detail.
  • Every index, constraint, relation, and delete behavior has rationale.
  • Every lifecycle/status transition has a state diagram and transition table — see the feature doc's Section 7.
  • Every read/write/action flow has a sequence diagram.
  • Every business invariant is cataloged.
  • Every cache key and invalidation path is documented; there is no queue, realtime event, or external call to document.
  • Every architectural tradeoff is documented with alternatives and revisit triggers.
  • Every operational failure mode has a runbook entry.

18. Backend Completion Checklist

  • Module boundaries are documented.
  • Every controller, service, DTO, schema file, and cache key is covered.
  • Every database table has a field table and relationship diagram.
  • Every runtime flow has a diagram and branch notes.
  • API, feature/flows docs are linked; no TDD document exists for this module.
  • No claim is made without a source file or documented source reference.

See Also