← Back to writing

Repositories and Persistence Boundaries

dddrepositoriespersistencesoftware-architecture

This is the sixth deep-dive in a series on Domain-Driven Design. It builds on the concepts introduced in Domain-Driven Design: A Practical Overview.

The domain model should not know how it’s stored. Not the database engine. Not the table structure. Not the ORM. The aggregate defines its shape in domain terms: entities, value objects, invariants. The persistence layer translates that shape into something a database can store.

The repository is the boundary between these two worlds. From the domain’s perspective, the repository is a collection of aggregates. From the infrastructure’s perspective, it’s a set of database operations with mapping logic. Neither side knows the other’s details.


The repository interface

A repository interface lives in the domain layer. It speaks in domain types: aggregate roots, strongly-typed IDs, domain entities. No DbContext, no SQL, no connection strings.

Here’s the base interface from the DddSeedwork package:

public interface IRepository<TAggregate, in TId>
    where TAggregate : class, IAggregateRoot
{
    IUnitOfWork UnitOfWork { get; }

    Task<TAggregate?> GetByIdAsync(TId id,
        CancellationToken cancellationToken = default);
    Task AddAsync(TAggregate aggregate,
        CancellationToken cancellationToken = default);
    Task UpdateAsync(TAggregate aggregate,
        CancellationToken cancellationToken = default);
    Task DeleteAsync(TId id,
        CancellationToken cancellationToken = default);
}

The generic constraint IAggregateRoot is an interface, not the AggregateRoot<TId> base class. This is intentional. It lets you write custom aggregate implementations without inheriting from a specific base class, while still ensuring that only aggregates get repositories.

A concrete repository extends the base with aggregate-specific operations:

public interface IBoardRepository : IRepository<Board, BoardId>
{
    Task SaveAsync(Board aggregate,
        CancellationToken cancellationToken = default);
    Task<bool> ExistsByParentBoardIdAsync(BoardId parentBoardId,
        CancellationToken cancellationToken = default);
}

SaveAsync handles the full sync: adding new children, updating existing ones, removing deleted ones. ExistsByParentBoardIdAsync is a query the domain needs for validation. Both use domain types. Neither mentions EF Core.


One repository per aggregate

This is a rule worth stating explicitly: every aggregate gets its own repository, and no repository crosses aggregate boundaries.

A BoardRepository loads and saves Board aggregates. It does not load Organization or Product aggregates. If a command handler needs data from another aggregate, it uses that aggregate’s repository. It doesn’t reach through Board to get the organization.

This rule keeps aggregates decoupled. If loading a Board also loaded its Organization, changing the organization model would require changes in the board persistence layer. Separate repositories mean separate concerns.


Separate domain and data models

This is the decision that separates clean DDD implementations from ones that gradually collapse under ORM coupling.

The domain model uses rich types: strongly-typed IDs, value objects, private setters, factory methods, read-only collections. The data model uses plain types: Guid, string, decimal, public setters, mutable collections. The ORM owns the data model. The domain owns the domain model. They never mix.

Here’s the domain entity for a sticky note:

// Domain model — rich, encapsulated, validated
public class Sticky : Entity<StickyId>
{
    public string Text { get; private set; }
    public Position Position { get; private set; }  // Value object
    public StickyType Type { get; private set; }
    public decimal Width { get; private set; }
    public decimal Height { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime UpdatedAt { get; private set; }
}

And the corresponding data entity:

// Data model — flat, mutable, ORM-friendly
public class StickyData
{
    public Guid Id { get; set; }
    public Guid BoardId { get; set; }
    public string Text { get; set; } = string.Empty;
    public decimal X { get; set; }
    public decimal Y { get; set; }
    public string Type { get; set; } = string.Empty;
    public decimal Width { get; set; }
    public decimal Height { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}

The differences matter. Position is a value object in the domain, a single concept with X and Y components. In the data model, it’s two flat decimal columns. StickyType is an enum in the domain; it’s stored as a string. StickyId is a strongly-typed ID; the database stores a raw Guid. The data model has a BoardId foreign key that doesn’t exist on the domain entity. The domain entity doesn’t know which board it belongs to because it’s always accessed through the Board aggregate.

You could skip this separation. EF Core can map domain entities directly with value converters and shadow properties. But that creates a coupling that compounds over time. Every domain change requires thinking about ORM implications, and every database migration requires understanding domain invariants. Separate models let each concern evolve independently.


Mapping between models

The translation between domain and data models happens through extension methods. Three methods per entity type:

public static class StickyDataExtensions
{
    // Data → Domain (loading from database)
    public static Sticky ToDomain(this StickyData data)
    {
        return Sticky.Hydrate(
            StickyId.From(data.Id),
            data.Text,
            new Position(data.X, data.Y),
            Enum.Parse<StickyType>(data.Type),
            data.Width,
            data.Height,
            data.CreatedAt,
            data.UpdatedAt);
    }

