Siddhant Deval
Siddhant Deval
backend5 min read

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.

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.

Architectural Note

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:

PRISMA
// prisma/schema.prisma

model Order {
  id              String      @id
  customerId      String      @map("customer_id")
  status          String
  shippingStreet  String?     @map("shipping_street")
  shippingCity    String?     @map("shipping_city")
  shippingState   String?     @map("shipping_state")
  shippingPostal  String?     @map("shipping_postal")
  shippingCountry String?     @map("shipping_country")
  discountCents   Int         @default(0) @map("discount_cents")
  discountCurrency String?    @map("discount_currency")
  placedAt        DateTime?   @map("placed_at")
  shippedAt       DateTime?   @map("shipped_at")
  cancelledAt     DateTime?   @map("cancelled_at")
  version         Int         @default(0)
  createdAt       DateTime    @default(now()) @map("created_at")
  updatedAt       DateTime    @updatedAt @map("updated_at")
  items           OrderItem[]

  @@index([customerId])
  @@index([status])
  @@map("orders")
}

model OrderItem {
  id              String  @id
  orderId         String  @map("order_id")
  productId       String  @map("product_id")
  quantity        Int
  unitPriceCents  Int     @map("unit_price_cents")
  currency        String
  order           Order   @relation(fields: [orderId], references: [id], onDelete: Cascade)

  @@index([orderId])
  @@map("order_items")
}

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

TYPESCRIPT
// src/infrastructure/persistence/PrismaOrderRepository.ts
import { injectable, inject } from 'inversify';
import { PrismaClient, Order as PrismaOrder, OrderItem as PrismaOrderItem } from '@prisma/client';
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository';
import { Order, OrderSnapshot } from '../../domain/order/Order';
import { OrderId, generateOrderId } from '../../domain/order/OrderId';
import { CustomerId } from '../../domain/customer/CustomerId';
import { OrderStatus } from '../../domain/order/OrderStatus';
import { LineItem } from '../../domain/order/LineItem';
import { Money } from '../../domain/payment/Money';
import { Address } from '../../domain/customer/Address';
import { DomainError } from '../../domain/shared/DomainError';
import { TYPES } from '../container/TYPES';

type PrismaOrderWithItems = PrismaOrder & { items: PrismaOrderItem[] };

@injectable()
export class PrismaOrderRepository implements IOrderRepository {
  constructor(
    @inject(TYPES.PrismaClient) private readonly prisma: PrismaClient
  ) {}

  async findById(id: OrderId): Promise<Order | null> {
    const raw = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    return raw ? this.toDomain(raw) : null;
  }

  async findByCustomerId(customerId: CustomerId): Promise<Order[]> {
    const rows = await this.prisma.order.findMany({
      where: { customerId },
      include: { items: true },
      orderBy: { createdAt: 'desc' },
    });
    return rows.map(row => this.toDomain(row));
  }

  async save(order: Order): Promise<void> {
    const events = order.pullDomainEvents();

    await this.prisma.$transaction(async (tx) => {
      // ── 1. Optimistic concurrency check ──
      // Only applies to existing orders — new orders have version 0
      if (order.version > 0) {
        const current = await tx.order.findUnique({
          where: { id: order.id },
          select: { version: true },
        });

        if (!current) {
          throw new DomainError(`Order ${order.id} not found during save — possible concurrent deletion`);
        }

        if (current.version !== order.version) {
          throw new OptimisticConcurrencyError(
            `Order ${order.id} was modified by another process (expected version ${order.version}, found ${current.version})`
          );
        }
      }

      // ── 2. Upsert the Order root ──
      const shippingAddr = order.shippingAddress;
      await tx.order.upsert({
        where: { id: order.id },
        create: {
          id:              order.id,
          customerId:      order.customerId,
          status:          order.status,
          shippingStreet:  shippingAddr?.street ?? null,
          shippingCity:    shippingAddr?.city ?? null,
          shippingState:   shippingAddr?.state ?? null,
          shippingPostal:  shippingAddr?.postalCode ?? null,
          shippingCountry: shippingAddr?.countryCode ?? null,
          discountCents:   order.appliedDiscount?.amountCents ?? 0,
          discountCurrency: order.appliedDiscount?.currency ?? null,
          placedAt:        order.placedAt ?? null,
          version:         0,
        },
        update: {
          status:          order.status,
          shippingStreet:  shippingAddr?.street ?? null,
          shippingCity:    shippingAddr?.city ?? null,
          shippingState:   shippingAddr?.state ?? null,
          shippingPostal:  shippingAddr?.postalCode ?? null,
          shippingCountry: shippingAddr?.countryCode ?? null,
          discountCents:   order.appliedDiscount?.amountCents ?? 0,
          placedAt:        order.placedAt ?? null,
          shippedAt:       order.shippedAt ?? null,
          cancelledAt:     order.cancelledAt ?? null,
          version:         { increment: 1 }, // Increment version on every successful write
        },
      });

      // ── 3. Synchronize LineItems: delete-then-recreate ──
      // Full replacement is simpler and safer than diffing for small item collections
      await tx.orderItem.deleteMany({ where: { orderId: order.id } });

      if (order.items.length > 0) {
        await tx.orderItem.createMany({
          data: order.items.map(item => ({
            id:             item.id,
            orderId:        order.id,
            productId:      item.productId,
            quantity:       item.quantity,
            unitPriceCents: item.unitPrice.amountCents,
            currency:       item.unitPrice.currency,
          })),
        });
      }

      // ── 4. Write events to Outbox (same transaction — atomic) ──
      if (events.length > 0) {
        await tx.outboxEvent.createMany({
          data: events.map(event => ({
            id:          event.eventId,
            eventType:   event.eventType,
            aggregateId: event.aggregateId,
            payload:     JSON.stringify(event),
            occurredAt:  event.occurredAt,
            publishedAt: null,
          })),
        });
      }
    });
  }

