Persistence & the Repository Pattern: Prisma Adapters, ORM Mapping & Transactions
Mapping domain Aggregates to a relational database through Prisma without leaking persistence concerns into the domain layer requires deliberate anti-corruption boundaries. This article implements the full PrismaOrderRepository adapter — converting between rich domain objects and flat Prisma records — and handles unit-of-work transactions across Aggregate roots.
Backend Clean Architecture & Domain-Driven Design
Persistence & Repository: The Prisma Anti-Corruption Layer
Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But to be durable, they must eventually be serialized to a database and deserialized back. The translation between the normalized relational model (Prisma rows) and the rich domain model (Aggregates with Value Objects and Domain Events) is where most Clean Architecture implementations break down. Engineers either leak Prisma types into the domain layer, or they write unmaintainable impedance-mapping code that loses invariants on the way back from the database.
This article implements PrismaOrderRepository in full — the Anti-Corruption Layer (ACL) that translates in both directions, handles optimistic concurrency with a version column, manages the parent/child Aggregate write within a single $transaction, and reconstitutes rich domain objects from flat rows without ever letting a Prisma type escape the repository file.
Prerequisites: Part 3 (Entities & Value Objects) for Order.reconstitute() and LineItem; Part 4 (Aggregates & Repositories) for IOrderRepository and the save() contract; Part 9 (DI Container) for @injectable wiring. Part 12 (Testing) — the contract test suite from Part 4 is the test target for this implementation.
1. The Schema: Normalized Write Model
The write model stores Order and LineItem as separate normalized tables. The version column enables optimistic concurrency:
The schema stores Money as unitPriceCents: Int + currency: String — the same integer-cent representation from Part 3, eliminating any floating-point precision risk in the database layer.
2. The PrismaOrderRepository: Full Implementation
The delete-then-recreate strategy for LineItems: for collections with typically fewer than 50 items, full replacement is simpler and safer than computing a diff (added/updated/removed). It eliminates edge cases around item identity and ensures the database always matches the domain object's state. For orders with potentially thousands of items, implement a proper diff algorithm.
3. The toDomain() Method: The Anti-Corruption Layer
The ACL translation is the most critical method in the repository. It must reconstruct a semantically equivalent domain object from flat relational data — never letting Prisma types leak out:
The toDomain() method:
- Never returns a
PrismaOrder— Prisma types do not leave this file - Validates the
statusfield before casting — a corrupted database value throwsDomainError, not a TypeScript cast exception - Uses
Money.fromCents()— the inverse ofMoney.of(), constructing from integer cents directly without floating-point conversion - Uses
Order.reconstitute()— bypasses creation-only invariants (an existing SHIPPED order with no items in PENDING checks is valid stored state)
4. Optimistic Concurrency: Preventing Lost Updates
In a billing system with multiple servers, two requests can load the same Order, modify it independently, and both attempt to save. Without concurrency control, the last write wins — silently discarding the first write's changes.
The version column provides optimistic concurrency without database locks:
The Use Case catches OptimisticConcurrencyError and retries (or returns a 409 Conflict to the client):
Optimistic concurrency is appropriate when concurrent modifications of the same Order are rare (they are — orders are typically modified by one user at a time). Use pessimistic locking (SELECT FOR UPDATE) only when concurrent modification is expected and a retry loop would be unacceptable — for example, decrementing a shared inventory counter.
5. Money.fromCents(): Safe Deserialization
The Money class needs a second factory for loading from the database — constructing from integer cents directly:
This is the reconstitution factory for Money — exactly analogous to Order.reconstitute(). It skips the Math.round(amount * 100) conversion step (the value is already in cents from the database) and validates that the stored value is a valid non-negative integer.
6. Repository Testing with the Contract Suite
The contract suite from Part 4 is now executed against PrismaOrderRepository:
Summary
| Concern | Implementation Decision |
|---|---|
| Schema design | unitPriceCents: Int + currency: String — integer money, no floats |
version column |
Optimistic concurrency; incremented on every successful save() |
| LineItem sync strategy | Delete-then-recreate within the same $transaction — no diff required for small collections |
| Outbox write | Part of the same $transaction as the Aggregate save — atomic, guaranteed delivery |
toDomain() |
Private to the repository file; Prisma types never escape; validates status before cast |
reconstitute() |
Bypasses creation-only invariants — SHIPPED orders with specific stored state load correctly |
Money.fromCents() |
Safe deserialization factory; validates stored cent values before construction |
| Contract tests | The same 12 assertions that InMemoryOrderRepository passes — behavioral equivalence guaranteed |
What's Next
In Part 14, the capstone, we assemble the complete billing engine — wiring all 14 parts into a production-ready system with Dockerfile, Docker Compose, CI pipeline, health checks, graceful shutdown, and structured logging. We review every architectural decision made across the series and examine where to go next. Part 14: Production Capstone →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.