Repository pattern
After five years building production systems, I've seen more data access code scattered across controllers and services than I care to admit. The Repository pattern is a collection-like interface over persistence that centralizes where your application talks to the database. It's not a new idea, but it's one that becomes invaluable the moment your data access logic grows beyond a handful of queries.
Purpose — Keep data access logic in one place, away from business logic
The core problem the Repository pattern solves is separation of concerns. When you're building an e-commerce platform or a trading system, your business logic doesn't care whether data lives in PostgreSQL, Redis, or a third-party API. What it does care about is asking for data in a predictable, testable way.
Without a repository, your services end up littered with query logic:
// Bad: Data access mixed into service
export class OrderService {
constructor(private db: Database) {}
async getHighValueOrders(threshold: number) {
return this.db.query(
`SELECT * FROM orders WHERE total_amount > $1 AND status = $2`,
[threshold, 'completed']
);
}
async cancelOrder(orderId: string) {
const order = await this.db.query(
`SELECT * FROM orders WHERE id = $1`,
[orderId]
);
// business logic here
await this.db.query(`UPDATE orders SET status = $2 WHERE id = $1`, [orderId, 'cancelled']);
}
}
This breaks down when you need to reuse queries, switch databases, or write tests without hitting the real database. A repository abstracts away the how of persistence:
// Good: Repository handles data access
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
findHighValue(threshold: number): Promise<Order[]>;
save(order: Order): Promise<void>;
}
export class OrderService {
constructor(private orderRepo: OrderRepository) {}
async cancelOrder(orderId: string) {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error('Order not found');
order.cancel();
await this.orderRepo.save(order);
}
}
Notice how the service now reads like business logic: find the order, cancel it, save it. The repository handles how to talk to PostgreSQL or any other store.
Aggregate-focused — One repository per aggregate root, not per table
This is where most implementations go wrong. Junior developers often create a repository for every database table. That leads to chaos fast.
In domain-driven design, an aggregate root is the entry point to a cluster of related entities. Think of an Order aggregate: it contains the order itself, line items, payments, and shipment info. You don't create separate repositories for OrderLineItem or Payment. You work through the Order repository.
Let me show you what this looks like in a ride-hailing platform I built. A Ride is an aggregate root containing Driver, Passenger, Location, and Route.
// Domain model
export class Ride {
id: string;
driver: Driver;
passenger: Passenger;
route: Route;
status: 'requested' | 'accepted' | 'completed' | 'cancelled';
accept(driver: Driver) {
if (this.status !== 'requested') throw new Error('Invalid state');
this.driver = driver;
this.status = 'accepted';
}
complete(endLocation: Location) {
this.route.setEndLocation(endLocation);
this.status = 'completed';
}
}
// Repository interface
export interface RideRepository {
findById(id: string): Promise<Ride | null>;
findByPassengerId(passengerId: string): Promise<Ride[]>;
findActiveRideForDriver(driverId: string): Promise<Ride | null>;
save(ride: Ride): Promise<void>;
remove(id: string): Promise<void>;
}
// Usage in service
export class RideService {
constructor(private rideRepo: RideRepository) {}
async acceptRide(rideId: string, driver: Driver) {
const ride = await this.rideRepo.findById(rideId);
if (!ride) throw new Error('Ride not found');
ride.accept(driver);
await this.rideRepo.save(ride);
}
}
Notice: we never query for Driver, Passenger, or Route directly. They're loaded as part of the Ride aggregate. This keeps your repository interface focused and your queries intentional.
Don't leak queries — Returning IQueryable everywhere defeats the purpose
This is the most common anti-pattern I see in codebases using Entity Framework Core. Developers expose IQueryable to avoid writing query methods in the repository.
// Bad: Query logic leaks into the service
export class OrderRepository {
async getOrders(query: IQueryable<Order>) {
return query.Where(o => o.Status == 'completed').ToListAsync();
}
}
// Service has to know EF syntax
const orders = await orderRepo.getOrders(
dbContext.Orders
.Include(o => o.Items)
.Where(o => o.TotalAmount > threshold)
);
This defeats the entire purpose of a repository. Your service is now tightly coupled to EF Core. Testing becomes hard. Switching databases becomes impossible.
The right approach is to write explicit query methods:
// Good: Repository encapsulates the query
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
findByStatus(status: string): Promise<Order[]>;
findHighValueCompletedOrders(threshold: number): Promise<Order[]>;
save(order: Order): Promise<void>;
}
export class PostgresOrderRepository implements OrderRepository {
constructor(private db: Database) {}
async findHighValueCompletedOrders(threshold: number): Promise<Order[]> {
const rows = await this.db.query(
`SELECT * FROM orders WHERE status = $1 AND total_amount > $2`,
['completed', threshold]
);
return rows.map(row => Order.fromDatabase(row));
}
}
// Service is clean and testable
const orders = await orderRepo.findHighValueCompletedOrders(1000);
By writing explicit methods, you control exactly what queries exist. Your service doesn't need to know SQL or EF syntax. And when you swap PostgreSQL for Elasticsearch, you only change the repository implementation.
Optional with ORMs — EF Core and Prisma already provide much of this
Here's the honest truth: modern ORMs like Entity Framework Core, Prisma, and TypeORM already give you much of what the Repository pattern provides. Their query builders abstract persistence details, and their DbContext or client objects are essentially repositories themselves.
So is the Repository pattern still necessary? It depends on your project.
Use the Repository pattern when:
- You have complex business logic that needs testing without hitting a real database
- You might swap databases or add multiple data sources (PostgreSQL + Redis + Elasticsearch)
- Your team is large and needs clear boundaries between domains
- You're building a trading platform, ride-hailing system, or other mission-critical app where data access logic is non-trivial
Skip the Repository pattern when:
- You're building a simple CRUD API where queries map 1:1 to database tables
- You're using Next.js with Prisma for a small project where the ORM client is enough
- Your team is small and moving fast matters more than formal structure
In a recent SaaS product I built with NestJS and TypeORM, we skipped explicit repository classes for most entities. TypeORM's repository object was sufficient. But for our core billing and subscription logic, we wrote explicit repositories to handle the complex state transitions and audit requirements.
The middle ground is pragmatism: use what your ORM gives you for straightforward queries, and add Repository interfaces only where complexity justifies it.
Common mistakes
Creating a repository for every table: You end up with OrderRepository, LineItemRepository, PaymentRepository, ShipmentRepository all doing trivial things. Instead, load them through the Order aggregate repository.
Making repositories too generic: A repository should know your domain. Methods like findByAnyField or getAll are red flags. If you need a method, add it explicitly.
Leaking ORM details through return types: Never return IQueryable, DbSet, or ORM-specific types from your repository. Always return domain objects.
Mixing query logic and persistence logic: If you're doing complex transformations after fetching data, move that into the domain object or a separate service. The repository should handle loading and saving.
Skipping the interface: Always define your repository as an interface. That's what makes testing and swapping implementations possible.
Key takeaways
- The Repository pattern is a collection-like interface over persistence that keeps data access logic separate from business logic
- Design repositories around aggregate roots, not database tables—one repository per aggregate, not per table
- Write explicit query methods instead of leaking IQueryable; your service shouldn't know SQL or ORM syntax
- Modern ORMs like Prisma and EF Core provide much of this already; use repositories where complexity justifies the abstraction
- Always define repositories as interfaces so you can test without a database and swap implementations without changing your service code






Comments
Loading comments…