    // Data → Read Model (query path)
    public static StickyInfo ToInfo(this StickyData data)
    {
        return new StickyInfo
        {
            Id = data.Id,
            Text = data.Text,
            X = data.X,
            Y = data.Y,
            Type = data.Type,
            Width = data.Width,
            Height = data.Height,
            CreatedAt = data.CreatedAt,
            UpdatedAt = data.UpdatedAt
        };
    }

    // Domain → Data (saving to database)
    public static StickyData ToData(this Sticky sticky, Guid boardId)
    {
        return new StickyData
        {
            Id = sticky.Id.Value,
            BoardId = boardId,
            Text = sticky.Text,
            X = sticky.Position.X,
            Y = sticky.Position.Y,
            Type = sticky.Type.ToString(),
            Width = sticky.Width,
            Height = sticky.Height,
            CreatedAt = sticky.CreatedAt,
            UpdatedAt = sticky.UpdatedAt
        };
    }
}

ToDomain reconstructs a domain entity from persisted data. It calls Hydrate, the factory method that reconstitutes an entity with an existing ID. Value objects are reassembled: two decimal columns become a Position. Strings become enums. Guids become typed IDs.

ToInfo creates a read model for queries. It uses flat, primitive types. No domain objects. Read models are cheap to serialize and don’t carry domain behavior.

ToData flattens a domain entity for persistence. Value objects are decomposed: Position becomes two decimal columns. Typed IDs unwrap to Guid. Enums become strings.

The convention is consistent across every entity type. Once you understand the pattern for Sticky, you know how Connection, Boundary, and Participant work.


The repository implementation

Here’s how a repository uses these mappings to translate between the domain and persistence worlds:

public class BoardRepository : IBoardRepository
{
    private readonly StormBoardDbContext _context;

    public BoardRepository(StormBoardDbContext context)
    {
        _context = context;
    }

    public IUnitOfWork UnitOfWork => new UnitOfWork(_context);

    public async Task<Board?> GetByIdAsync(BoardId id,
        CancellationToken cancellationToken = default)
    {
        var data = await _context.Boards
            .Include(b => b.Stickies)
            .Include(b => b.Connections)
            .Include(b => b.Boundaries)
            .Include(b => b.Participants)
            .FirstOrDefaultAsync(b => b.Id == id.Value, cancellationToken);

        return data?.ToDomain();
    }

    public async Task AddAsync(Board aggregate,
        CancellationToken cancellationToken = default)
    {
        var data = new BoardData
        {
            Id = aggregate.Id.Value,
            ProductId = aggregate.ProductId.Value,
            Name = aggregate.Name,
            CreatedBy = aggregate.CreatedBy,
            CreatedAt = aggregate.CreatedAt,
            Status = aggregate.Status.ToString(),
            Type = aggregate.Type.ToString()
        };

        foreach (var sticky in aggregate.Stickies)
            data.Stickies.Add(sticky.ToData(aggregate.Id.Value));

        foreach (var connection in aggregate.Connections)
            data.Connections.Add(connection.ToData(aggregate.Id.Value));

        await _context.Boards.AddAsync(data, cancellationToken);
    }
}

GetByIdAsync loads the data entity with all its children, then calls ToDomain() to reconstruct the aggregate. AddAsync flattens the aggregate into data entities and hands them to EF Core. The domain never touches the DbContext. The DbContext never touches domain types.


The Unit of Work

The IUnitOfWork interface is deliberately minimal:

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default);
}

