Skip to main content

Overview

The Repository Pattern provides an abstraction layer between the domain/business logic and data persistence, centralizing data access logic and improving testability, maintainability, and flexibility. Intent Architect implements this pattern through the Intent.EntityFrameworkCore.Repositories module.

Repository Benefits

Repositories encapsulate data access logic, provide a collection-like interface for domain objects, and enable easy switching between different data access strategies without affecting business logic.

What is a Repository?

A repository acts as an in-memory collection of domain objects, hiding the details of how objects are actually stored and retrieved.

Core Responsibilities

Query Encapsulation

Provide methods to find entities without exposing query implementation details

Persistence Logic

Handle adding, updating, and removing entities from the data store

Domain Focus

Allow business logic to work with domain objects, not database concerns

Testability

Enable easy mocking and unit testing of data access

Repository Architecture

Interface (Application Layer)

Repository interfaces are defined in the Application layer and depend only on domain entities:
public interface ICustomerRepository : IEFRepository<Customer, Customer>
{
    Task<Customer?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
    Task<Customer?> FindByEmailAsync(string email, CancellationToken cancellationToken = default);
    Task<List<Customer>> FindAllAsync(CancellationToken cancellationToken = default);
    Task<List<Customer>> FindActiveCustomersAsync(CancellationToken cancellationToken = default);
}
Generated by: Intent.Entities.Repositories.Api module

Implementation (Infrastructure Layer)

Repository implementations reside in the Infrastructure layer and use Entity Framework Core:
[IntentManaged(Mode.Merge, Signature = Mode.Fully)]
public class CustomerRepository : RepositoryBase<Customer, Customer, ApplicationDbContext>, ICustomerRepository
{
    public CustomerRepository(ApplicationDbContext dbContext) : base(dbContext)
    {
    }

    public async Task<Customer?> FindByIdAsync(
        Guid id, 
        CancellationToken cancellationToken = default)
    {
        return await FindAsync(x => x.Id == id, cancellationToken);
    }

    public async Task<Customer?> FindByEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await FindAsync(x => x.Email == email, cancellationToken);
    }

    public async Task<List<Customer>> FindActiveCustomersAsync(
        CancellationToken cancellationToken = default)
    {
        return await FindAllAsync(
            x => x.Status == CustomerStatus.Active,
            cancellationToken);
    }
}
Generated by: Intent.EntityFrameworkCore.Repositories module

Repository Base Class

