The Repository Pattern
The repository pattern puts a single, intention-revealing object between your business logic and the database. Instead of scattering SQL strings, ORM calls, or fetch requests through your services, you expose a small collection-like interface—findById, save, delete—and hide how the data is stored behind it. The payoff is decoupling: your domain code talks to an abstraction, so you can swap Postgres for an in-memory map in tests, or change query engines without touching a line of business logic.
What problem it solves
When data access leaks into your services, two things rot quickly. First, the same query logic gets copy-pasted across handlers, and a schema change means hunting through the codebase. Second, every test that touches business logic now needs a real database, which makes tests slow, flaky, and hard to set up.
A repository fixes both. It centralizes persistence for one entity in one place, and it presents that entity as a conceptual collection—you “get” and “put” domain objects, not rows. The service layer no longer knows whether data lives in SQL, a document store, or a remote API.
A repository deals in domain objects, not raw rows. It maps database shapes (snake_case columns, foreign keys) to clean application objects on the way out, and back again on the way in. Keep that mapping inside the repository.
Defining the interface
Start with the shape of what callers need, independent of any database. In plain JavaScript the “interface” is a documented contract; every implementation must provide the same methods with the same signatures.
// user-repository.js — the contract every implementation honors:
// findById(id) -> Promise<User | null>
// findByEmail(email) -> Promise<User | null>
// save(user) -> Promise<User>
// delete(id) -> Promise<boolean>
export class UserRepository {
async findById(id) { throw new Error("not implemented"); }
async findByEmail(email) { throw new Error("not implemented"); }
async save(user) { throw new Error("not implemented"); }
async delete(id) { throw new Error("not implemented"); }
}
A concrete SQL implementation
This Postgres-backed repository uses the pg driver. Note how it translates between the users table and a clean User domain object, so callers never see column names like created_at.
// postgres-user-repository.js
import { UserRepository } from "./user-repository.js";
function toDomain(row) {
if (!row) return null;
return { id: row.id, email: row.email, name: row.name, createdAt: row.created_at };
}
export class PostgresUserRepository extends UserRepository {
constructor(pool) {
super();
this.pool = pool; // a `pg` Pool, injected from outside
}
async findById(id) {
const { rows } = await this.pool.query(
"SELECT id, email, name, created_at FROM users WHERE id = $1",
[id],
);
return toDomain(rows[0]);
}
async findByEmail(email) {
const { rows } = await this.pool.query(
"SELECT id, email, name, created_at FROM users WHERE email = $1",
[email],
);
return toDomain(rows[0]);
}
async save(user) {
const { rows } = await this.pool.query(
`INSERT INTO users (email, name) VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id, email, name, created_at`,
[user.email, user.name],
);
return toDomain(rows[0]);
}
async delete(id) {
const { rowCount } = await this.pool.query(
"DELETE FROM users WHERE id = $1",
[id],
);
return rowCount > 0;
}
}
The service that consumes this repository depends only on the four methods—never on pg, SQL, or the table layout.
// user-service.js
export class UserService {
constructor(users) {
this.users = users; // any UserRepository
}
async register(email, name) {
const existing = await this.users.findByEmail(email);
if (existing) throw new Error("Email already registered");
return this.users.save({ email, name });
}
}
Swapping implementations for tests
Because UserService accepts any object that satisfies the contract, tests can pass an in-memory repository backed by a Map. No database, no migrations, no cleanup—just fast, deterministic logic tests.
// in-memory-user-repository.js
import { UserRepository } from "./user-repository.js";
export class InMemoryUserRepository extends UserRepository {
#rows = new Map();
#seq = 0;
async findById(id) {
return this.#rows.get(id) ?? null;
}
async findByEmail(email) {
for (const u of this.#rows.values()) {
if (u.email === email) return u;
}
return null;
}
async save(user) {
const id = user.id ?? ++this.#seq;
const saved = { ...user, id, createdAt: user.createdAt ?? new Date() };
this.#rows.set(id, saved);
return saved;
}
async delete(id) {
return this.#rows.delete(id);
}
}
// user-service.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { UserService } from "./user-service.js";
import { InMemoryUserRepository } from "./in-memory-user-repository.js";
test("rejects duplicate email", async () => {
const service = new UserService(new InMemoryUserRepository());
await service.register("[email protected]", "Ada");
await assert.rejects(
() => service.register("[email protected]", "Ada Again"),
/already registered/,
);
});
Run it with Node’s built-in test runner:
node --test
Output:
✔ rejects duplicate email (1.4ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
Repository vs. DAO
The two terms overlap, and teams often use them interchangeably, but there is a useful distinction.
| Aspect | Repository | DAO (Data Access Object) |
|---|---|---|
| Mental model | A collection of domain objects | A gateway to a table or data source |
| Returns | Rich domain entities | Rows / DTOs close to the schema |
| Granularity | Often one per aggregate root | Often one per table |
| Focus | Domain language (findActiveCustomers) | CRUD operations on storage |
In small Node.js apps the line blurs; what matters is that both keep persistence concerns out of your services.
Resist adding a generic
query(sql)escape hatch to your repository. The moment callers pass raw SQL, the abstraction leaks and the database is back in your business logic. Add a named method instead.
CommonJS note
The same design works under CommonJS—replace export class X with class X {} plus module.exports = { X }, and import with require. Since a repository is constructed with its dependencies (the connection pool) rather than created as a module-level singleton, it composes cleanly with either module system.
Best Practices
- Keep one repository per aggregate root, and name methods in domain terms (
findOverdueInvoices) rather than storage terms. - Inject the database connection or pool through the constructor so the repository never reaches for a global—this is what makes test doubles possible.
- Do the row-to-domain mapping inside the repository; callers should never see column names or driver-specific types.
- Return
null(or an empty array) for “not found” rather than throwing, and reserve exceptions for genuine failures. - Never leak raw query strings or ORM objects out of the repository—if it returns a query builder, the abstraction has failed.
- Maintain an in-memory implementation alongside the real one so the whole service layer is testable without a database.
- Keep transactions in a layer above the repository when an operation spans several repositories, so each repository stays focused on its own entity.