Its implementation wraps the DbContext:

public class UnitOfWork : IUnitOfWork
{
    private readonly StormBoardDbContext _context;

    public UnitOfWork(StormBoardDbContext context)
    {
        _context = context;
    }

    public Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        return _context.SaveChangesAsync(cancellationToken);
    }
}

Command handlers use the Unit of Work to commit changes after domain operations:

public class CreateBoardHandler
{
    private readonly IBoardRepository _boardRepository;
    private readonly IUnitOfWork _unitOfWork;

    public async Task<BoardId> HandleAsync(CreateBoard command,
        CancellationToken cancellationToken)
    {
        var board = Board.Create(
            ProductId.From(command.ProductId),
            command.Name,
            command.CreatedBy,
            command.Type);

        await _boardRepository.AddAsync(board, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return board.Id;
    }
}

The handler creates the aggregate, adds it to the repository, and saves. The repository stages the data. The Unit of Work commits. The handler doesn’t know the database exists.


Query repositories

Not every database read needs to go through the aggregate. Queries that return data for display (lists, summaries, dashboards) don’t need domain objects with behavior and invariants. They need flat data, fast.

A query repository returns read models instead of aggregates:

public interface IBoardQueryRepository
{
    Task<BoardInfo?> GetByIdAsync(Guid id,
        CancellationToken cancellationToken = default);
    Task<IReadOnlyList<BoardSummaryInfo>> GetVersionsByProductAndNameAsync(
        ProductId productId, string name,
        CancellationToken cancellationToken = default);
}

Read models are immutable records with primitive types:

public sealed record BoardInfo
{
    public required Guid Id { get; init; }
    public required string Name { get; init; }
    public required string Status { get; init; }
    public required string Type { get; init; }
    public required IReadOnlyList<StickyInfo> Stickies { get; init; }
    public required IReadOnlyList<ConnectionInfo> Connections { get; init; }
}

The query repository implementation uses ToInfo() instead of ToDomain(). It maps from data entities to read models, skipping the domain layer entirely. No aggregate is constructed. No invariants are checked. The data flows straight from the database to the API response.

This is the read side of CQRS, and it’s covered in more depth in the next post.


Common mistakes

Mapping domain entities directly to database tables. Using EF Core to map Board and Sticky directly (with value converters for BoardId, shadow properties for foreign keys, owned entities for Position) creates a tight coupling between your domain model and your ORM. Every domain change requires migration knowledge. Every migration requires domain knowledge. Separate data models eliminate this coupling.

Repositories that return child entities. A GetStickyByIdAsync method that loads a single sticky without its parent board bypasses the aggregate boundary. If you need to modify a sticky, load the board, call board.UpdateSticky(), and save the board. The aggregate root is the access point.

Leaking IQueryable from repositories. Returning IQueryable<Board> from a repository lets callers compose arbitrary queries against the aggregate. This defeats encapsulation. The repository should control what queries are possible. Return materialized collections or single objects.

Skipping the Unit of Work. Calling SaveChangesAsync directly on the repository instead of through IUnitOfWork means you can’t coordinate saves across multiple repositories in the same operation. The Unit of Work ensures that all changes within a handler are committed atomically.

Fat query repositories. If your query repository has 30 methods, you might need to split it by use case: one for the dashboard, one for the board detail page, one for the admin panel. Query interfaces should be cohesive.


The repository is the gateway between your domain and your database. It loads aggregates whole, saves them whole, and keeps the two worlds separated. The domain model stays pure: no ORM attributes, no database concerns, no infrastructure dependencies. The data model stays simple: flat entities optimized for storage and retrieval.

The next post covers CQRS: how separating commands from queries gives you different optimization strategies for writing and reading, and when the added complexity is worth it.

Found this useful?

If this post helped you, consider buying me a coffee.

Buy me a coffee

Comments