Maintainability and extension boundaries¶
This chapter explains how to size and organize a change without losing the repository's authorization, transaction, and temporal contracts. Use REFERENCE to locate the owning module, then read the relevant domain chapter. File length, module counts, and past audit findings are not architectural contracts.
1. Review the change boundary¶
Start with the behavior that must remain true. Identify the routes, background jobs, imports, setup handlers, and database helpers that can write the affected state. A rule enforced by only one entry point is not a shared invariant.
For each change, make these decisions explicit:
- Which module owns the state and the public contract?
- Which tenant, academic year, selected session, and school day govern it?
- Which operation owns the real transaction, locks, and commit boundary?
- Is a dependent operation atomic, optional, or deferred until commit?
- Which existing source pattern can be reused without changing its semantics?
Keep source-backed facts in the numbered handbook. Task-specific findings, estimates, execution evidence, and proposals belong in task artifacts; they do not become permanent handbook sections.
2. Keep responsibilities local¶
A small resource can remain flat. A larger domain can group cohesive capabilities or subresources while retaining one Nest module. Existing examples include src/attendance/expected/, src/timetables/generation/, and src/auth/password-reset/. See CRUD patterns.
Moving a large service into a folder does not separate its responsibilities. Prefer:
| Responsibility | Usual home |
|---|---|
| HTTP contract and delegation | Controller, DTOs, Swagger helpers |
| Named database operations and Prisma payload types | Owner's query files |
| Pure eligibility, mapping, or calculation | Cohesive functions beside the capability |
| Transaction and lifecycle orchestration | Service or an existing command owner |
| Deliberate cross-module API | Owner-maintained exports and typed interfaces |
Use providers when dependency injection or orchestration earns its cost. Do not introduce a service for every pure function, repository classes for every model, or another Nest module solely to organize files.
Cross-module service calls are supported through explicit module imports and exported providers. Query-only entry points can be deliberate where a runtime barrel would create a cycle. Check actual consumers before moving an export.
The CRUD base has a broad impact because several person modules inherit it. Keep domain-specific temporal commands in their domain owners rather than turning the base service into a universal workflow engine.
3. Preserve the runtime foundations¶
Tenant and permission boundaries¶
PostgreSQL RLS is active, alongside explicit tenant filters. The request transaction sets the tenant GUC and uses the restricted application role. Global/authentication operations have named privileged paths. See multitenancy; do not treat service filters or platform-admin permissions as replacements for the database boundary.
Scopes, actions, role admission, and record policy answer different questions. A route decorator does not establish which rows may be returned. Policy evaluation can be asynchronous; use the policy's supported resolver and preserve qualified assignment parameters. See permissions.
Aggregate and grouped response shapes are also part of the authorization contract. Keep @AggregateResponse(), DTO contracts, and manual field masking aligned with the endpoint's actual shape.
Transactions and side effects¶
PrismaService joins the ambient tenant transaction. Returning from a nested $transaction does not establish a commit boundary.
- Atomic dependent database writes run in the transaction owned by the command.
AuditService.record(tx, ...)records evidence in that same transaction.- Request-side effects that must follow commit register with
PostCommitCoordinator; notification producers do it throughsendAfterCommit. - Per-tenant scheduled sweeps extend
TenantSweeper(src/common/scheduling/tenant-sweeper.ts) and implement only the per-tenant pass. - A route outside the automatic tenant transaction must explicitly bracket its real transaction with
runScopedif it needs that coordinator. - Background operations that own their real transaction can use an owner-local after-commit queue.
The coordinator's outermost scope owns draining; rollback discards its jobs. It is a process-local commit hook, not a durable delivery guarantee. Durable senders persist their own state and retries. Blob staging, compensation on failure, and committed deletion are distinct lifecycles; an after-commit callback alone does not solve all three.
Canonical sources: src/post-commit/post-commit.coordinator.ts, src/common/interceptors/tenant-transaction.interceptor.ts, src/audit-log/audit.service.ts.
Time and concurrency¶
Use the school's clock for dated domain facts. Capture one effective date or boundary per command and thread it through policies, reads, and mutations. Calendar dates, lesson ticks, role-expiry instants, and delivery deadlines have different meanings.
A transaction does not automatically serialize competing writers. Match the mechanism to the invariant:
| Invariant | Mechanism |
|---|---|
| Unique identity or ordered key | Database unique constraint; map conflicts to a typed domain error |
| Non-overlapping valid-time episodes | Database exclusion constraint plus owner-defined interval operations |
| Check-then-mutate against a parent | Lock the owning row, reload authoritative state, then decide and write |
| Shared enrolment/schedule graph | Existing tenant/year advisory-lock protocol |
| A stale revision must not overwrite a newer one | Compare-and-swap or revision predicate |
| Structural changes can invalidate dependent selections | The owner's same-transaction revalidation or reconciliation |
src/common/temporal/enrolment-schedule-lock.ts owns the graph-lock primitive. Its usage drift specification inventories participating writers. Preserve established lock order and do not infer that a lock introduces an additional business veto.
Database error handling must use shared helpers such as mapP2002 in src/common/utils/prisma-p2002.ts. Prisma adapter errors and RLS-redacted constraint details can differ from a simple meta.target array.
4. Extensibility: established paths¶
An established path has a source precedent and a documented extension recipe. It can still require a design spec under the repository's design gate.
| Change | Start here | Main alignment work |
|---|---|---|
| Field or scope | Workflows, CRUD patterns | Schema, DTOs, field mappings, grants, queries, response shape |
| Entity or endpoint | Workflows | Module ownership, permission catalogue, row policy, tenancy, errors |
| Role or action | Permissions | Preset definition, action requirements, session semantics, qualified row policies |
| Setup step | Setup wizard, src/setup/step-handlers/ |
Step registration, validation, completion, load/save semantics |
| Storage adapter | src/files/storage/file-storage.port.ts |
Upload/read/sign/delete behavior, configuration, cleanup and failure semantics |
| Mail transport | src/mailer/mailer-port.interface.ts, src/mailer/ |
Delivery results, reports, provider limits, retry ownership |
| Error | Error handling | Typed code and parameters, catalogue rendering, HTTP contract |
Do not estimate a change from its number of files alone. A one-field addition may cross authorization and historical-data boundaries; a larger pure projection extraction may preserve them completely.
5. Extensibility: changes that need a design decision¶
A familiar mechanism does not establish a new feature's semantics. Use the design gate when extending the architecture beyond a documented precedent.
Cross-module coordination¶
Decide whether another module's work is required for correctness, optional enrichment, or a committed-state follow-up. Required database changes need a shared transaction owner and error contract. Optional work needs an explicit failure policy. External work needs durable state if loss on process failure is unacceptable.
For timetable scheduling changes, the existing follow-up mechanism records an in-memory mark and optionally discloses affected official version IDs after commit. It does not compute diagnostics or reject the saved domain edit. See timetables. Do not copy an obsolete before/after evaluator as a write gate.
Cascades and temporal history¶
A student move can affect placement, membership, selections, and downstream dated readers. Reuse the existing temporal owner and reconciliation paths. Do not independently mutate head columns or hard-delete lived episodes because a local view appears empty.
Changing a parent identity field can be a domain change rather than ordinary CRUD. Assess roster eligibility, child identity, historical references, and all consumers before making such a field mutable.
New delivery, caching, or event infrastructure¶
The codebase contains scheduled workers and durable delivery state with different guarantees. Preserve per-domain claims, leases, deduplication, fairness, and retry semantics before proposing a shared runner or event bus.
A cache must state its authorization inputs, invalidation rule, and lifetime. Request memoization and a cross-request cache are different contracts; do not substitute one for the other.
New authorization or write gates¶
Read visibility does not grant write authority. A new shared authorizer must account for active session narrowing, assignment parameters, dated membership, hidden-resource behavior, and field grants. A shared write gate must include all competing writers. These are design decisions, not opportunities to generalize from two similar method signatures.
6. Assessing a stack change¶
Estimate from coupling and required semantic preservation, not a generic calendar promise.
| Boundary | Coupling to inspect |
|---|---|
| Object storage | Storage port semantics, signed URLs, provider configuration, staged objects and cleanup |
| Mail provider | Transport API, webhook verification, report normalization, attachments, idempotency |
| Hosting | Build/start/pre-deploy commands, migrations, secrets, workers, health checks, database roles |
| Authentication | Shared identity, school membership, JWT/session selection, credential version, cookies, invitations |
| Database | RLS/GUCs, advisory locks, exclusion/partial unique constraints, raw SQL, JSON and date behavior |
| ORM | Prisma query payloads, row-policy fragments, transaction clients, generated types, error mapping |
| HTTP framework | Guards, interceptors, decorators, DI, reflection, validation, exception rendering |
| Runtime | Native modules, async context propagation, worker execution, SDK compatibility |
The portable contract is the domain model: tenant isolation, selected-session authority, academic-year ownership, valid-time history, immutable evidence, and transaction outcomes. A replacement must preserve these explicitly; package substitution alone is not feature parity.