What is the Repository Pattern?
The Repository Pattern mediates between the domain and data mapping layers, acting like an in-memory collection of domain objects. It encapsulates the logic required to access data sources, centralizing data access functionality and providing a clean separation between the domain model and the data access code.
Benefits
- Testability: Swap the real repository with an in-memory implementation for unit tests
- Decoupling: Domain logic does not depend on database technology — switch databases without touching business code
- Single responsibility: Data access concerns are isolated from business logic
- Consistency: A single place to add caching, logging, or query optimization for each entity type
Repository Interface (Port)
// Generic repository interface
interface Repository {
findById(id: TId): Promise;
findAll(): Promise;
save(entity: T): Promise;
delete(id: TId): Promise;
exists(id: TId): Promise;
}
// Domain-specific repository with custom queries
interface UserRepository extends Repository {
findByEmail(email: string): Promise;
findByRole(role: UserRole): Promise;
findActive(): Promise;
countByRole(role: UserRole): Promise;
}
interface OrderRepository extends Repository {
findByCustomer(customerId: CustomerId): Promise;
findByStatus(status: OrderStatus): Promise;
findRecent(limit: number): Promise;
getTotalRevenue(since: Date): Promise;
}
// Specification pattern for complex queries
interface Specification {
isSatisfiedBy(entity: T): boolean;
toQuery(): QueryCondition; // Database-specific query
}
class ActiveUsersInRole implements Specification {
constructor(private readonly role: UserRole) {}
isSatisfiedBy(user: User): boolean {
return user.isActive && user.role === this.role;
}
toQuery(): QueryCondition {
return { is_active: true, role: this.role };
}
}
Concrete Implementations
// PostgreSQL implementation
class PostgresUserRepository implements UserRepository {
constructor(private readonly pool: Pool) {}
async findById(id: UserId): Promise {
const result = await this.pool.query(
"SELECT * FROM users WHERE id = $1",
[id.value]
);
return result.rows[0] ? this.toDomain(result.rows[0]) : null;
}
async findAll(): Promise {
const result = await this.pool.query("SELECT * FROM users ORDER BY created_at");
return result.rows.map(row => this.toDomain(row));
}
async findByEmail(email: string): Promise {
const result = await this.pool.query(
"SELECT * FROM users WHERE email = $1",
[email]
);
return result.rows[0] ? this.toDomain(result.rows[0]) : null;
}
async findByRole(role: UserRole): Promise {
const result = await this.pool.query(
"SELECT * FROM users WHERE role = $1",
[role]
);
return result.rows.map(row => this.toDomain(row));
}
async findActive(): Promise {
const result = await this.pool.query(
"SELECT * FROM users WHERE is_active = true"
);
return result.rows.map(row => this.toDomain(row));
}
async save(user: User): Promise {
await this.pool.query(
`INSERT INTO users (id, email, name, role, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (id) DO UPDATE SET
email = $2, name = $3, role = $4, is_active = $5, updated_at = NOW()`,
[user.id.value, user.email, user.name, user.role, user.isActive, user.createdAt]
);
}
async delete(id: UserId): Promise {
await this.pool.query("DELETE FROM users WHERE id = $1", [id.value]);
}
async exists(id: UserId): Promise {
const result = await this.pool.query(
"SELECT 1 FROM users WHERE id = $1", [id.value]
);
return result.rows.length > 0;
}
async countByRole(role: UserRole): Promise {
const result = await this.pool.query(
"SELECT COUNT(*) FROM users WHERE role = $1", [role]
);
return parseInt(result.rows[0].count);
}
// Map database row to domain entity
private toDomain(row: Record): User {
return new User(
new UserId(row.id as string),
row.email as string,
row.name as string,
row.role as UserRole,
row.is_active as boolean,
row.created_at as Date
);
}
}
// In-memory implementation for testing
class InMemoryUserRepository implements UserRepository {
private users = new Map();
async findById(id: UserId): Promise {
return this.users.get(id.value) || null;
}
async findAll(): Promise {
return [...this.users.values()];
}
async findByEmail(email: string): Promise {
return [...this.users.values()].find(u => u.email === email) || null;
}
async findByRole(role: UserRole): Promise {
return [...this.users.values()].filter(u => u.role === role);
}
async findActive(): Promise {
return [...this.users.values()].filter(u => u.isActive);
}
async save(user: User): Promise {
this.users.set(user.id.value, user);
}
async delete(id: UserId): Promise {
this.users.delete(id.value);
}
async exists(id: UserId): Promise {
return this.users.has(id.value);
}
async countByRole(role: UserRole): Promise {
return [...this.users.values()].filter(u => u.role === role).length;
}
}
Repository with Caching
// Decorator pattern: Add caching to any repository
class CachedUserRepository implements UserRepository {
private cache = new Map();
constructor(
private readonly inner: UserRepository,
private readonly ttlMs: number = 60000
) {}
async findById(id: UserId): Promise {
const cached = this.cache.get(id.value);
if (cached && cached.expiresAt > Date.now()) {
return cached.user;
}
const user = await this.inner.findById(id);
if (user) {
this.cache.set(id.value, { user, expiresAt: Date.now() + this.ttlMs });
}
return user;
}
async save(user: User): Promise {
await this.inner.save(user);
// Invalidate cache on write
this.cache.delete(user.id.value);
}
async delete(id: UserId): Promise {
await this.inner.delete(id);
this.cache.delete(id.value);
}
// Delegate remaining methods
findAll = () => this.inner.findAll();
findByEmail = (email: string) => this.inner.findByEmail(email);
findByRole = (role: UserRole) => this.inner.findByRole(role);
findActive = () => this.inner.findActive();
exists = (id: UserId) => this.inner.exists(id);
countByRole = (role: UserRole) => this.inner.countByRole(role);
}
// Usage — compose repositories with caching
const pgRepo = new PostgresUserRepository(pool);
const cachedRepo = new CachedUserRepository(pgRepo, 30000); // 30s cache
const useCase = new GetUserUseCase(cachedRepo);
Repository Anti-Patterns
- Generic-only repositories: Do not limit yourself to generic CRUD — add domain-specific query methods
- Leaking persistence concerns: Repository interfaces should not expose ORM-specific types or SQL fragments
- Too many methods: If a repository has 30+ methods, it may be doing too much — consider splitting by read/write (CQRS)
- Returning raw database rows: Always map to domain entities — the repository is the boundary between persistence and domain