Loading content...
A beautifully designed repository interface means nothing if its implementation is flawed. The implementation is where theory meets reality—where you wrestle with ORM quirks, optimize query performance, manage connections, and handle the edge cases that haunt production systems.
This page takes you from interface to production-ready implementation. We'll explore how to build repositories that are not just correct, but efficient, maintainable, and robust. You'll learn patterns that experienced engineers use to avoid common pitfalls and build persistence layers that scale.
By the end of this page, you will know how to implement repositories using modern ORMs (Entity Framework Core, Hibernate/JPA), handle aggregate loading strategies, manage database connections properly, translate domain exceptions, and build implementations that work reliably in production environments.
Before diving into code, let's establish the architectural context. Repository implementations sit between your domain and the actual data access technology. Their job is to translate domain operations into persistence operations—and vice versa.
The Implementation Stack:
┌─────────────────────────────────────┐
│ Domain Layer │
│ (Uses IOrderRepository) │
└─────────────────┬───────────────────┘
│ Interface dependency
┌─────────────────▼───────────────────┐
│ Repository Implementation │
│ (EfOrderRepository) │
│ - Translates domain ↔ persistence │
│ - Manages loading strategies │
│ - Handles exceptions │
└─────────────────┬───────────────────┘
│ Uses
┌─────────────────▼───────────────────┐
│ ORM / Data Access Library │
│ (Entity Framework, Hibernate) │
│ - Unit of Work (DbContext) │
│ - Change tracking │
│ - Query translation │
└─────────────────┬───────────────────┘
│ Database protocol
┌─────────────────▼───────────────────┐
│ Database │
│ (SQL Server, PostgreSQL, etc.) │
└─────────────────────────────────────┘
Key Implementation Responsibilities:
Let's build a complete repository implementation using Entity Framework Core, one of the most popular ORMs in the .NET ecosystem. This example demonstrates the core patterns you'll use regardless of which ORM you choose.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
using Microsoft.EntityFrameworkCore; /// <summary>/// Complete Entity Framework Core implementation of IOrderRepository./// This is a production-ready example demonstrating key implementation patterns./// </summary>public class EfOrderRepository : IOrderRepository{ private readonly OrderingDbContext _context; private readonly ILogger<EfOrderRepository> _logger; public EfOrderRepository( OrderingDbContext context, ILogger<EfOrderRepository> logger) { _context = context ?? throw new ArgumentNullException(nameof(context)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// <summary> /// Retrieves an order by its ID, including all aggregate children. /// </summary> public Order? GetById(OrderId id) { // Load the complete aggregate - Order with its LineItems and Addresses // This ensures the aggregate is always in a consistent state return _context.Orders .Include(o => o.LineItems) .ThenInclude(li => li.ProductSnapshot) .Include(o => o.ShippingAddress) .Include(o => o.BillingAddress) .FirstOrDefault(o => o.Id == id); } /// <summary> /// Retrieves an order or throws if not found. /// Use when the order is expected to exist (business logic guarantee). /// </summary> public Order GetByIdOrThrow(OrderId id) { var order = GetById(id); if (order == null) { _logger.LogWarning("Order {OrderId} not found", id); throw new OrderNotFoundException(id); } return order; } /// <summary> /// Async version of GetById - preferred for web applications. /// </summary> public async Task<Order?> GetByIdAsync( OrderId id, CancellationToken ct = default) { return await _context.Orders .Include(o => o.LineItems) .ThenInclude(li => li.ProductSnapshot) .Include(o => o.ShippingAddress) .Include(o => o.BillingAddress) .FirstOrDefaultAsync(o => o.Id == id, ct); } /// <summary> /// Adds a new order to the repository. /// The order is added to the change tracker and will be persisted /// when SaveChanges is called on the DbContext (Unit of Work). /// </summary> public void Add(Order order) { ArgumentNullException.ThrowIfNull(order); _context.Orders.Add(order); _logger.LogDebug("Order {OrderId} added to change tracker", order.Id); } /// <summary> /// Marks an order for removal. /// The delete will occur when SaveChanges is called. /// </summary> public void Remove(Order order) { ArgumentNullException.ThrowIfNull(order); // EF Core handles cascade deletes based on configuration _context.Orders.Remove(order); _logger.LogDebug("Order {OrderId} marked for removal", order.Id); } /// <summary> /// Finds orders that are ready for shipment processing. /// Encapsulates the business rules for "ready for shipment". /// </summary> public IEnumerable<Order> FindOrdersReadyForShipment() { return _context.Orders .Include(o => o.LineItems) .Include(o => o.ShippingAddress) .Where(o => o.Status == OrderStatus.Paid && o.StockVerified && !o.ShippedAt.HasValue) .OrderBy(o => o.OrderDate) // FIFO processing .ToList(); } /// <summary> /// Finds orders awaiting payment. /// </summary> public IEnumerable<Order> FindOrdersPendingPayment() { return _context.Orders .Where(o => o.Status == OrderStatus.PendingPayment) .OrderBy(o => o.OrderDate) .ToList(); } /// <summary> /// Finds all orders for a specific customer. /// </summary> public IEnumerable<Order> FindForCustomer(CustomerId customerId) { return _context.Orders .Include(o => o.LineItems) .Where(o => o.CustomerId == customerId) .OrderByDescending(o => o.OrderDate) .ToList(); } /// <summary> /// Checks if an order exists with the given idempotency key. /// Used to prevent duplicate order creation. /// </summary> public bool ExistsWithIdempotencyKey(string key) { return _context.Orders.Any(o => o.IdempotencyKey == key); } /// <summary> /// Generates the next order number. /// Uses a database sequence for atomicity. /// </summary> public OrderNumber GenerateNextOrderNumber() { // Option 1: Use a database sequence (PostgreSQL, Oracle, SQL Server 2012+) var nextVal = _context.Database .SqlQuery<long>($"SELECT NEXT VALUE FOR OrderNumberSequence") .Single(); return new OrderNumber(nextVal); // Option 2: For databases without sequences, use a dedicated table // This requires careful transaction handling to avoid gaps/duplicates } /// <summary> /// Paged query with total count for UI listing. /// </summary> public async Task<PagedResult<Order>> GetPagedAsync( int pageNumber, int pageSize, CancellationToken ct = default) { var query = _context.Orders.AsQueryable(); var totalCount = await query.CountAsync(ct); var items = await query .OrderByDescending(o => o.OrderDate) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(ct); return new PagedResult<Order>(items, totalCount, pageNumber, pageSize); }}Notice several patterns: (1) Always include the complete aggregate graph in GetById. (2) Use logging for debugging without polluting domain code. (3) Async methods for I/O operations. (4) Clear null handling with OrThrow variants. (5) Encapsulated business logic in query methods.
Let's see the equivalent implementation using Java Persistence API (JPA) with Hibernate. While the ORM differs, the patterns remain remarkably similar.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
import javax.persistence.*;import java.util.*;import org.slf4j.Logger;import org.slf4j.LoggerFactory; /** * Complete JPA/Hibernate implementation of OrderRepository. * Production-ready example demonstrating key implementation patterns. */@Repositorypublic class JpaOrderRepository implements OrderRepository { private static final Logger log = LoggerFactory.getLogger(JpaOrderRepository.class); @PersistenceContext private EntityManager entityManager; /** * Retrieves an order by its ID, including all aggregate children. * Uses JPQL with JOIN FETCH to load the complete aggregate. */ @Override public Optional<Order> findById(OrderId id) { try { Order order = entityManager .createQuery(""" SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.lineItems li LEFT JOIN FETCH li.productSnapshot LEFT JOIN FETCH o.shippingAddress LEFT JOIN FETCH o.billingAddress WHERE o.id = :id """, Order.class) .setParameter("id", id) .getSingleResult(); return Optional.of(order); } catch (NoResultException e) { return Optional.empty(); } } /** * Retrieves an order or throws if not found. */ @Override public Order findByIdOrThrow(OrderId id) { return findById(id) .orElseThrow(() -> { log.warn("Order {} not found", id); return new OrderNotFoundException(id); }); } /** * Saves (creates or updates) an order. * JPA merge handles both insert and update. */ @Override @Transactional public Order save(Order order) { Objects.requireNonNull(order, "Order cannot be null"); if (order.getId() == null) { entityManager.persist(order); log.debug("Order persisted (new)"); return order; } else { Order merged = entityManager.merge(order); log.debug("Order {} merged", order.getId()); return merged; } } /** * Removes an order from the persistence context. */ @Override @Transactional public void delete(Order order) { Objects.requireNonNull(order, "Order cannot be null"); // Ensure entity is managed before removing Order managed = entityManager.contains(order) ? order : entityManager.merge(order); entityManager.remove(managed); log.debug("Order {} marked for deletion", order.getId()); } /** * Finds orders ready for shipment. */ @Override public List<Order> findOrdersReadyForShipment() { return entityManager .createQuery(""" SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.lineItems LEFT JOIN FETCH o.shippingAddress WHERE o.status = :status AND o.stockVerified = true AND o.shippedAt IS NULL ORDER BY o.orderDate """, Order.class) .setParameter("status", OrderStatus.PAID) .getResultList(); } /** * Finds orders for a specific customer with pagination. */ @Override public Page<Order> findByCustomerId(CustomerId customerId, Pageable pageable) { // Count query for total Long total = entityManager .createQuery( "SELECT COUNT(o) FROM Order o WHERE o.customerId = :customerId", Long.class) .setParameter("customerId", customerId) .getSingleResult(); // Data query with pagination List<Order> orders = entityManager .createQuery(""" SELECT o FROM Order o WHERE o.customerId = :customerId ORDER BY o.orderDate DESC """, Order.class) .setParameter("customerId", customerId) .setFirstResult((int) pageable.getOffset()) .setMaxResults(pageable.getPageSize()) .getResultList(); return new PageImpl<>(orders, pageable, total); } /** * Checks for duplicate order by idempotency key. */ @Override public boolean existsByIdempotencyKey(String key) { Long count = entityManager .createQuery( "SELECT COUNT(o) FROM Order o WHERE o.idempotencyKey = :key", Long.class) .setParameter("key", key) .getSingleResult(); return count > 0; } /** * Generates next order number using database sequence. */ @Override public OrderNumber generateNextOrderNumber() { // Native query for sequence (PostgreSQL example) BigInteger nextVal = (BigInteger) entityManager .createNativeQuery("SELECT nextval('order_number_seq')") .getSingleResult(); return new OrderNumber(nextVal.longValue()); }}One of the most critical decisions in repository implementation is how to load aggregates. Load too little, and you hit N+1 query problems. Load too much, and you waste memory and bandwidth. Let's explore the strategies.
| Strategy | When to Use | Pros | Cons |
|---|---|---|---|
| Eager Loading (Include/Fetch) | When you always need related data | Single query, predictable | Can over-fetch if not needed |
| Lazy Loading | When related data is rarely accessed | Loads only what's used | N+1 problems, requires proxy |
| Explicit Loading | When conditional loading is needed | Precise control | More code, manual tracking |
| Split Queries | Large aggregates with multiple collections | Avoids Cartesian explosion | Multiple round trips |
| Projection/DTOs | Read-only scenarios | Optimal performance | Not suitable for updates |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
public class EfOrderRepository : IOrderRepository{ // STRATEGY 1: Eager Loading with Include // Best for: Consistent aggregate access where children are always needed public Order? GetById_EagerLoading(OrderId id) { return _context.Orders .Include(o => o.LineItems) .ThenInclude(li => li.ProductSnapshot) .Include(o => o.ShippingAddress) .Include(o => o.BillingAddress) .FirstOrDefault(o => o.Id == id); // Generates a single query with JOINs // Good for small aggregates, problematic with many includes } // STRATEGY 2: Split Queries // Best for: Aggregates with multiple collections to avoid Cartesian product public Order? GetById_SplitQuery(OrderId id) { return _context.Orders .Include(o => o.LineItems) .Include(o => o.ShippingAddress) .Include(o => o.BillingAddress) .AsSplitQuery() // EF Core 5+ feature .FirstOrDefault(o => o.Id == id); // Generates multiple queries (one per include) // Avoids Cartesian explosion: O(n*m) rows → O(n+m) rows } // STRATEGY 3: Explicit Loading // Best for: Conditional loading based on business rules public Order? GetById_ExplicitLoading(OrderId id, bool includeLineItems) { var order = _context.Orders.Find(id); if (order != null) { // Always load addresses (small, always needed) _context.Entry(order) .Reference(o => o.ShippingAddress) .Load(); // Conditionally load line items if (includeLineItems) { _context.Entry(order) .Collection(o => o.LineItems) .Query() .Include(li => li.ProductSnapshot) .Load(); } } return order; } // STRATEGY 4: Projection for Read-Only Scenarios // Best for: Queries, reporting, APIs where full aggregate isn't needed public OrderSummaryDto? GetOrderSummary(OrderId id) { return _context.Orders .Where(o => o.Id == id) .Select(o => new OrderSummaryDto { OrderId = o.Id.Value, OrderNumber = o.OrderNumber.Value, CustomerName = o.CustomerName, TotalAmount = o.TotalAmount.Amount, Currency = o.TotalAmount.Currency, Status = o.Status.ToString(), ItemCount = o.LineItems.Count, OrderDate = o.OrderDate }) .FirstOrDefault(); // Generates a SELECT with only needed columns // No entity tracking overhead, minimal data transfer } // STRATEGY 5: Batch Loading for Collections // Best for: Loading orders for multiple customers efficiently public Dictionary<CustomerId, List<Order>> GetOrdersByCustomers( IEnumerable<CustomerId> customerIds) { var orders = _context.Orders .Include(o => o.LineItems) .Where(o => customerIds.Contains(o.CustomerId)) .ToList(); return orders .GroupBy(o => o.CustomerId) .ToDictionary(g => g.Key, g => g.ToList()); // Single query fetches all orders for all customers // Avoids N queries for N customers }}If you load a list of Orders and then access LineItems on each (with lazy loading), you execute 1 query for Orders + N queries for LineItems = N+1 queries. For 100 orders, that's 101 database round trips instead of 1-2. Always eager load collections when you'll iterate over parent entities.
Repositories don't typically manage their own transactions—that's the responsibility of the Unit of Work or the application service layer. However, repository implementations must be transaction-aware and connection-friendly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
// PATTERN 1: DbContext-scoped repositories (recommended)// The DbContext IS the Unit of Work in EF Core public class OrderingUnitOfWork : IUnitOfWork{ private readonly OrderingDbContext _context; // Lazy-loaded repositories share the same DbContext private IOrderRepository? _orders; private ICustomerRepository? _customers; public OrderingUnitOfWork(OrderingDbContext context) { _context = context; } public IOrderRepository Orders => _orders ??= new EfOrderRepository(_context, _logger); public ICustomerRepository Customers => _customers ??= new EfCustomerRepository(_context, _logger); public async Task<int> SaveChangesAsync(CancellationToken ct = default) { return await _context.SaveChangesAsync(ct); } public async Task BeginTransactionAsync() { await _context.Database.BeginTransactionAsync(); } public async Task CommitAsync() { await _context.Database.CommitTransactionAsync(); } public async Task RollbackAsync() { await _context.Database.RollbackTransactionAsync(); } public void Dispose() { _context.Dispose(); }} // PATTERN 2: Usage in Application Servicepublic class PlaceOrderService{ private readonly IUnitOfWork _uow; public PlaceOrderService(IUnitOfWork uow) { _uow = uow; } public async Task<OrderId> PlaceOrder(PlaceOrderCommand command) { try { await _uow.BeginTransactionAsync(); // Multiple repository operations in same transaction var customer = await _uow.Customers.GetByIdAsync(command.CustomerId); if (customer == null) throw new CustomerNotFoundException(command.CustomerId); var order = customer.PlaceOrder(command.Items); _uow.Orders.Add(order); // All changes saved atomically await _uow.SaveChangesAsync(); await _uow.CommitAsync(); return order.Id; } catch { await _uow.RollbackAsync(); throw; } }} // PATTERN 3: Scoped DbContext with DI (ASP.NET Core)// In Startup.cs / Program.cs:services.AddDbContext<OrderingDbContext>(options => options.UseSqlServer(connectionString)); services.AddScoped<IOrderRepository, EfOrderRepository>();services.AddScoped<IUnitOfWork, OrderingUnitOfWork>(); // DbContext is scoped per request, so all repository operations// within a request share the same context and transaction.Repositories should not start or commit transactions—they participate in them. Transaction boundaries belong in the application/service layer where you understand the full scope of a business operation. This allows multiple repository operations to be grouped into a single atomic transaction.
Database and ORM exceptions are infrastructure concerns—they shouldn't leak into your domain layer. Repository implementations should catch infrastructure exceptions and translate them into domain-appropriate exceptions.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
public class EfOrderRepository : IOrderRepository{ public void Add(Order order) { try { _context.Orders.Add(order); } catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) { // Translate database constraint violation to domain exception _logger.LogWarning(ex, "Duplicate order detected: {OrderId}", order.Id); throw new DuplicateOrderException(order.Id, ex); } catch (DbUpdateConcurrencyException ex) { // Translate optimistic concurrency failure _logger.LogWarning(ex, "Concurrency conflict for order: {OrderId}", order.Id); throw new OrderConcurrencyException(order.Id, ex); } } public Order GetByIdOrThrow(OrderId id) { try { var order = _context.Orders .Include(o => o.LineItems) .FirstOrDefault(o => o.Id == id); if (order == null) { throw new OrderNotFoundException(id); } return order; } catch (SqlException ex) when (IsConnectionError(ex)) { _logger.LogError(ex, "Database connection failed"); throw new PersistenceUnavailableException( "Unable to connect to order database", ex); } catch (TimeoutException ex) { _logger.LogError(ex, "Database query timed out for order {OrderId}", id); throw new PersistenceTimeoutException( $"Query for order {id} timed out", ex); } } private static bool IsUniqueConstraintViolation(DbUpdateException ex) { return ex.InnerException is SqlException sqlEx && (sqlEx.Number == 2601 || sqlEx.Number == 2627); // SQL Server unique constraint violation error codes } private static bool IsConnectionError(SqlException ex) { // SQL Server connection-related error codes return new[] { 53, 10054, 10061, 40143, 40197, 40501, 40613 } .Contains(ex.Number); }} // Domain exceptions that can be caught by application servicespublic class OrderNotFoundException : DomainException{ public OrderId OrderId { get; } public OrderNotFoundException(OrderId orderId) : base($"Order with ID {orderId} was not found") { OrderId = orderId; }} public class DuplicateOrderException : DomainException{ public OrderId OrderId { get; } public DuplicateOrderException(OrderId orderId, Exception inner) : base($"Order with ID {orderId} already exists", inner) { OrderId = orderId; }} public class PersistenceUnavailableException : InfrastructureException{ public PersistenceUnavailableException(string message, Exception inner) : base(message, inner) { }}Consider a layered exception hierarchy: DomainException (for business rule violations), InfrastructureException (for external system failures), and their subtypes. This lets application services handle different failure categories appropriately—retrying transient infrastructure failures while immediately reporting domain violations.
Production repositories need to perform under load. Here are key optimization techniques that experienced engineers apply.
.AsNoTracking() (EF Core) or session.setDefaultReadOnly(true) (Hibernate).BulkInsert, ExecuteUpdate, or raw SQL instead of individual saves in a loop.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
public class EfOrderRepository : IOrderRepository{ // Compiled query - defined once, reused for every call private static readonly Func<OrderingDbContext, OrderId, Order?> GetOrderByIdCompiled = EF.CompileQuery((OrderingDbContext ctx, OrderId id) => ctx.Orders .Include(o => o.LineItems) .FirstOrDefault(o => o.Id == id)); public Order? GetById(OrderId id) { // Use compiled query - avoids expression tree compilation return GetOrderByIdCompiled(_context, id); } // No-tracking for read-only list operations public IEnumerable<OrderSummaryDto> GetRecentOrderSummaries(int count) { return _context.Orders .AsNoTracking() // Skip change tracking overhead .OrderByDescending(o => o.OrderDate) .Take(count) .Select(o => new OrderSummaryDto { /* ... */ }) .ToList(); } // Bulk update - single query instead of loading all entities public int MarkOrdersAsShipped(IEnumerable<OrderId> orderIds) { // EF Core 7+ ExecuteUpdate return _context.Orders .Where(o => orderIds.Contains(o.Id)) .ExecuteUpdate(setters => setters .SetProperty(o => o.Status, OrderStatus.Shipped) .SetProperty(o => o.ShippedAt, DateTime.UtcNow)); // Generates single UPDATE statement, no entities loaded } // Streamed results for large datasets public async IAsyncEnumerable<Order> StreamAllOrders( [EnumeratorCancellation] CancellationToken ct) { // AsAsyncEnumerable streams results instead of loading all to memory await foreach (var order in _context.Orders .AsNoTracking() .AsAsyncEnumerable() .WithCancellation(ct)) { yield return order; } }}Decorators allow you to add cross-cutting concerns—logging, caching, metrics, retry logic—without modifying repository implementations. This maintains single responsibility and allows flexible composition.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
// Decorator 1: Loggingpublic class LoggingOrderRepositoryDecorator : IOrderRepository{ private readonly IOrderRepository _inner; private readonly ILogger<LoggingOrderRepositoryDecorator> _logger; public LoggingOrderRepositoryDecorator( IOrderRepository inner, ILogger<LoggingOrderRepositoryDecorator> logger) { _inner = inner; _logger = logger; } public Order? GetById(OrderId id) { _logger.LogDebug("Retrieving order {OrderId}", id); var stopwatch = Stopwatch.StartNew(); var order = _inner.GetById(id); stopwatch.Stop(); _logger.LogDebug( "Retrieved order {OrderId} in {ElapsedMs}ms. Found: {Found}", id, stopwatch.ElapsedMilliseconds, order != null); return order; } // Delegate all other methods with logging...} // Decorator 2: Cachingpublic class CachingOrderRepositoryDecorator : IOrderRepository{ private readonly IOrderRepository _inner; private readonly IDistributedCache _cache; private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5); public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct) { var cacheKey = $"order:{id.Value}"; // Try cache first var cached = await _cache.GetStringAsync(cacheKey, ct); if (cached != null) { return JsonSerializer.Deserialize<Order>(cached); } // Miss - load from database var order = await _inner.GetByIdAsync(id, ct); if (order != null) { // Populate cache await _cache.SetStringAsync( cacheKey, JsonSerializer.Serialize(order), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _cacheDuration }, ct); } return order; } // Invalidate cache on writes public void Add(Order order) { _inner.Add(order); _cache.Remove($"order:{order.Id.Value}"); }} // Decorator 3: Retry with Pollypublic class RetryingOrderRepositoryDecorator : IOrderRepository{ private readonly IOrderRepository _inner; private readonly IAsyncPolicy _retryPolicy; public RetryingOrderRepositoryDecorator(IOrderRepository inner) { _inner = inner; _retryPolicy = Policy .Handle<SqlException>(ex => IsTransient(ex)) .WaitAndRetryAsync( retryCount: 3, sleepDurationProvider: attempt => TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt))); } public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct) { return await _retryPolicy.ExecuteAsync( () => _inner.GetByIdAsync(id, ct)); }} // DI Configuration with decorationservices.AddScoped<EfOrderRepository>();services.AddScoped<IOrderRepository>(provider =>{ var baseRepo = provider.GetRequiredService<EfOrderRepository>(); var logger = provider.GetRequiredService<ILogger<LoggingOrderRepositoryDecorator>>(); var cache = provider.GetRequiredService<IDistributedCache>(); // Stack decorators: Logging -> Caching -> Retry -> Base return new LoggingOrderRepositoryDecorator( new CachingOrderRepositoryDecorator( new RetryingOrderRepositoryDecorator(baseRepo), cache), logger);});The order of decorators is significant. Logging should be outermost to capture all activity. Caching should be inside logging but outside retry logic (you don't want to cache retry attempts). Retry should be closest to the actual repository to only retry database calls.
We've covered the essential patterns for implementing robust repository classes. Let's consolidate the key implementation principles:
What's Next:
With implementation patterns understood, we'll move to Unit Testing Repositories in the next page. You'll learn how to write effective tests for your repositories using in-memory databases, test doubles, and integration testing strategies.
You now have the knowledge to implement production-ready repositories with any ORM. These patterns—properly applied—result in persistence layers that are correct, performant, and maintainable.