Repository Pattern
The Repository pattern places a typed abstraction between your domain logic and the mechanism that actually stores data. Instead of sprinkling SQL queries, HTTP calls, or ORM specifics throughout your services, you funnel all persistence through a small interface that speaks in domain terms — findById, save, delete. Callers depend on the interface, not on Postgres or a REST endpoint, so storage can change without touching business rules. In TypeScript a repository is naturally generic, giving you reusable CRUD contracts with full type safety. This page shows the interface, an in-memory and a real implementation, and where the pattern helps or hurts.
The problem it solves
When services talk to the database directly, persistence details leak everywhere. Tests need a live database, swapping data sources means a rewrite, and the same query logic is duplicated across call sites.
// ❌ Business logic welded to the database driver
async function deactivateUser(id: string) {
const rows = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!rows[0]) throw new Error("not found");
await db.query("UPDATE users SET active = false WHERE id = $1", [id]);
}
This function cannot be unit-tested without a database, and the SQL string is repeated in every place that loads a user. A repository hides those details behind a method call.
Defining the repository contract
Start with a generic interface for the operations every aggregate needs. Keeping it generic means each entity gets the same predictable surface.
interface Repository<T, ID = string> {
findById(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: ID): Promise<void>;
}
interface User {
id: string;
email: string;
active: boolean;
}
interface UserRepository extends Repository<User> {
findByEmail(email: string): Promise<User | null>;
}
Repository<T, ID> captures the common CRUD shape, while UserRepository extends it with domain-specific queries. Returning T | null (rather than throwing) makes “not found” an explicit, type-checked outcome that callers must handle.
An in-memory implementation for tests
Because consumers depend only on the interface, a Map-backed implementation is a perfect test double — no database, fully deterministic.
class InMemoryUserRepository implements UserRepository {
private readonly store = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.store.get(id) ?? null;
}
async findAll(): Promise<User[]> {
return [...this.store.values()];
}
async findByEmail(email: string): Promise<User | null> {
return [...this.store.values()].find((u) => u.email === email) ?? null;
}
async save(user: User): Promise<User> {
this.store.set(user.id, user);
return user;
}
async delete(id: string): Promise<void> {
this.store.delete(id);
}
}
Tests instantiate this in microseconds and assert against real behavior. The ?? null conversions keep the implementation faithful to the interface’s T | null contract.
A real implementation
The production version implements the same interface against an actual data source. Swapping it in requires no change to any consumer.
class SqlUserRepository implements UserRepository {
constructor(private readonly db: { query: <R>(sql: string, p: unknown[]) => Promise<R[]> }) {}
async findById(id: string): Promise<User | null> {
const rows = await this.db.query<User>("SELECT * FROM users WHERE id = $1", [id]);
return rows[0] ?? null;
}
async findByEmail(email: string): Promise<User | null> {
const rows = await this.db.query<User>("SELECT * FROM users WHERE email = $1", [email]);
return rows[0] ?? null;
}
async findAll(): Promise<User[]> {
return this.db.query<User>("SELECT * FROM users", []);
}
async save(user: User): Promise<User> {
await this.db.query(
"INSERT INTO users (id, email, active) VALUES ($1,$2,$3) ON CONFLICT (id) DO UPDATE SET email = $2, active = $3",
[user.id, user.email, user.active],
);
return user;
}
async delete(id: string): Promise<void> {
await this.db.query("DELETE FROM users WHERE id = $1", [id]);
}
}
Now the service from earlier depends on UserRepository, accepts either implementation, and contains zero SQL — it is pure, testable domain logic.
When to use and pitfalls
| Use Repository when… | Reconsider when… |
|---|---|
| Domain logic must stay storage-agnostic | The app is a thin CRUD wrapper with no logic |
| You need fast, DB-free unit tests | A capable ORM already gives a repository-like API |
| Multiple data sources back one entity | The abstraction only ever has one implementation |
| You want a single place for query logic | Leaky methods re-expose storage details |
A repository that returns ORM entities or query builders leaks its implementation. Return plain domain objects so callers stay decoupled from how data is fetched.
Use the Repository pattern when business rules are substantial and you value testability and the freedom to change storage. Skip it for trivial apps where it just wraps the ORM in another layer that adds indirection without insulating anything.
Best Practices
- Define repositories as interfaces; depend on the interface, never the concrete class.
- Keep methods in domain language (
findActiveCustomers), not storage language. - Return domain objects or
null, never ORM entities or raw query builders. - Provide an in-memory implementation to make unit tests fast and deterministic.
- Make “not found” explicit with
T | nullinstead of throwing for control flow. - Inject the repository so production and test wiring choose the implementation.
- Avoid one giant repository; scope each to a single aggregate root.