Data Access Patterns: Repository Pattern and Unit of Work
For .NET engineers who know: The Repository pattern, Unit of Work, EF Core’s
DbContextas a built-in UoW, and whySaveChanges()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:
- Testability —
IUserRepositoryis mockable.DbContextdirectly is not. - Decoupling — services depend on interfaces, not on EF Core. You could (theoretically) swap ORMs.
- Explicit transactions — the UoW groups multiple operations into one atomic commit.
SaveChanges()semantics — EF Core’s change tracking requiresSaveChanges()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
| Concern | EF Core + UoW pattern | Prisma in NestJS |
|---|---|---|
| Change tracking | Yes — mutations auto-detected | No — every update is explicit |
| Unit of Work | DbContext.SaveChanges() | prisma.$transaction([...]) or callback |
| Repository abstraction | IRepository<T> over DbSet<T> | Usually none — PrismaService is injected directly |
| When to add Repository | Common (testability, decoupling) | Only when you have a specific reason |
| Transaction scope | Implicit UoW boundary | Explicit $transaction callback |
| Isolation levels | Database.BeginTransactionAsync(IsolationLevel.X) | prisma.$transaction(fn, { isolationLevel }) |
| Service layer role | Orchestrates repos, UoW | Owns queries directly via Prisma |
| DI lifetime | Scoped DbContext | Global 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 withfindById,findAll,create,update,delete- Five repository classes implementing it
IUnitOfWorkinterface with five repository properties and acommit()methodPrismaUnitOfWorkclass implementingIUnitOfWork- All five services updated to use
IUnitOfWorkinstead ofPrismaService
Tasks:
-
List three concrete concerns this abstraction solves for this application. If you cannot list three, note which arguments are weaker than they appear.
-
Rewrite
PostService.publishPost(postId, authorId)in two versions:- Version A: using
IUnitOfWorkas described in the PR - Version B: using
PrismaServicedirectly with$transactionCompare verbosity, type safety, and testability.
- Version A: using
-
The PR’s
commit()method wrapsprisma.$transaction([...])with the operations accumulated since the last commit. What does this break that Prisma’s interactive transaction handles correctly? -
Write a unit test for
PostService.publishPostin both versions. Which version is easier to test, and why? -
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 Core | Prisma equivalent | Notes |
|---|---|---|
_db.SaveChangesAsync() | prisma.$transaction([...]) | No implicit accumulation — all ops explicit |
IUnitOfWork.commit() | prisma.$transaction(fn) | Callback form supports conditional logic |
IRepository<T> | prisma.modelName | Already 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 exception | Automatic inside $transaction callback | Same as EF Core — any throw rolls back |
DbContext Scoped lifetime | PrismaService singleton | PrismaService is global; no per-request instance needed |
IRepository<T> for mocking | vi.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
- Prisma — Transactions and batch queries — the official guide covering sequential transactions, interactive transactions, and when to use each
- NestJS — Database (Prisma) — the canonical NestJS + Prisma setup, showing
PrismaServiceand module structure without a Repository layer - Martin Fowler — Repository pattern — the original definition; note that Fowler’s motivation (mediating between domain and data mapping layers) largely does not apply when the ORM already maps domain types
- Khalil Stemmler — Should you use the Repository pattern with Prisma? — a balanced breakdown of when the Repository pattern adds value in TypeScript and when it is premature abstraction