Intent Architect generates a RepositoryBase<TDomain, TPersistence, TDbContext> class providing common operations:
public abstract class RepositoryBase<TDomain, TPersistence, TDbContext>
    where TPersistence : class
    where TDbContext : DbContext
{
    protected readonly TDbContext _dbContext;

    protected RepositoryBase(TDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    // Query operations
    protected virtual IQueryable<TPersistence> CreateQuery();
    protected Task<TPersistence?> FindAsync(
        Expression<Func<TPersistence, bool>> filterExpression,
        CancellationToken cancellationToken = default);
    protected Task<List<TPersistence>> FindAllAsync(
        Expression<Func<TPersistence, bool>> filterExpression,
        CancellationToken cancellationToken = default);

    // Modification operations
    public void Add(TDomain entity);
    public void Update(TDomain entity);
    public void Remove(TDomain entity);
}

Data Fetching Strategies

Lazy Loading with Proxies

Intent Architect enables lazy loading by default through EF Core’s lazy loading proxies:
var customer = await _customerRepository.FindByIdAsync(customerId);

// Orders are loaded automatically when accessed
foreach (var order in customer.Orders)  // Database query happens here
{
    Console.WriteLine(order.OrderNumber);
}
Configuration:
Lazy loading can cause N+1 query problems. Use eager loading for related data you know you’ll need.

Eager Loading

Load related entities upfront to avoid multiple queries:
Configure repository to always eager load specific relationships:
public class CustomerRepository : RepositoryBase<Customer, Customer, ApplicationDbContext>, ICustomerRepository
{
    public CustomerRepository(ApplicationDbContext dbContext) : base(dbContext)
    {
    }

    // All queries will include Orders
    protected override IQueryable<Customer> CreateQuery()
    {
        var result = base.CreateQuery();
        return result.Include(c => c.Orders);
    }
}

Aggregates and Repositories

Repository per Aggregate Root

In Domain-Driven Design, repositories should only exist for Aggregate Roots:

Modeling Owned Entities

In the Domain Designer, use composition (black diamond) to model owned relationships:
Owned entity relationship
This results in:
  • Only Order gets a repository
  • OrderItem is accessed through the Order aggregate
  • Saving Order saves all its OrderItems
// Load the aggregate root
var order = await _orderRepository.FindByIdAsync(orderId);

// Modify owned entities through the aggregate
order.AddItem(productId, quantity, unitPrice);
order.RemoveItem(itemId);

// Save the entire aggregate at once
_orderRepository.Update(order);
await _unitOfWork.SaveChangesAsync();

Repository on Owned Entities

Sometimes you need direct access to owned entities for performance reasons:
1

Apply Repository Stereotype

In the Domain Designer, apply the Repository stereotype to the owned entity
2

Repository is Generated

Intent Architect will generate both interface and implementation:
public interface IOrderItemRepository : IEFRepository<OrderItem, OrderItem>
{
    // Custom query methods
}
Accessing owned entities directly bypasses aggregate consistency boundaries. Use this only for non-functional requirements like performance optimization.

Advanced Repository Patterns

Specification Pattern

Encapsulate query logic in reusable specifications:
public class ActiveCustomersSpecification
{
    public static Expression<Func<Customer, bool>> Criteria =>
        c => c.Status == CustomerStatus.Active && !c.IsDeleted;
}

public class HighValueCustomersSpecification
{
    public static Expression<Func<Customer, bool>> Criteria =>
        c => c.TotalPurchases > 10000m;
}

Pagination Support

public interface ICustomerRepository : IEFRepository<Customer, Customer>
{
    Task<PagedResult<Customer>> FindPagedAsync(
        int pageNumber,
        int pageSize,
        CancellationToken cancellationToken = default);
}

public class CustomerRepository : RepositoryBase<Customer, Customer, ApplicationDbContext>, ICustomerRepository
{
    public async Task<PagedResult<Customer>> FindPagedAsync(
        int pageNumber,
        int pageSize,
        CancellationToken cancellationToken = default)
    {
        var query = CreateQuery();
        
        var totalCount = await query.CountAsync(cancellationToken);
        
        var items = await query
            .Skip((pageNumber - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(cancellationToken);

        return new PagedResult<Customer>
        {
            Items = items,
            TotalCount = totalCount,
            PageNumber = pageNumber,
            PageSize = pageSize
        };
    }
}
The Intent.Application.Dtos.Pagination module provides built-in pagination support for DTOs.

Query Object Pattern

public class CustomerSearchQuery
{
    public string? NameFilter { get; set; }
    public CustomerStatus? Status { get; set; }
    public DateTime? RegisteredAfter { get; set; }
    public string? SortBy { get; set; }
    public bool SortDescending { get; set; }
}

public async Task<List<Customer>> SearchAsync(
    CustomerSearchQuery query,
    CancellationToken cancellationToken = default)
{
    var dbQuery = CreateQuery();

    if (!string.IsNullOrEmpty(query.NameFilter))
        dbQuery = dbQuery.Where(c => c.Name.Contains(query.NameFilter));

    if (query.Status.HasValue)
        dbQuery = dbQuery.Where(c => c.Status == query.Status.Value);

    if (query.RegisteredAfter.HasValue)
        dbQuery = dbQuery.Where(c => c.RegisteredDate >= query.RegisteredAfter.Value);

    // Sorting logic
    if (!string.IsNullOrEmpty(query.SortBy))
    {
        dbQuery = query.SortBy.ToLower() switch
        {
            "name" => query.SortDescending 
                ? dbQuery.OrderByDescending(c => c.Name)
                : dbQuery.OrderBy(c => c.Name),
            "date" => query.SortDescending
                ? dbQuery.OrderByDescending(c => c.RegisteredDate)
                : dbQuery.OrderBy(c => c.RegisteredDate),
            _ => dbQuery
        };
    }

    return await dbQuery.ToListAsync(cancellationToken);
}

Unit of Work Pattern

Repositories work alongside the Unit of Work pattern for transaction management:
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        IUnitOfWork unitOfWork)
    {
    { _orderRepository = orderRepository;
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        var order = new Order { /* ... */ };
        
        foreach (var item in request.Items)
        {
            var product = await _productRepository.FindByIdAsync(
                item.ProductId,
                cancellationToken);
            
            product.DecreaseStock(item.Quantity);
            order.AddItem(product, item.Quantity);
        }

        _orderRepository.Add(order);
        
        // Single transaction saves both Order and Product changes
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        return order.Id;
    }
}
Module: Intent.Common.UnitOfWork

Testing with Repositories

Unit Testing with Mocks

public class CreateCustomerCommandHandlerTests
{
    [Fact]
    public async Task Handle_ValidCommand_AddsCustomerToRepository()
    {
        // Arrange
        var mockRepository = new Mock<ICustomerRepository>();
        var handler = new CreateCustomerCommandHandler(mockRepository.Object);
        var command = new CreateCustomerCommand
        {
            Name = "John Doe",
            Email = "[email protected]"
        };

        // Act
        await handler.Handle(command, CancellationToken.None);

        // Assert
        mockRepository.Verify(
            x => x.Add(It.Is<Customer>(c => 
                c.Name == "John Doe" && 
                c.Email == "[email protected]")),
            Times.Once);
    }
}

Integration Testing

public class CustomerRepositoryIntegrationTests : IClassFixture<DatabaseFixture>
{
    private readonly ApplicationDbContext _dbContext;
    private readonly ICustomerRepository _repository;

    public CustomerRepositoryIntegrationTests(DatabaseFixture fixture)
    {
        _dbContext = fixture.CreateContext();
        _repository = new CustomerRepository(_dbContext);
    }

    [Fact]
    public async Task FindByEmailAsync_ExistingEmail_ReturnsCustomer()
    {
        // Arrange
        var customer = new Customer { Name = "Test", Email = "[email protected]" };
        _repository.Add(customer);
        await _dbContext.SaveChangesAsync();

        // Act
        var result = await _repository.FindByEmailAsync("[email protected]");

        // Assert
        Assert.NotNull(result);
        Assert.Equal("Test", result.Name);
    }
}

Best Practices

Repositories should focus on data access, not business logic:
// Good - Simple data access
Task<Customer?> FindByEmailAsync(string email);

// Bad - Business logic in repository
Task<bool> IsCustomerEligibleForDiscountAsync(Guid customerId);
Put business logic in domain entities or domain services instead.
Repositories should return domain entities, not DTOs:
// Good - Returns domain entity
Task<Customer?> FindByIdAsync(Guid id);

// Bad - Returns DTO
Task<CustomerDto> FindByIdAsync(Guid id);
Map to DTOs in the application layer (handlers/services).
Don’t create repositories for every entity:
// Good - Repository for aggregate root
public interface IOrderRepository { }

// Bad - Repository for owned entity
public interface IOrderItemRepository { }  // Access through Order instead
Always use asynchronous methods for I/O operations:
// Good
Task<Customer?> FindByIdAsync(Guid id, CancellationToken cancellationToken);

// Avoid
Customer? FindById(Guid id);

Module Configuration

Database Settings

Setting: Database Settings - Lazy loading with proxiesDefault: EnabledEffect:
services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.UseLazyLoadingProxies();  // When enabled
});

Unit of Work

Transaction management across multiple repositories

Specification

Encapsulate query logic in reusable objects

CQRS

Separate read and write repositories

Additional Resources

Build docs developers (and LLMs) love