Course outline

NestJS / Intermediate: policies, adapters and sources / Lesson 11

Repositories, transactions and persistence boundaries

Keep database access behind tenant-aware domain contracts and place atomicity where the selected source can enforce it.

About 4 min readContent reviewed
On this page

Lesson goals

  • Separate injected repository identity from a complete persistence guarantee.
  • Reason about transaction-scoped access, stale writes and external effects.

A repository is an adapter with a policy boundary

Nest can integrate with several persistence libraries, but injection convenience does not make every repository call tenant-safe or transactional. Choose an adapter contract that expresses eligible reads and coherent writes. Keep data-source and repository tokens aligned with the selected connection when multiple sources exist. An ORM entity is not automatically a public DTO or an authorization decision. Review query shape, source limits and parameter binding separately from dependency wiring. Do not run schema synchronization or migrations as an accidental consequence of importing a course module; operational changes need their own explicit authorized process.

Describe an eligible read rather than an unrestricted find

The interface-like fragment expresses the domain requirement independently of a particular ORM. Its implementation must apply tenant and visibility policy at the source and return a public-compatible value. A TypeScript contract does not itself prevent an unsafe query. Source IDs, null outcomes and errors need documented meanings. Keep filters from the caller as validated domain options rather than forwarding arbitrary query objects. The example is intentionally not runnable and opens no database; it specifies what a repository adapter must prove through separate isolated integration tests.

typescript Illustrative persistence capability contract
interface CourseRepository {
  visibleCard(input: {tenantId:string; id:string; principal:Principal}): Promise<CourseCard|null>;
  rename(input: {tenantId:string; id:string; expectedRevision:number; title:string}): Promise<RenameOutcome>;
}

Principal, CourseCard and RenameOutcome require domain definitions. No ORM method, query or transaction is executed.

Use transaction-bound dependencies for a coherent change

A transaction boundary must use the selected library’s transaction-scoped manager or repository correctly. Calling a separately injected global repository from inside a callback may escape the intended transaction, depending on the library. Define atomic state changes and uniqueness constraints together. An external email or message cannot automatically roll back with database state. A transactional outbox can record intent alongside the change, while dispatch still needs replay-safe delivery. A controller decorator or interceptor surrounding the method is not proof that every source access and external effect participates in one atomic unit.

Model stale-write rejection before any mutation

The local function returns a new record only when the expected revision matches. A stale edit throws before modifying the original. This is a synchronous immutable model, not a database compare-and-swap or concurrent transaction. A real adapter must combine the revision condition and write atomically and inspect affected-row semantics under its source. Distinguish a new stale edit from replay of a request that already succeeded; they need separate identities. Testing the original remains unchanged helps prove local sequencing but cannot establish isolation or durability.

javascript Complete offline revision precondition
function rename(record,expected,title) {
  if (record.revision !== expected) throw new Error("REVISION_CONFLICT");
  return {...record,title,revision:record.revision+1};
}
const old={id:"c1",title:"Old",revision:4};
console.assert(rename(old,4,"New").revision === 5);
let conflict=false; try {rename(old,3,"Wrong");} catch {conflict=true;}
console.assert(conflict && old.title === "Old" && old.revision === 4);

Matching revision produces a new version five; stale input throws and the original remains unchanged. No storage atomicity is tested.

Bound source work and keep operations separate

Keep pool lifetime application-owned and request work bounded by source deadlines, result size and concurrency. A request-scoped service should not automatically create a new unbounded connection pool per caller. Propagate cancellation where the driver supports it and preserve uncertain writes after a timeout. Test access policy, query results, transaction rollback and migrations through separate approved procedures; they are different risk and evidence categories. This course executes only the immutable revision function and does not contact a database, construct an ORM, run migrations or validate engine-level transaction semantics. Any real integration must reproduce those boundaries in an authorized isolated environment.

Common errors and fixes

A tenant filter is missing in one repository method

Why it happens
Generic source access bypassed the shared eligible-read contract.
How to fix it
Centralize policy-aware repository methods and test alternate paths.
Prevent it next time
Use the same ID under eligible and denied principals.

A write escapes the intended transaction

Why it happens
A non-transaction-bound repository was used inside the workflow.
How to fix it
Use the library’s scoped transaction dependencies consistently.
Prevent it next time
Test failure after the first source effect in isolation.

A timeout is reported as definite rollback

Why it happens
Transport failure was confused with authoritative source outcome.
How to fix it
Reconcile using operation identity and storage evidence.
Prevent it next time
Include response-loss-after-commit cases.

Check your understanding

1. Why is the local revision function not a concurrent-write proof?

Show worked solution

It evaluates one in-memory object synchronously. A real source must atomically enforce the revision condition together with the update so two writers cannot both pass an earlier read. Verify source-specific affected-row, isolation and retry semantics independently; the local immutability assertion proves only local sequencing.

2. Does an outbox mean an external message is delivered exactly once?

Show worked solution

No. It can coordinate recording delivery intent with a database change, but dispatch can retry and consumers can receive duplicates. Define event identity, acknowledgment and deduplication. Keep the database transaction claim separate from transport delivery and downstream effects instead of extending one guarantee beyond its actual boundary.

Key takeaways

  • Injected repositories still need source-level policy and atomicity.
  • Transport errors do not establish the final persistence outcome.

Official references