The Repository Pattern: Pros, Cons, and When to Skip It
The repository pattern gets recommended by default in a lot of backend tutorials, right alongside dependency injection, as if skipping it were obviously wrong. It isn't. It's a genuinely useful pattern for a specific set of problems, and genuinely unnecessary overhead for a lot of applications that adopt it anyway out of habit.
What it actually is
A repository is an abstraction between your domain/business logic and your data access code. Instead of your service layer calling an ORM or writing SQL directly, it calls a repository interface, and the repository is the only thing that knows how the data is actually stored.
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
A concrete implementation might use an ORM:
class PostgresUserRepository implements UserRepository {
constructor(private db: Pool) {}
async findById(id: string): Promise<User | null> {
const { rows } = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return rows[0] ? mapRowToUser(rows[0]) : null;
}
async findByEmail(email: string): Promise<User | null> {
const { rows } = await this.db.query('SELECT * FROM users WHERE email = $1', [email]);
return rows[0] ? mapRowToUser(rows[0]) : null;
}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, name = $3',
[user.id, user.email, user.name],
);
}
}
Your service code depends only on UserRepository, never on Pool or SQL directly:
class RegisterUserService {
constructor(private users: UserRepository) {}
async register(email: string, name: string): Promise<User> {
const existing = await this.users.findByEmail(email);
if (existing) throw new Error('email already registered');
const user = new User(crypto.randomUUID(), email, name);
await this.users.save(user);
return user;
}
}
The case for it
Testability without a real database. A fake implementation of UserRepository is trivial to write and needs no test containers, no migrations, no network:
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async findById(id: string) {
return this.users.get(id) ?? null;
}
async findByEmail(email: string) {
return [...this.users.values()].find((u) => u.email === email) ?? null;
}
async save(user: User) {
this.users.set(user.id, user);
}
}
Tests against RegisterUserService run in milliseconds and don't need any infrastructure.
A real seam when storage genuinely changes. If you migrate from Postgres to DynamoDB, or add a caching layer in front of reads, the repository interface is the one place that changes — every caller is unaffected because they never depended on the storage mechanism to begin with.
A single place to enforce query conventions. Soft-delete filtering, tenant scoping in a multi-tenant system, consistent column mapping — centralizing these in the repository means every caller gets them automatically instead of every call site needing to remember.
The case against it
It's an abstraction for a change that mostly doesn't happen. In practice, very few applications actually swap their database engine. The repository pattern's biggest selling point — "you can change persistence without touching business logic" — is insurance against an event that, for most projects, never occurs. You pay the cost (an extra interface, an extra implementation, an extra layer to navigate when reading code) every day for a benefit you may never collect.
Modern ORMs already isolate you from raw SQL. Part of the original motivation for the repository pattern was keeping SQL out of business logic. An ORM's query builder already does that. Wrapping an ORM call in a repository method that does nothing but forward the call adds a layer without adding isolation you didn't already have:
// This "repository" method isn't abstracting anything the ORM wasn't already abstracting.
async findById(id: string) {
return this.db.user.findUnique({ where: { id } });
}
It can hide the actual query being run behind a name that doesn't tell you its cost. orderRepository.findActiveWithLineItems() looks innocent at the call site. Whether it's one indexed query or an N+1 loop over line items is invisible until you go read the implementation — the abstraction that was supposed to make code easier to reason about ends up making performance characteristics harder to see.
Most test suites don't actually need to swap the database out. Test containers running a real Postgres in Docker have gotten fast and reliable enough that "test without a real database" is a weaker argument than it used to be — and testing against the real thing catches issues (a bad index, a constraint violation, a real query plan) that an in-memory fake can't.
A middle ground
For a small-to-medium application talking to one database it's not realistically going to swap out, a thin data-access layer built directly on your ORM's query builder — without a repository interface behind it — gets most of the organizational benefit (queries centralized, not scattered through business logic) without the extra indirection:
// db/users.ts — plain functions, no interface, no DI required
export async function findUserByEmail(db: PrismaClient, email: string) {
return db.user.findUnique({ where: { email } });
}
export async function saveUser(db: PrismaClient, user: User) {
return db.user.upsert({
where: { id: user.id },
update: user,
create: user,
});
}
Tests can still swap in a test database or use your ORM's transaction-rollback-per-test pattern, without needing an interface built specifically to enable swapping.
When to actually reach for it
- You're building a library or module that genuinely needs to support multiple backends (a self-hosted app that must run against both Postgres and SQLite, for instance).
- The domain logic is complex enough that you want unit tests with zero I/O, and a real test database is too slow or unavailable in your CI environment.
- You're deliberately doing Domain-Driven Design with a rich domain model that shouldn't know persistence exists at all.
Outside of those, a repository interface with exactly one implementation you'll ever write is complexity paid for on credit against a future that, for most projects, doesn't arrive.