Repository Pattern
Decouple business logic from data access by defining a collection-like interface for retrieving and persisting domain objects.
★★★★★5/5Inside a codebase — classes, modules, files
How it works
The Repository pattern, introduced by Martin Fowler in Patterns of Enterprise Application Architecture (2003), sits between the domain model and the data mapping layer. Domain objects interact with a repository interface as if it were an in-memory collection — they call save(), findById(), or delete() without knowing whether data is stored in Postgres, MongoDB, Redis, or anywhere else.
The interface lives in the domain layer. Concrete implementations (e.g. PgUserRepository) live in an adapters or infrastructure layer. Swapping the database requires writing a new adapter, not touching business logic.
This boundary also makes testing clean: replace the Postgres adapter with an InMemoryRepository in tests — no database, no slow I/O, full coverage of business logic.
Project structure
recommended layoutsrc/├── domain/│ └── user.ts # Entity — pure data + behaviour├── ports/│ └── user-repository.ts # Interface (the contract)├── adapters/│ ├── pg-user-repository.ts # Postgres implementation│ └── in-memory-user-repo.ts # Test / dev implementation└── services/└── user-service.ts # Business logic — depends on interface
Implementation
TypeScript · Go · Rust// ports/user-repository.ts
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}
// adapters/pg-user-repository.ts
export class PgUserRepository implements UserRepository {
constructor(private db: Pool) {}
async findById(id: string): Promise<User | null> {
const { rows } = await this.db.query(
"SELECT id, email, name FROM users WHERE id = $1", [id]
);
return 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]
);
}
}
// services/user-service.ts — depends on interface, not implementation
export class UserService {
constructor(private repo: UserRepository) {}
async changeEmail(id: string, newEmail: string): Promise<void> {
const user = await this.repo.findById(id);
if (!user) throw new Error("User not found");
user.email = newEmail;
await this.repo.save(user);
}
}
// bootstrap: swap PgUserRepository for InMemoryUserRepo in tests
const repo = new PgUserRepository(pool);
const service = new UserService(repo);Why it matters
SQL queries scattered through business logic are the leading cause of software that's hard to test, change, and reason about. A repository interface provides a clean seam between your domain and your database.
✓ When to use
- →Any application with a meaningful domain model and persistence
- →When you want to unit-test business logic without a database
- →When you anticipate switching or abstracting the storage backend
- →Paired with Clean Architecture, Hexagonal Architecture, or DDD
✗ When NOT to use
- →Simple CRUD apps with no real business logic — direct ORM calls are fine
- →Scripts or one-off tools where the overhead isn't worth it
Trade-offs
Business logic is fully testable without a real database
Extra abstraction layer adds boilerplate, especially for simple queries
Storage technology can be swapped transparently
Complex queries (joins, aggregations) feel awkward behind a collection interface
Clear boundary between domain and infrastructure
ORM-specific features (lazy loading, change tracking) may need workarounds
In production
Spring Data auto-generates repository implementations from interface definitions
Repository pattern commonly layered over Eloquent ORM in large Laravel apps
EF Core DbContext is itself a repository/unit-of-work; custom repos add domain abstraction
Industry adoption
Related principles
Clean Architecture
LiveOrganise code into concentric dependency rings so business logic never depends on frameworks, databases, or UI.
Dependency Injection
Supply a component's dependencies from the outside rather than letting it construct them — so implementations can be swapped without changing the component.
Hexagonal Architecture
Place the domain at the centre and connect all I/O (HTTP, database, messaging) through explicit ports and adapters — so the core is framework-free and infinitely testable.
Domain-Driven Design
Model software around the core business domain using a shared language between developers and domain experts.