Loading content...
The repository interface is perhaps the most important surface in your domain layer. It defines the contract between your domain logic and the outside world of persistence. A well-designed repository interface empowers domain services to express business operations cleanly. A poorly designed one leaks infrastructure concerns throughout your codebase.
This is not merely about method signatures—it's about creating an API that speaks the language of the domain. Every method name, every parameter type, every return value sends a message about what your domain cares about. The interface should be so expressive that someone reading it understands the domain's persistence needs without ever seeing the implementation.
This page covers the principles and practices of repository interface design. You'll learn how to name methods, choose parameter and return types, handle null/absent values, balance specificity with genericity, and avoid common design mistakes.
Before diving into method design, let's establish where repository interfaces live and how they're structured.
Location: Domain Layer
Repository interfaces are defined in the domain layer. This is non-negotiable in DDD. The interface represents what the domain needs from persistence, not what a database provides. By placing it in the domain layer, we achieve:
123456789101112131415161718192021
src/├── domain/│ ├── model/│ │ └── order/│ │ ├── Order.ts # Aggregate root│ │ ├── OrderId.ts # Value object (identity)│ │ ├── OrderItem.ts # Entity within aggregate│ │ └── OrderStatus.ts # Value object (enum-like)│ ││ └── repository/ # Repository interfaces live here│ ├── OrderRepository.ts # Interface for Order aggregate│ └── CustomerRepository.ts # Interface for Customer aggregate│├── infrastructure/│ └── persistence/│ ├── OrderRepositoryImpl.ts # Implementation (SQL/ORM)│ └── CustomerRepositoryImpl.ts│└── application/ └── services/ └── OrderApplicationService.ts # Uses repository interfacesNaming Conventions
The interface should clearly communicate its role:
OrderRepository (not IOrderRepository or OrderRepositoryInterface)PostgresOrderRepository, MongoOrderRepository, or simply OrderRepositoryImplThe prefix/suffix conventions vary by language and team. The key point is that the domain code references the interface, never the implementation.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// domain/repository/OrderRepository.ts import { Order } from '../model/order/Order';import { OrderId } from '../model/order/OrderId';import { CustomerId } from '../model/customer/CustomerId'; /** * Repository for Order aggregates. * * Provides collection-like access to all orders in the system. * Implementations handle actual persistence to various storage backends. */export interface OrderRepository { // ---------- Identity-based retrieval ---------- /** * Finds an order by its unique identifier. * @returns The order if found, null otherwise */ findById(orderId: OrderId): Promise<Order | null>; // ---------- Domain query methods ---------- /** * Finds all orders placed by a specific customer. */ findByCustomer(customerId: CustomerId): Promise<Order[]>; /** * Finds all orders awaiting processing. */ findPendingOrders(): Promise<Order[]>; // ---------- Existence checks ---------- /** * Checks if an order with the given ID exists. */ exists(orderId: OrderId): Promise<boolean>; // ---------- Collection operations ---------- /** * Adds a new order to the repository. * @throws If an order with the same ID already exists */ add(order: Order): Promise<void>; /** * Removes an order from the repository. */ remove(order: Order): Promise<void>;}Method names are the primary way repository interfaces communicate domain intent. Thoughtful naming elevates the interface from a data access tool to a domain-aligned contract.
Principle 1: Use Domain Language
Method names should use terminology from the ubiquitous language of your bounded context. If domain experts talk about "pending orders," your method is findPendingOrders(), not findByStatus('PENDING').
findPendingOrders()findOverdueInvoices()findActiveSubscriptions()findExpiredTrials()findOrdersAwaitingShipment()findByStatusEquals('PENDING')findByDueDateBefore(now)findByActiveTrue()findByTrialEndDateBefore(now)findByStatusAndShippedAtNull()Principle 2: Prefer Specific Methods Over Generic Parameters
Rather than creating a generic findBy(criteria) method, create specific methods for each domain use case. Specific methods:
12345678910111213141516171819
// ✅ GOOD: Specific methods for each use caseinterface OrderRepository { findPendingOrders(): Promise<Order[]>; findByCustomer(customerId: CustomerId): Promise<Order[]>; findOrdersPlacedBetween(start: Date, end: Date): Promise<Order[]>; findHighValueOrders(minimumTotal: Money): Promise<Order[]>;} // ❌ AVOID: Generic query methodsinterface OrderRepository { // This leaks query concerns into the domain findBy(criteria: QueryCriteria): Promise<Order[]>; // This exposes SQL concepts findBySpecification(spec: Specification<Order>): Promise<Order[]>; // This is too flexible - encourages unpredictable queries query(filter: Partial<OrderFilter>): Promise<Order[]>;}If your domain genuinely needs flexible querying (e.g., a search feature with many optional filters), consider the Specification pattern or a separate Query Service outside the repository. Keep the repository focused on aggregate persistence.
Principle 3: Use Consistent Verb Prefixes
Establish consistent naming conventions across all repositories:
| Prefix | Meaning | Example |
|---|---|---|
findById | Retrieve by identity | findById(OrderId) |
findXxx | Query returning multiple | findPendingOrders() |
get | Retrieve, throw if missing | getById(OrderId) |
exists | Check existence | exists(OrderId) |
count | Count aggregates | countPendingOrders() |
add | Insert new aggregate | add(Order) |
remove | Delete aggregate | remove(Order) |
save | Persist changes (if used) | save(Order) |
The types used in repository method signatures significantly impact interface quality. Every type choice should reinforce domain concepts and prevent programming errors.
Use Value Objects for Identities
Never use primitive types (string, number) for aggregate identities. Use typed value objects that prevent mix-ups and express domain meaning.
1234567891011121314151617181920212223242526272829303132333435363738394041
// ❌ WEAK: Primitive identitiesinterface OrderRepository { findById(orderId: string): Promise<Order | null>; findByCustomer(customerId: string): Promise<Order[]>;} // Problems:// - Easy to pass wrong ID type: findById(customerId) compiles fine// - No domain meaning: it's just a string// - No validation: any string is accepted // ✅ STRONG: Value object identitiesinterface OrderRepository { findById(orderId: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>;} // Benefits:// - Type safety: findById(customerId) won't compile// - Domain clarity: OrderId expresses what it represents// - Validation: OrderId constructor can validate format // Value object implementationclass OrderId { private constructor(private readonly value: string) {} static create(value: string): OrderId { if (!value || value.trim() === '') { throw new InvalidOrderIdError(value); } return new OrderId(value); } get id(): string { return this.value; } equals(other: OrderId): boolean { return this.value === other.value; }}Return Complete Aggregates, Not Fragments
Repository methods that retrieve aggregates must return complete aggregates—the root plus all entities and value objects within the aggregate boundary. Returning partial aggregates violates aggregate invariants.
1234567891011121314151617
// ✅ CORRECT: Returns complete aggregateinterface OrderRepository { findById(orderId: OrderId): Promise<Order | null>; // Order includes OrderItems, ShippingAddress, PaymentInfo, etc.} // ❌ WRONG: Returns partial datainterface OrderRepository { // Don't do this - breaks aggregate boundaries findOrderWithoutItems(orderId: OrderId): Promise<PartialOrder | null>; // Don't do this - items should be accessed through the aggregate findItemsByOrder(orderId: OrderId): Promise<OrderItem[]>; // Don't do this - address is part of Order aggregate findShippingAddress(orderId: OrderId): Promise<ShippingAddress | null>;}If you need partial or projected data for display purposes (e.g., an order summary without items), use CQRS read models or projection services—not repositories. Repositories are for command-side aggregate access.
Handle Absence Explicitly
When an aggregate might not exist, make the absence explicit in the return type. Different languages have different idioms:
T | null or T | undefinedOptional<T>T? (nullable reference types)T?12345678910111213141516171819202122232425
interface OrderRepository { // Explicitly nullable - caller must handle absence findById(orderId: OrderId): Promise<Order | null>; // Throws if not found - use when absence is exceptional getById(orderId: OrderId): Promise<Order>;} // Usage patterns:class OrderService { async cancelOrder(orderId: OrderId): Promise<void> { // Using findById - we handle the null case const order = await this.orderRepository.findById(orderId); if (!order) { throw new OrderNotFoundException(orderId); } order.cancel(); } async processNextPendingOrder(): Promise<void> { // Using getById - absence would be a bug, not expected const order = await this.orderRepository.getById(this.queuedOrderId); order.process(); }}Beyond basic CRUD, repositories expose query methods tailored to domain needs. Here are proven patterns for designing these queries.
Pattern 1: Finder Methods
Finder methods return collections of aggregates matching specific criteria. They encapsulate filtering logic and return domain objects.
123456789101112131415
interface OrderRepository { // Simple finders - return all matching findByCustomer(customerId: CustomerId): Promise<Order[]>; findPendingOrders(): Promise<Order[]>; // Parameterized finders findOrdersPlacedAfter(date: Date): Promise<Order[]>; findOrdersWithTotalAbove(minimum: Money): Promise<Order[]>; // Compound criteria findPendingOrdersForCustomer(customerId: CustomerId): Promise<Order[]>; // Ordering (when domain-relevant) findMostRecentOrders(limit: number): Promise<Order[]>;}Pattern 2: Existence Checks
When you only need to know if an aggregate exists—not retrieve it—use explicit existence methods. These are more efficient than retrieving and checking for null.
12345678910111213141516171819
interface OrderRepository { // Simple existence exists(orderId: OrderId): Promise<boolean>; // Domain-meaningful existence hasCustomerPlacedOrderBefore(customerId: CustomerId): Promise<boolean>; hasUnshippedOrders(customerId: CustomerId): Promise<boolean>;} // Usageclass CustomerService { async determineCustomerTier(customerId: CustomerId): Promise<CustomerTier> { // Efficient - doesn't load any orders const hasOrdered = await this.orderRepository .hasCustomerPlacedOrderBefore(customerId); return hasOrdered ? CustomerTier.RETURNING : CustomerTier.NEW; }}Pattern 3: Count Methods
Similar to existence, count methods return aggregate counts without loading data. Use when counts have domain meaning.
12345678910111213141516
interface OrderRepository { // Simple counting countPendingOrders(): Promise<number>; countByCustomer(customerId: CustomerId): Promise<number>; // Domain-specific counting countUnfulfilledOrdersForDate(date: Date): Promise<number>;} // Usage - domain logic that needs countsclass FulfillmentCapacityService { async canAcceptNewOrders(): Promise<boolean> { const pending = await this.orderRepository.countPendingOrders(); return pending < this.maxCapacity; }}If you need counts primarily for dashboards or reporting, consider dedicated read models rather than repository methods. Repositories serve the command side; complex analytics belong in separate query services.
Knowing what to exclude from repository interfaces is as important as knowing what to include. Several common additions actually degrade interface quality.
findBy(criteria) or query(filter) leak query construction into domain code.executeQuery(sql) completely breaks the abstraction.updateAll(predicate, changes) bypasses aggregates and their invariants.beginTransaction(), commit() belong to Unit of Work, not repository.getConnection(), close() are infrastructure concerns.findAll(page, size) is database-centric; prefer domain-meaningful limits.1234567891011121314151617181920212223242526
// ❌ ANTI-PATTERNS - Don't include these interface BadOrderRepository { // Exposes query language findByQuery(jpql: string): Promise<Order[]>; executeNativeQuery(sql: string): Promise<unknown>; // Bypasses aggregates updateStatus(orderId: OrderId, status: OrderStatus): Promise<void>; updateAllExpiredOrders(): Promise<number>; bulkDelete(orderIds: OrderId[]): Promise<void>; // Transaction management beginTransaction(): Promise<void>; commit(): Promise<void>; rollback(): Promise<void>; // Infrastructure leakage getConnection(): DatabaseConnection; setQueryTimeout(ms: number): void; enableCache(): void; // Over-generic operations findByCriteria(criteria: Record<string, unknown>): Promise<Order[]>; findByExample(example: Partial<Order>): Promise<Order[]>;}Why Batch Operations Are Problematic
Batch update/delete operations like updateAll() or deleteWhere() are tempting for performance, but they:
If you genuinely need batch operations, implement them in the aggregate—loading each, modifying, and saving—or reconsider your aggregate design.
Direct SQL updates are faster than loading aggregates. But correctness trumps performance. First, make it correct with proper aggregates. Then, profile. If specific bulk operations are bottlenecks, optimize surgically with explicit batch jobs—not by compromising the domain model.
A common question in repository design: should you use a generic repository base type or craft specific repositories for each aggregate?
TL;DR: Prefer specific repositories, but consider generic bases for common infrastructure.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// ✅ GOOD: Generic base as IMPLEMENTATION detail // Domain layer - specific interfacesinterface OrderRepository { findById(orderId: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>; findPendingOrders(): Promise<Order[]>; add(order: Order): Promise<void>; remove(order: Order): Promise<void>;} interface CustomerRepository { findById(customerId: CustomerId): Promise<Customer | null>; findByEmail(email: Email): Promise<Customer | null>; add(customer: Customer): Promise<void>; remove(customer: Customer): Promise<void>;} // Infrastructure layer - generic base for implementation reuseabstract class BaseRepository<T extends AggregateRoot, TId> { constructor(protected readonly orm: ORM) {} protected async findByIdInternal(id: TId): Promise<T | null> { // Common ORM logic } protected async addInternal(entity: T): Promise<void> { // Common ORM logic } protected async removeInternal(entity: T): Promise<void> { // Common ORM logic }} // Specific implementation extends baseclass OrderRepositoryImpl extends BaseRepository<Order, OrderId> implements OrderRepository { async findById(orderId: OrderId): Promise<Order | null> { return this.findByIdInternal(orderId); } // Domain-specific methods unique to OrderRepository async findByCustomer(customerId: CustomerId): Promise<Order[]> { return this.orm.orders.findMany({ where: { customerId: customerId.value } }); } async findPendingOrders(): Promise<Order[]> { return this.orm.orders.findMany({ where: { status: 'PENDING' } }); }}Exposing IRepository<T> directly to domain code is an anti-pattern. It provides CRUD operations that may not make sense for all aggregates and lacks domain-specific queries. Always define specific interfaces that expose only relevant operations.
Repository interfaces evolve as domain understanding deepens. Managing this evolution while maintaining system stability requires deliberate practices.
123456789101112131415161718192021222324252627282930313233343536
// Version 1: Initial interfaceinterface OrderRepository { findById(orderId: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>; add(order: Order): Promise<void>;} // Version 2: New business requirement - find pending ordersinterface OrderRepository { findById(orderId: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>; findPendingOrders(): Promise<Order[]>; // Added add(order: Order): Promise<void>;} // Version 3: Deprecation of broad queryinterface OrderRepository { findById(orderId: OrderId): Promise<Order | null>; /** * @deprecated Use findRecentOrdersByCustomer instead * Returns ALL orders which has performance implications */ findByCustomer(customerId: CustomerId): Promise<Order[]>; // New, more efficient method with limit findRecentOrdersByCustomer( customerId: CustomerId, limit: number ): Promise<Order[]>; findPendingOrders(): Promise<Order[]>; add(order: Order): Promise<void>;} // Version 4: Deprecated method removed after migration completeRepository interface design is where domain language meets persistence abstraction. Let's consolidate the key principles:
findPendingOrders() over findByStatus('PENDING').OrderId instead of string prevents errors and expresses intent.null, Optional, or throwing—be consistent.What's next:
With interface design principles established, the next page explores Repository Implementation Considerations—the infrastructure-side challenges of turning these clean interfaces into working persistence code.
You now have a comprehensive framework for designing repository interfaces. Remember: the interface is the contract your domain depends on. Design it with the same care you'd give to any public API.