Repository Pattern
Repository mediates between domain and persistence with a collection-like interface: findById, save, delete — without exposing SQL, ORM, or table shapes.
Introduction
Repository mediates between domain and persistence with a collection-like interface: findById, save, delete — without exposing SQL, ORM, or table shapes. Services speak domain language; infrastructure translates to Postgres, Mongo, or DynamoDB behind the port.
The story
Services called Mongoose directly — switching to Postgres required editing every service. Repositories during migration meant the second swap (Postgres → DynamoDB for hot tables) took days instead of weeks.
The business problem
Direct ORM/SQL in services couples domain to persistence technology:
- Migration cost: DB vendor change touches every service.
- Untestable services: tests need real database or heavy mocks.
- Query duplication: same fetch logic copied across services.
- Leaky persistence: ORM entities become application's domain model.
The problem teams faced
Repository fits when:
- Persistence technology may change or vary (SQL + cache + search).
- Application services should not contain SQL or ORM queries.
- Tests need in-memory or fake persistence seam.
- Query logic should be centralized per aggregate.
Understanding the topic
Intent: Collection-like interface for domain objects hiding persistence details.
- Repository interface — owned by domain/application layer.
- Concrete repository — ORM/SQL implementation in infrastructure.
- Aggregate root — repository typically one per aggregate (OrderRepository).
- Specification/query — optional pattern for complex queries.
Internal architecture
Order repository port + adapter:
interface OrderRepository {findById(id: OrderId): Promise<Order | null>save(order: Order): Promise<void>}class PostgresOrderRepository implements OrderRepository {async findById(id: OrderId) {const row = await db.query("SELECT ...", [id.value])return row ? OrderMapper.toDomain(row) : null}async save(order: Order) {await db.query("UPSERT ...", OrderMapper.toPersistence(order))}}
Visual explanation
Three diagrams cover repository seam, fake for tests, and vs DAO:
Informative example
Before / after — Mongoose in service to repository:
// ❌ Mongoose everywhere — migration nightmareclass OrderService {async get(id: string) {return OrderModel.findById(id) // Mongoose document leaks up}}// ✅ Repository hides persistenceinterface OrderRepository {findById(id: OrderId): Promise<Order | null>}class OrderService {constructor(private orders: OrderRepository) {}async get(id: OrderId) { return this.orders.findById(id) }}class InMemoryOrderRepository implements OrderRepository {private store = new Map<string, Order>()findById(id: OrderId) { return Promise.resolve(this.store.get(id.value) ?? null) }save(o: Order) { this.store.set(o.id.value, o); return Promise.resolve() }}
Execution workflow
Define interface
OrderRepository in domain/application package.
Real-world use
Spring Data JPA repositories, DDD aggregate repositories, Entity Framework DbContext as repository-ish, TypeORM custom repositories.
Production case study
Mongo → Postgres → Dynamo migration:
- First migration: Mongoose → Postgres via OrderRepository interface.
- Second migration: hot tables to Dynamo — new adapter, zero service edits.
- Metric: second migration days vs weeks without repository.
Trade-offs
- Pro: persistence swap; testable services; centralized queries.
- Con: mapping layer overhead; abstraction leak if interface mirrors tables.
- Con: generic repository anti-pattern tempts god interface.
Decision framework
- Use when persistence may change or varies per deployment.
- Use with Hexagonal/Clean — repository is driven adapter.
- Avoid generic Repository
for everything — per aggregate. - Skip when app is prototype with fixed SQLite forever.
Best practices
- One repository per aggregate root — not per table.
- Return domain objects — map ORM rows inside repository.
- Complex queries: specification pattern or query service — not SQL in service.
Anti-patterns to avoid
- Generic IRepository
with 20 methods — leaks persistence concerns. - Repository returning ORM entities to domain layer.
- Business logic inside repository — belongs in domain/service.
Common mistakes
- Repository interface mirrors database tables not domain model.
- N+1 queries hidden inside repository methods — profile adapters.
Debugging tips
- Log SQL at repository adapter boundary — not scattered in services.
Optimization strategies
- Read-optimized query repos separate from write repos when CQRS applies.
Common misconceptions
- Repository ≠ DAO. Repository speaks domain collection language; DAO is table-centric.
- Spring Data @Repository is repository pattern with code generation.
Advanced interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1IntermediateQuestionRepository vs Active Record?+
Answer
Follow-up
2AdvancedQuestionOne repo per table?+
Answer
Follow-up
3IntermediateQuestionTest without DB?+
Answer
Follow-up
Summary
You can define repository ports, implement adapters, and migrate databases without touching services. Tell the Mongo→Postgres→Dynamo days-not-weeks story — if you can do that, you own Repository.