Skip to content

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 through sendAfterCommit.
  • 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 runScoped if 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.