Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Data Access Patterns: Repository Pattern and Unit of Work

For .NET engineers who know: The Repository pattern, Unit of Work, EF Core’s DbContext as a built-in UoW, and why SaveChanges() exists You’ll learn: When wrapping Prisma in a Repository adds value, when it is unnecessary overhead, and how transactions replace the Unit of Work in TypeScript Time: 10-15 min


The .NET Way (What You Already Know)

In .NET, the Repository pattern and Unit of Work (UoW) are staples of the enterprise architecture playbook. EF Core’s DbContext is itself an implementation of the Unit of Work pattern. The DbSet<T> properties on a DbContext are in-memory repositories. SaveChanges() commits everything the UoW tracked.

// The "classic" pattern: wrap DbContext in explicit repository abstractions
public interface IUserRepository
{
    Task<User?> FindByIdAsync(Guid id);
    Task<IReadOnlyList<User>> FindActiveAsync();
    void Add(User user);
    void Remove(User user);
}

public interface IUnitOfWork
{
    IUserRepository Users { get; }
    IOrderRepository Orders { get; }
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

public class EfUnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _db;

    public EfUnitOfWork(AppDbContext db) { _db = db; }

    public IUserRepository Users => new EfUserRepository(_db);
    public IOrderRepository Orders => new EfOrderRepository(_db);

    public Task<int> SaveChangesAsync(CancellationToken ct = default) =>
        _db.SaveChangesAsync(ct);
}

// A service that uses it
public class OrderService
{
    private readonly IUnitOfWork _uow;

    public OrderService(IUnitOfWork uow) { _uow = uow; }

    public async Task PlaceOrderAsync(Guid userId, List<OrderItemDto> items)
    {
        var user = await _uow.Users.FindByIdAsync(userId)
            ?? throw new NotFoundException($"User {userId} not found");

        var order = Order.Create(user, items);
        _uow.Orders.Add(order);

        await _uow.SaveChangesAsync(); // single transaction: user lookup + order insert
    }
}

Why did the pattern become so prevalent? Several reasons:

  1. TestabilityIUserRepository is mockable. DbContext directly is not.
  2. Decoupling — services depend on interfaces, not on EF Core. You could (theoretically) swap ORMs.
  3. Explicit transactions — the UoW groups multiple operations into one atomic commit.
  4. SaveChanges() semantics — EF Core’s change tracking requires SaveChanges() to flush. The UoW pattern gives that a named home.

All of these motivations are real. But some of them evaporate in the Prisma/NestJS world.


The TypeScript Way

Why the UoW Pattern Mostly Disappears

The Unit of Work exists in EF Core because EF Core tracks changes. You load an entity, mutate it, and SaveChanges() detects and flushes the diff. Without change tracking, there is no “unit” to save. Every Prisma operation is immediately explicit — there is nothing to accumulate and flush.

This eliminates the primary mechanical reason for the UoW abstraction. What remains is transaction management, and Prisma handles that directly.

Prisma $transaction — The Replacement for SaveChanges + UoW

// Sequential transaction — operations run one after another, sharing a connection
const [user, order] = await prisma.$transaction([
  prisma.user.findUniqueOrThrow({ where: { id: userId } }),
  prisma.order.create({
    data: {
      customerId: userId,
      status: 'PENDING',
      total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      lineItems: { create: items },
    },
  }),
]);
// EF Core equivalent — implicit transaction via SaveChanges
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException();
var order = new Order { CustomerId = userId, Status = OrderStatus.Pending };
order.AddItems(items);
_db.Orders.Add(order);
await _db.SaveChangesAsync(); // atomic: both the order + line items committed together

The sequential transaction API works for simple cases. For complex multi-step logic, use the interactive transaction:

// Interactive transaction — explicit control, single shared connection
await prisma.$transaction(async (tx) => {
  // tx is a Prisma client scoped to this transaction
  const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });

  if (!user.isActive) {
    throw new Error('Cannot place order for inactive user');
  }

  const inventory = await tx.product.findMany({
    where: { id: { in: items.map((i) => i.productId) } },
    select: { id: true, stock: true, price: true },
  });

  for (const item of items) {
    const product = inventory.find((p) => p.id === item.productId);
    if (!product || product.stock < item.quantity) {
      throw new Error(`Insufficient stock for product ${item.productId}`);
    }
  }

  // Decrement inventory
  await Promise.all(
    items.map((item) =>
      tx.product.update({
        where: { id: item.productId },
        data: { stock: { decrement: item.quantity } },
      })
    )
  );

  // Create order
  const order = await tx.order.create({
    data: {
      customerId: userId,
      status: 'PENDING',
      total: items.reduce((sum, item) => {
        const product = inventory.find((p) => p.id === item.productId)!;
        return sum + product.price * item.quantity;
      }, 0),
      lineItems: { create: items },
    },
  });

  return order;
});

If any statement inside the callback throws, Prisma rolls back the entire transaction. This replaces both SaveChanges() and the UoW boundary.

Transaction options:

await prisma.$transaction(async (tx) => {
  // ...
}, {
  maxWait: 5000,   // ms to wait for a connection from the pool
  timeout: 10_000, // ms before the transaction is automatically rolled back
  isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});

The NestJS Service Pattern — Prisma IS Your Repository

In a standard NestJS application, services use Prisma directly. The service is a thin wrapper around Prisma queries. This is not a lack of architecture — it is a deliberate choice to avoid indirection that provides no value.

// users/user.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) {}

  async findById(id: string) {
    const user = await this.prisma.user.findUnique({ where: { id } });
    if (!user) throw new NotFoundException(`User ${id} not found`);
    return user;
  }

  async findAll(isActive?: boolean) {
    return this.prisma.user.findMany({
      where: isActive !== undefined ? { isActive } : undefined,
      orderBy: { createdAt: 'desc' },
    });
  }

  async create(dto: CreateUserDto) {
    return this.prisma.user.create({ data: dto });
  }

  async update(id: string, dto: UpdateUserDto) {
    return this.prisma.user.update({ where: { id }, data: dto });
  }

  async delete(id: string) {
    return this.prisma.user.delete({ where: { id } });
  }
}
// prisma/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
  // onModuleDestroy is handled by NestJS lifecycle hooks
}
// users/user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  providers: [UserService],
  controllers: [UserController],
  exports: [UserService],
})
export class UserModule {}

Compare with the C# equivalent. The NestJS service plays the role of the repository. Prisma is the data layer. There is no intermediate interface.

// C# — what the NestJS pattern collapses into one class
public class UserService
{
    private readonly AppDbContext _db;  // Prisma fills this role
    public UserService(AppDbContext db) { _db = db; }

    public async Task<User?> FindById(Guid id) => await _db.Users.FindAsync(id);
    public async Task<List<User>> FindAll() => await _db.Users.ToListAsync();
    // ...
}

When a Repository Wrapper Does Add Value

The rule of thumb: add a Repository abstraction when you have a specific reason, not by default.

Reason 1: You need to swap data sources

If a feature might be backed by PostgreSQL today and an external API or Redis tomorrow, a repository interface isolates the swap.

// Useful abstraction — implementation can change without touching the service
interface FeatureFlagRepository {
  isEnabled(flag: string, userId: string): Promise<boolean>;
}

// One implementation backed by Prisma
class PrismaFeatureFlagRepository implements FeatureFlagRepository {
  constructor(private readonly prisma: PrismaService) {}

  async isEnabled(flag: string, userId: string): Promise<boolean> {
    const record = await this.prisma.featureFlag.findFirst({
      where: { name: flag, userId },
    });
    return record?.enabled ?? false;
  }
}

// Another backed by LaunchDarkly
class LaunchDarklyFeatureFlagRepository implements FeatureFlagRepository {
  async isEnabled(flag: string, userId: string): Promise<boolean> {
    return launchDarkly.variation(flag, { key: userId }, false);
  }
}

Reason 2: Complex query logic that benefits from a dedicated home

If you have ten-line Prisma queries that are reused across multiple services, extracting them into a named method on a Repository class keeps services readable.

