Repository Pattern for Data Access
Isolate your data access logic with the Repository pattern, making your application testable and database-agnostic.
The Repository pattern mediates between the domain layer and the data mapping layer, acting like an in-memory collection of domain objects. It abstracts the data source, letting your business logic work without knowing whether data comes from PostgreSQL, MongoDB, an API, or a flat file.
Why You Need It
Without a repository, data access concerns bleed into business logic:
// ❌ Business logic tightly coupled to database
class OrderService {
async createOrder(userId: string, items: CartItem[]) {
// Business logic mixed with SQL
const user = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
if (!user.rows[0]) throw new Error('User not found');
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const result = await pool.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[userId, total, 'pending']
);
for (const item of items) {
await pool.query(
'INSERT INTO order_items (order_id, product_id, qty, price) VALUES ($1, $2, $3, $4)',
[result.rows[0].id, item.productId, item.qty, item.price]
);
}
return result.rows[0];
}
}
The Repository Interface
Define what your business logic needs, not how the database works:
interface OrderRepository {
findById(id: string): Promise<Order | null>;
findByUserId(userId: string): Promise<Order[]>;
findByStatus(status: OrderStatus): Promise<Order[]>;
save(order: Order): Promise<Order>;
delete(id: string): Promise<void>;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
}
Implementation
class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
`SELECT o.*, json_agg(oi.*) as items
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = $1
GROUP BY o.id`,
[id]
);
return result.rows[0] ? this.toDomain(result.rows[0]) : null;
}
async findByUserId(userId: string): Promise<Order[]> {
const result = await this.pool.query(
'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows.map(row => this.toDomain(row));
}
async findByStatus(status: OrderStatus): Promise<Order[]> {
const result = await this.pool.query(
'SELECT * FROM orders WHERE status = $1',
[status]
);
return result.rows.map(row => this.toDomain(row));
}
async save(order: Order): Promise<Order> {
const result = await this.pool.query(
`INSERT INTO orders (id, user_id, total, status)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET total = $3, status = $4
RETURNING *`,
[order.id, order.userId, order.total, order.status]
);
return this.toDomain(result.rows[0]);
}
async delete(id: string): Promise<void> {
await this.pool.query('DELETE FROM orders WHERE id = $1', [id]);
}
private toDomain(row: any): Order {
return new Order({
id: row.id,
userId: row.user_id,
total: parseFloat(row.total),
status: row.status,
items: row.items?.map(this.toOrderItem) ?? [],
createdAt: row.created_at,
});
}
private toOrderItem(row: any): OrderItem {
return { productId: row.product_id, quantity: row.qty, price: parseFloat(row.price) };
}
}
In-Memory Implementation for Testing
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 findByUserId(userId: string): Promise<Order[]> {
return [...this.orders.values()].filter(o => o.userId === userId);
}
async findByStatus(status: OrderStatus): Promise<Order[]> {
return [...this.orders.values()].filter(o => o.status === status);
}
async save(order: Order): Promise<Order> {
this.orders.set(order.id, order);
return order;
}
async delete(id: string): Promise<void> {
this.orders.delete(id);
}
// Test helpers
clear(): void {
this.orders.clear();
}
seed(orders: Order[]): void {
orders.forEach(o => this.orders.set(o.id, o));
}
}
Clean Business Logic
Now your service is pure business logic — no SQL, no database details:
class OrderService {
constructor(
private orderRepo: OrderRepository,
private userRepo: UserRepository,
private eventBus: EventBus,
) {}
async createOrder(userId: string, items: CartItem[]): Promise<Order> {
const user = await this.userRepo.findById(userId);
if (!user) throw new NotFoundError('User');
const order = Order.create({
userId,
items: items.map(i => ({
productId: i.productId,
quantity: i.qty,
price: i.price,
})),
});
const saved = await this.orderRepo.save(order);
this.eventBus.emit('order:created', { orderId: saved.id });
return saved;
}
async cancelOrder(orderId: string): Promise<Order> {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new NotFoundError('Order');
order.cancel(); // Domain logic lives on the entity
const saved = await this.orderRepo.save(order);
this.eventBus.emit('order:cancelled', { orderId });
return saved;
}
}
Testing Is Effortless
describe('OrderService', () => {
let service: OrderService;
let orderRepo: InMemoryOrderRepository;
let userRepo: InMemoryUserRepository;
beforeEach(() => {
orderRepo = new InMemoryOrderRepository();
userRepo = new InMemoryUserRepository();
userRepo.seed([testUser]);
service = new OrderService(orderRepo, userRepo, new MockEventBus());
});
it('creates an order for existing user', async () => {
const order = await service.createOrder(testUser.id, [
{ productId: 'p1', qty: 2, price: 29.99 },
]);
expect(order.userId).toBe(testUser.id);
expect(order.total).toBe(59.98);
expect(await orderRepo.findById(order.id)).toBeTruthy();
});
it('throws when user not found', async () => {
await expect(service.createOrder('nonexistent', []))
.rejects.toThrow(NotFoundError);
});
});
No database setup. No Docker containers for tests. Blazing fast. The Repository pattern keeps your architecture clean and your tests instant.
“A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection.” — Martin Fowler