  nextId(): OrderId {
    return generateOrderId();
  }
}
Crucial Requirement

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:

TYPESCRIPT
// Private method — only accessible within PrismaOrderRepository
private toDomain(raw: PrismaOrderWithItems): Order {
  // ── Translate each LineItem row → LineItem Entity ──
  const items: LineItem[] = raw.items.map(item =>
    LineItem.reconstitute({
      id:        item.id as LineItemId,
      productId: item.productId as ProductId,
      quantity:  item.quantity,
      unitPrice: Money.fromCents(
        item.currency as Currency,
        item.unitPriceCents,
      ),
    })
  );

  // ── Translate shipping address columns → Address Value Object ──
  const shippingAddress = raw.shippingStreet
    ? Address.reconstitute({
        street:      raw.shippingStreet,
        city:        raw.shippingCity!,
        state:       raw.shippingState!,
        postalCode:  raw.shippingPostal!,
        countryCode: raw.shippingCountry!,
      })
    : null;

  // ── Translate discount columns → Money Value Object (or null) ──
  const appliedDiscount = raw.discountCents > 0 && raw.discountCurrency
    ? Money.fromCents(raw.discountCurrency as Currency, raw.discountCents)
    : null;

  // ── Validate status is a known OrderStatus ──
  if (!Object.values(OrderStatus).includes(raw.status as OrderStatus)) {
    throw new DomainError(`Invalid OrderStatus loaded from database: '${raw.status}' for Order ${raw.id}`);
  }

  // ── Reconstitute the Aggregate from the snapshot ──
  const snapshot: OrderSnapshot = {
    id:              raw.id as OrderId,
    customerId:      raw.customerId as CustomerId,
    status:          raw.status as OrderStatus,
    items,
    shippingAddress,
    appliedDiscount,
    placedAt:        raw.placedAt ?? undefined,
    shippedAt:       raw.shippedAt ?? undefined,
    cancelledAt:     raw.cancelledAt ?? undefined,
    version:         raw.version,
  };

  return Order.reconstitute(snapshot);
}

The toDomain() method:

  1. Never returns a PrismaOrder — Prisma types do not leave this file
  2. Validates the status field before casting — a corrupted database value throws DomainError, not a TypeScript cast exception
  3. Uses Money.fromCents() — the inverse of Money.of(), constructing from integer cents directly without floating-point conversion
  4. 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):

TYPESCRIPT
// src/application/order/PlaceOrderUseCase.ts — retry on optimistic conflict
async execute(command: PlaceOrderCommand): Promise<OrderId> {
  const MAX_RETRIES = 3;
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    try {
      return await this.attemptExecution(command);
    } catch (err) {
      if (err instanceof OptimisticConcurrencyError && attempt < MAX_RETRIES - 1) {
        // Reload and retry — the Aggregate will have the new version on next load
        continue;
      }
      throw err;
    }
  }
  throw new DomainError('Order was modified concurrently — please retry');
}
Pro Tip & Optimization

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:

TYPESCRIPT
// src/domain/payment/Money.ts (addition)
static fromCents(currency: Currency, amountCents: number): Money {
  if (!SupportedCurrencies.includes(currency))
    throw new DomainError(`Unsupported currency loaded from database: ${currency}`);
  if (!Number.isInteger(amountCents) || amountCents < 0)
    throw new DomainError(`Invalid cent value loaded from database: ${amountCents}`);
  return new Money({ amountCents, currency });
}

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:

TYPESCRIPT
// tests/integration/PrismaOrderRepository.integration.test.ts
import { PrismaClient } from '@prisma/client';
import { PrismaOrderRepository } from '../../../src/infrastructure/persistence/PrismaOrderRepository';
import { orderRepositoryContract } from '../../shared/OrderRepositoryContract';
import { OptimisticConcurrencyError } from '../../../src/domain/shared/OptimisticConcurrencyError';

const prisma = new PrismaClient();
let repo: PrismaOrderRepository;

// Run the full shared contract suite
orderRepositoryContract(
  () => repo,
  async () => {
    repo = new PrismaOrderRepository(prisma);
    await prisma.$transaction([
      prisma.outboxEvent.deleteMany(),
      prisma.orderItem.deleteMany(),
      prisma.order.deleteMany(),
    ]);
  },
);

// Additional Prisma-specific tests (not part of the interface contract)
describe('PrismaOrderRepository — concurrency', () => {
  it('should throw OptimisticConcurrencyError when version conflicts', async () => {
    const order = Order.create('cust_001' as CustomerId);
    await repo.save(order); // version becomes 1 in DB

    // Load the same order twice — both have version=1
    const [copy1, copy2] = await Promise.all([
      repo.findById(order.id),
      repo.findById(order.id),
    ]);

    // First save succeeds — version becomes 2
    copy1!.addItem('prod_A' as ProductId, 1, Money.of('USD', 10));
    await repo.save(copy1!);

    // Second save fails — DB version is now 2, copy2 still has version=1
    copy2!.setShippingAddress(testAddress());
    await expect(repo.save(copy2!)).rejects.toThrow(OptimisticConcurrencyError);
  });
});

afterAll(() => prisma.$disconnect());

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 →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Repository Pattern#Prisma#PostgreSQL#TypeScript#Node.js#ORM#Clean Architecture
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.