// OrderRepository — justified by query complexity and reuse
@Injectable()
export class OrderRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findOrdersWithRevenueSummary(customerId: string, dateRange: DateRange) {
    return this.prisma.order.groupBy({
      by: ['status'],
      _count: { id: true },
      _sum: { total: true },
      where: {
        customerId,
        createdAt: { gte: dateRange.from, lte: dateRange.to },
      },
    });
  }

  async findPaginatedWithDetails(
    filters: OrderFilters,
    cursor?: string,
    take = 20,
  ) {
    return this.prisma.order.findMany({
      where: {
        status: filters.status,
        customerId: filters.customerId,
        createdAt: filters.dateRange
          ? { gte: filters.dateRange.from, lte: filters.dateRange.to }
          : undefined,
      },
      include: { customer: true, lineItems: { include: { product: true } } },
      orderBy: { createdAt: 'desc' },
      take,
      cursor: cursor ? { id: cursor } : undefined,
      skip: cursor ? 1 : 0,
    });
  }
}

Reason 3: You need to test service logic in isolation without a database

If your service has non-trivial business logic and you want fast unit tests that do not spin up Docker, a Repository interface lets you mock data access.

// Interface defined for testability
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<User>;
}

// In the test — mock the interface, not PrismaClient
const mockUserRepo: UserRepository = {
  findById: vi.fn().mockResolvedValue(null),
  save: vi.fn(),
};

const service = new UserService(mockUserRepo);

When this reason applies, you are usually better served by vitest-mock-extended to mock PrismaService directly (see the previous article) — the interface is an extra layer unless you genuinely need it.


Key Differences

ConcernEF Core + UoW patternPrisma in NestJS
Change trackingYes — mutations auto-detectedNo — every update is explicit
Unit of WorkDbContext.SaveChanges()prisma.$transaction([...]) or callback
Repository abstractionIRepository<T> over DbSet<T>Usually none — PrismaService is injected directly
When to add RepositoryCommon (testability, decoupling)Only when you have a specific reason
Transaction scopeImplicit UoW boundaryExplicit $transaction callback
Isolation levelsDatabase.BeginTransactionAsync(IsolationLevel.X)prisma.$transaction(fn, { isolationLevel })
Service layer roleOrchestrates repos, UoWOwns queries directly via Prisma
DI lifetimeScoped DbContextGlobal PrismaService

Gotchas for .NET Engineers

1. There is no SaveChanges — every mutation must be explicit

This is the most disorienting shift. In EF Core, you can load entities, call methods on them, and SaveChanges() sorts it out. There is no equivalent in Prisma. If you do not call prisma.user.update(...), nothing is persisted, no matter what you do to the object in memory.

// This persists nothing
const user = await prisma.user.findUnique({ where: { id } });
user!.name = 'New Name'; // mutates the JS object only
// No SaveChanges equivalent. The database is unchanged.

// This persists the change
await prisma.user.update({
  where: { id },
  data: { name: 'New Name' },
});

2. Interactive transactions have a default timeout of 5 seconds

EF Core transactions time out based on the CommandTimeout you configure, which is often 30 seconds or more. Prisma’s interactive transaction defaults to 5 seconds (timeout: 5000). If your transaction involves slow queries or multiple round trips, it will roll back before completing.

// Increase for slow or complex transactions
await prisma.$transaction(async (tx) => {
  // multi-step logic...
}, {
  timeout: 15_000,  // 15 seconds
  maxWait: 5_000,   // wait up to 5s for a connection
});

3. Wrapping PrismaService in a generic Repository<T> buys almost nothing

A common reflex from .NET is to create a Repository<T> base class that wraps CRUD operations:

// Looks familiar but adds no value in Prisma
class Repository<T> {
  constructor(private readonly model: any) {}
  findById(id: string) { return this.model.findUnique({ where: { id } }); }
  findAll() { return this.model.findMany(); }
  create(data: any) { return this.model.create({ data }); }
  update(id: string, data: any) { return this.model.update({ where: { id }, data }); }
  delete(id: string) { return this.model.delete({ where: { id } }); }
}

