The Repository Pattern: Abstracting Data Access the Right Way
The repository pattern places a collection-like interface between your domain logic and data access code. Instead of scattering SQL queries or ORM calls across services, you interact with a repository that speaks the language of your domain. When implemented well, it makes code testable, swappable, and easier to reason about. When implemented poorly, it becomes an unnecessary wrapper that hides the power of your database.
The Core Abstraction#
A repository exposes operations that look like an in-memory collection:
interface OrderRepository {
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
}
The caller does not know — or care — whether the implementation talks to PostgreSQL, MongoDB, or a flat file. The interface is defined in the domain layer while the implementation lives in the infrastructure layer.
Implementation with an ORM#
class PostgresOrderRepository implements OrderRepository {
constructor(private readonly db: PrismaClient) {}
async findById(id: string): Promise<Order | null> {
const row = await this.db.order.findUnique({
where: { id },
include: { lineItems: true },
});
return row ? this.toDomain(row) : null;
}
async findByCustomer(customerId: string): Promise<Order[]> {
const rows = await this.db.order.findMany({
where: { customerId },
include: { lineItems: true },
});
return rows.map(this.toDomain);
}
async save(order: Order): Promise<void> {
await this.db.order.upsert({
where: { id: order.id },
create: this.toPersistence(order),
update: this.toPersistence(order),
});
}
async delete(order: Order): Promise<void> {
await this.db.order.delete({ where: { id: order.id } });
}
private toDomain(row: PrismaOrder): Order {
// Map persistence model to domain entity
}
private toPersistence(order: Order): PrismaOrderCreate {
// Map domain entity to persistence model
}
}
The toDomain and toPersistence methods are critical. They decouple the database schema from the domain model, allowing each to evolve independently.
Unit of Work#
The Unit of Work pattern tracks all changes made during a business transaction and commits them atomically:
interface UnitOfWork {
orders: OrderRepository;
payments: PaymentRepository;
commit(): Promise<void>;
rollback(): Promise<void>;
}
class PostgresUnitOfWork implements UnitOfWork {
private tx: PrismaTransaction | null = null;
get orders(): OrderRepository {
return new PostgresOrderRepository(this.tx!);
}
get payments(): PaymentRepository {
return new PostgresPaymentRepository(this.tx!);
}
async begin(): Promise<void> {
this.tx = await prisma.$transaction.start();
}
async commit(): Promise<void> {
await this.tx?.$commit();
}
async rollback(): Promise<void> {
await this.tx?.$rollback();
}
}
Usage in a service:
async function placeOrder(uow: UnitOfWork, dto: PlaceOrderDTO) {
const order = Order.create(dto);
const payment = Payment.authorize(order.total, dto.paymentMethod);
uow.orders.save(order);
uow.payments.save(payment);
await uow.commit(); // single atomic transaction
}
If either save fails, nothing is committed. The Unit of Work ensures consistency without the service knowing anything about database transactions.
The Specification Pattern#
When query logic grows complex, embedding it in named repository methods creates an explosion of methods like findByStatusAndDateRangeAndCustomerTier. The Specification pattern extracts query criteria into composable objects:
interface Specification<T> {
isSatisfiedBy(entity: T): boolean; // in-memory filtering
toQuery(): QueryFragment; // database filtering
}
class OrderIsOverdue implements Specification<Order> {
isSatisfiedBy(order: Order): boolean {
return order.status === "pending"
&& order.createdAt < daysAgo(30);
}
toQuery(): QueryFragment {
return {
where: {
status: "pending",
createdAt: { lt: daysAgo(30) },
},
};
}
}
class OrderBelongsToCustomer implements Specification<Order> {
constructor(private customerId: string) {}
isSatisfiedBy(order: Order): boolean {
return order.customerId === this.customerId;
}
toQuery(): QueryFragment {
return { where: { customerId: this.customerId } };
}
}
Compose specifications with boolean logic:
const spec = and(
new OrderIsOverdue(),
new OrderBelongsToCustomer("cust-123")
);
const overdueOrders = await orderRepo.findBySpec(spec);
This keeps the repository interface slim while supporting arbitrarily complex queries.
Query Objects#
For read-heavy use cases — dashboards, reports, search — the repository pattern can feel restrictive. Query objects provide a purpose-built read path that bypasses domain entities entirely:
class OrderSummaryQuery {
constructor(private readonly db: PrismaClient) {}
async execute(filters: OrderFilters): Promise<OrderSummaryDTO[]> {
return this.db.$queryRaw`
SELECT o.id,
o.status,
o.total,
c.name AS customer_name,
count(li.id) AS line_item_count
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN line_items li ON li.order_id = o.id
WHERE o.created_at >= ${filters.since}
GROUP BY o.id, c.name
ORDER BY o.created_at DESC
LIMIT ${filters.limit}
`;
}
}
This follows CQRS thinking: commands go through the repository (write path), queries go through dedicated query objects (read path). No need to force a complex JOIN through a domain entity mapping.
Testing with In-Memory Repositories#
One of the pattern's biggest wins is testability. An in-memory implementation removes the database from unit tests:
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) ?? null;
}
async findByCustomer(customerId: string): Promise<Order[]> {
return [...this.orders.values()]
.filter(o => o.customerId === customerId);
}
async save(order: Order): Promise<void> {
this.orders.set(order.id, order);
}
async delete(order: Order): Promise<void> {
this.orders.delete(order.id);
}
}
Tests run in milliseconds with no database setup:
test("placeOrder saves order and payment", async () => {
const uow = new InMemoryUnitOfWork();
await placeOrder(uow, validDTO);
const saved = await uow.orders.findById(validDTO.orderId);
expect(saved).not.toBeNull();
expect(saved!.status).toBe("placed");
});
ORM vs Raw SQL: Which Belongs in a Repository?#
| Approach | Pros | Cons |
|---|---|---|
| ORM | Type safety, migrations, less boilerplate | Leaky abstractions, N+1 risks, complex queries are awkward |
| Raw SQL | Full database power, optimized queries | No type checking, manual mapping, migration management |
| Query builder (Knex, Kysely) | SQL-like with type safety | Middle ground — not as clean as either extreme |
The repository pattern works with all three. The key rule: keep the choice inside the implementation. The domain layer should never import your ORM or see a SQL string.
When Repository Adds Value#
The pattern earns its keep when:
- Multiple data sources — You need to swap PostgreSQL for DynamoDB in one bounded context without rewriting services.
- Complex domain logic — Rich domain entities benefit from clean separation between behavior and persistence.
- Testability matters — In-memory repos make unit tests fast and deterministic.
- Team boundaries — Different teams own domain logic and infrastructure. The interface is the contract.
When Repository Is Overkill#
Skip it when:
- CRUD-dominant apps — If your "domain logic" is just validating fields and saving to a database, the repository adds a layer with no benefit. Use your ORM directly.
- Tight ORM coupling is acceptable — Small projects or prototypes where swapping the database is not a realistic scenario.
- Read-heavy dashboards — Query objects or direct SQL are more natural than forcing reads through domain entities.
- Single developer, small scope — The abstraction cost outweighs the benefit when there is no team to protect from implementation details.
Architecture Placement#
┌──────────────────────────────────┐
│ Application Layer │
│ (Use Cases / Services) │
│ ▼ ▼ │
│ OrderRepository UnitOfWork │ ◄── Interfaces
└──────────┬───────────┬───────────┘
│ │
┌──────────▼───────────▼───────────┐
│ Infrastructure Layer │
│ PostgresOrderRepository │
│ PostgresUnitOfWork │ ◄── Implementations
│ (Prisma / Knex / raw SQL) │
└──────────────────────────────────┘
Dependency inversion: the domain defines the interface, infrastructure implements it, and a DI container wires them together at startup.
Quick Reference Checklist#
- Define repository interfaces in the domain layer, not the infrastructure layer
- Map between persistence models and domain entities explicitly
- Use Unit of Work for multi-aggregate transactions
- Use Specification pattern when query methods proliferate
- Use Query Objects for complex reads and reporting
- Write in-memory implementations for fast unit tests
- Do not expose ORM types or SQL through the repository interface
- Evaluate honestly whether your project needs the abstraction at all
That is article #353 on codelit.io — browse the full library for more on software architecture, design patterns, and clean code practices.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
Try these templates
Build this architecture
Generate an interactive architecture for The Repository Pattern in seconds.
Try it in Codelit →
Comments