This destroys type safety — model: any is untyped, and Prisma’s per-model client is already a well-typed, model-specific API. You lose Prisma’s type inference and gain nothing. Prisma’s user, order, etc. model clients are already thin, typed repositories. Do not wrap them in another layer unless the abstraction carries specific value.

4. Passing tx (the transaction client) through the call stack is manual work

EF Core’s transaction is implicit once you call Database.BeginTransactionAsync(). Any DbContext operation within that scope is automatically in the transaction. Prisma’s interactive transaction gives you a scoped tx client that you must pass explicitly to every operation in the transaction.

If your transaction spans multiple service methods, each method needs to accept tx as an optional parameter:

async function processOrder(
  userId: string,
  items: OrderItem[],
  tx?: Prisma.TransactionClient
): Promise<Order> {
  const client = tx ?? prisma; // use tx if in a transaction, prisma otherwise
  return client.order.create({ data: { ... } });
}

// Calling with a transaction
await prisma.$transaction(async (tx) => {
  await checkInventory(items, tx);    // passes tx down
  await processOrder(userId, items, tx); // passes tx down
  await notifyWarehouse(items, tx);   // passes tx down
});

This is more verbose than EF Core’s ambient transaction model, but it is explicit and easy to trace.


Hands-On Exercise

You are reviewing a NestJS PR that adds a full Repository + Unit of Work abstraction over Prisma for a small CRUD application with five models: User, Post, Comment, Tag, and PostTag. The PR adds:

  • IRepository<T> base interface with findById, findAll, create, update, delete
  • Five repository classes implementing it
  • IUnitOfWork interface with five repository properties and a commit() method
  • PrismaUnitOfWork class implementing IUnitOfWork
  • All five services updated to use IUnitOfWork instead of PrismaService

Tasks:

  1. List three concrete concerns this abstraction solves for this application. If you cannot list three, note which arguments are weaker than they appear.

  2. Rewrite PostService.publishPost(postId, authorId) in two versions:

    • Version A: using IUnitOfWork as described in the PR
    • Version B: using PrismaService directly with $transaction Compare verbosity, type safety, and testability.
  3. The PR’s commit() method wraps prisma.$transaction([...]) with the operations accumulated since the last commit. What does this break that Prisma’s interactive transaction handles correctly?

  4. Write a unit test for PostService.publishPost in both versions. Which version is easier to test, and why?

  5. Give a verdict: accept, request changes, or reject. State the one scenario where the abstraction would become justified, and what change to the application would trigger that scenario.


Quick Reference

EF CorePrisma equivalentNotes
_db.SaveChangesAsync()prisma.$transaction([...])No implicit accumulation — all ops explicit
IUnitOfWork.commit()prisma.$transaction(fn)Callback form supports conditional logic
IRepository<T>prisma.modelNameAlready a typed, model-specific API
Database.BeginTransactionAsync()prisma.$transaction(async tx => {...})tx must be passed explicitly through the call stack
IsolationLevel.Serializable{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }Option on $transaction
Rollback on exceptionAutomatic inside $transaction callbackSame as EF Core — any throw rolls back
DbContext Scoped lifetimePrismaService singletonPrismaService is global; no per-request instance needed
IRepository<T> for mockingvi.mock() or mockDeep<PrismaService>()Mock PrismaService directly; no interface needed

PrismaService for NestJS (canonical pattern):

// prisma/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

// prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';

@Global()  // makes PrismaService available everywhere without importing PrismaModule
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Transaction pattern for cross-service operations:

// Pass Prisma.TransactionClient as optional param for composability
async function operationA(
  data: AData,
  tx?: Prisma.TransactionClient,
): Promise<A> {
  const client = tx ?? prisma;
  return client.a.create({ data });
}

async function operationB(
  data: BData,
  tx?: Prisma.TransactionClient,
): Promise<B> {
  const client = tx ?? prisma;
  return client.b.create({ data });
}

// Compose atomically
await prisma.$transaction(async (tx) => {
  const a = await operationA(aData, tx);
  const b = await operationB({ ...bData, aId: a.id }, tx);
  return { a, b };
});

Further Reading