Encapsulating Collections in Domain Entities

Encapsulating Collections in Domain Entities

By

7 min read··

ddddotnet

Encapsulate domain collections by storing a mutable collection in a private field, exposing a read-only wrapper, and routing changes through domain methods. Returning the original list as IReadOnlyList is insufficient because callers can cast it back. Protect child mutations too, and configure EF Core to populate the backing field.

The Problem With Public Collections

Exposing mutable collections on aggregate roots lets callers skip the root's rules.

// DON'T do this
public class Order : AggregateRoot
{
    public List<LineItem> LineItems { get; set; } = [];
}

This is dangerous because anyone can modify the collection directly:

order.LineItems.Clear();           // bypass all business rules
order.LineItems.Add(invalidItem);  // no validation
order.LineItems.Remove(item);      // no domain events raised

The aggregate root exists to protect domain invariants. If external code can reach inside and mutate the collection, the aggregate can't enforce its rules.

The Fix: Expose ReadOnly, Mutate Through Methods

The solution is a backing field with a read-only public property and dedicated methods for mutations:

The excerpts use application-defined ProductId, Money, events, and Result types. Money is immutable and compares by value; the root's factory and other order operations are omitted so the collection behavior is visible:

public class Order : AggregateRoot
{
    private readonly List<LineItem> _lineItems = [];

    public IReadOnlyList<LineItem> LineItems => _lineItems.AsReadOnly();

    public Result AddLineItem(ProductId productId, Money price, int quantity)
    {
        if (quantity <= 0)
            return Result.Failure(OrderErrors.InvalidQuantity);

        var existingItem = _lineItems
            .FirstOrDefault(li => li.ProductId == productId);

        if (existingItem is not null)
        {
            if (existingItem.Price != price)
                return Result.Failure(new Error(
                    "Order.PriceChanged", "Existing item has a different price."));

            existingItem.IncreaseQuantity(quantity);
            RaiseDomainEvent(new LineItemAddedEvent(Id, productId, quantity));
            return Result.Success();
        }

        if (_lineItems.Count >= 50)
            return Result.Failure(OrderErrors.TooManyLineItems);

        var lineItem = new LineItem(Id, productId, price, quantity);
        _lineItems.Add(lineItem);

        RaiseDomainEvent(new LineItemAddedEvent(Id, productId, quantity));

        return Result.Success();
    }

    public Result RemoveLineItem(ProductId productId)
    {
        var lineItem = _lineItems
            .FirstOrDefault(li => li.ProductId == productId);

        if (lineItem is null)
            return Result.Failure(OrderErrors.LineItemNotFound);

        _lineItems.Remove(lineItem);

        RaiseDomainEvent(new LineItemRemovedEvent(Id, productId));

        return Result.Success();
    }
}

Now every mutation goes through a method that can validate input, enforce invariants, and raise domain events.

A public List lets external code mutate items directly and bypass invariants, while an AddLineItem method validates rules and raises domain events before touching the private backing list

Configuring EF Core to Use Backing Fields

EF Core needs to know about the backing field to persist the collection. Use the Fluent API:

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasMany(o => o.LineItems)
            .WithOne()
            .HasForeignKey(li => li.OrderId);

        builder.Navigation(o => o.LineItems)
            .HasField("_lineItems")
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}

EF Core will populate the _lineItems backing field directly when loading the entity from the database. This is the documented backing-field pattern for collection navigations. Load the required children explicitly, for example with Include(o => o.LineItems); configuring a navigation does not automatically load it. Configure the ProductId conversion and Money mapping separately for your value-object definitions.

Is IReadOnlyList Enough to Protect a Collection?

There's a subtle but important difference between returning _lineItems.AsReadOnly() and casting to IReadOnlyList<T>:

// GOOD: Returns a true read-only wrapper
public IReadOnlyList<LineItem> LineItems => _lineItems.AsReadOnly();

// BAD: Can be cast back to List<T>
public IReadOnlyList<LineItem> LineItems => _lineItems;

The second approach is unsafe because a caller can cast it back:

var mutableList = (List<LineItem>)order.LineItems;
mutableList.Clear(); // encapsulation bypassed!

AsReadOnly() returns a ReadOnlyCollection<T> that wraps the original list. It can't be cast back. It also reflects changes to the underlying list, which is what you want - the read-only view stays in sync.

Encapsulating Sets and Dictionaries

The pattern works for any collection type:

public class ShoppingCart : AggregateRoot
{
    private readonly HashSet<CartItem> _items =
        new(ReferenceEqualityComparer.Instance);

    public IReadOnlySet<CartItem> Items => _items.AsReadOnly(); // .NET 9+

    public void AddItem(ProductId productId, int quantity)
    {
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive.");

        var existing = _items.FirstOrDefault(i => i.ProductId == productId);

        if (existing is not null)
        {
            existing.UpdateQuantity(checked(existing.Quantity + quantity));
            return;
        }

        _items.Add(new CartItem(productId, quantity));
    }
}

Returning the original HashSet as IReadOnlySet has the same cast-back weakness as a list. On .NET 9 and later, AsReadOnly() returns a ReadOnlySet<T> wrapper. Reference equality also prevents a mutable child's fields from changing its hash bucket. On older runtimes, expose an immutable snapshot or a defensive copy; returning the original set as IEnumerable still allows a cast back.

For key-value scenarios:

public class UserProfile : AggregateRoot
{
    private readonly Dictionary<string, string> _preferences = new();

    public IReadOnlyDictionary<string, string> Preferences =>
        new System.Collections.ObjectModel.ReadOnlyDictionary<string, string>(
            _preferences);

    public void SetPreference(string key, string value)
    {
        if (string.IsNullOrWhiteSpace(key))
            throw new DomainException("Preference key cannot be empty.");

        _preferences[key] = value;
    }
}

Protecting Child Entity Invariants

It's not enough to control access to the collection. You also need to ensure the child entities themselves maintain their invariants.

public class LineItem
{
    private LineItem() { } // EF Core materialization

    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public ProductId ProductId { get; private init; } = default!;
    public Money Price { get; private init; } = null!;
    public int Quantity { get; private set; }

    internal LineItem(Guid orderId, ProductId productId, Money price, int quantity)
    {
        if (quantity <= 0)
            throw new DomainException("Quantity must be positive.");

        ArgumentNullException.ThrowIfNull(price);

        Id = Guid.NewGuid();
        OrderId = orderId;
        ProductId = productId;
        Price = price;
        Quantity = quantity;
    }

    internal void IncreaseQuantity(int amount)
    {
        if (amount <= 0)
            throw new DomainException("Quantity increase must be positive.");

        Quantity = checked(Quantity + amount);
    }
}

The internal constructor and methods prevent ordinary callers outside the domain assembly from creating or changing line items directly. Other classes inside that assembly still have access, so respecting the root remains a design rule. The private parameterless constructor supports persistence, but you still need the value-object mappings; a read-only collection wrapper alone does not configure child materialization.

Collection Value Objects

Sometimes a collection itself is a value object. For example, an Address has a list of address lines, or a Schedule has a set of time slots.

public sealed class Schedule : IEquatable<Schedule>
{
    private readonly List<TimeSlot> _slots;

    public IReadOnlyList<TimeSlot> Slots => _slots.AsReadOnly();

    public Schedule(IEnumerable<TimeSlot> slots)
    {
        ArgumentNullException.ThrowIfNull(slots);
        var slotsList = slots.OrderBy(slot => slot.Start).ToList();

        if (slotsList.Count == 0)
            throw new DomainException("Schedule must have at least one slot.");

        ValidateNoOverlaps(slotsList);

        _slots = slotsList;
    }

    private static void ValidateNoOverlaps(List<TimeSlot> slots)
    {
        var sorted = slots.OrderBy(s => s.Start).ToList();

        for (int i = 1; i < sorted.Count; i++)
        {
            if (sorted[i].Start < sorted[i - 1].End)
                throw new DomainException("Time slots must not overlap.");
        }
    }

    public bool Equals(Schedule? other) =>
        other is not null && _slots.SequenceEqual(other._slots);

    public override bool Equals(object? obj) =>
        obj is Schedule other && Equals(other);

    public override int GetHashCode()
    {
        var hash = new HashCode();
        foreach (var slot in _slots)
            hash.Add(slot);
        return hash.ToHashCode();
    }
}

public sealed record TimeSlot
{
    public DateTime Start { get; }
    public DateTime End { get; }

    public TimeSlot(DateTime start, DateTime end)
    {
        if (end <= start)
            throw new DomainException("Slot end must follow its start.");

        Start = start;
        End = end;
    }
}

The constructor copies and sorts the slots, and both the collection and each TimeSlot are immutable through their public APIs. Schedule compares slot contents explicitly: declaring a record with a List<T> field would otherwise compare that list by reference. Use one consistent time basis, such as UTC, for these DateTime values.

Testing Encapsulated Collections

Tests should use the public API to verify collection behavior:

[Fact]
public void AddLineItem_WithValidData_AddsToCollection()
{
    var order = Order.Create(customerId);

    var result = order.AddLineItem(productId, Money.From(10, "USD"), quantity: 2);

    result.IsSuccess.Should().BeTrue();
    order.LineItems.Should().HaveCount(1);
    order.LineItems[0].ProductId.Should().Be(productId);
    order.LineItems[0].Quantity.Should().Be(2);
}

[Fact]
public void AddLineItem_WhenMaxReached_ReturnsFailure()
{
    var order = Order.Create(customerId);

    for (int i = 0; i < 50; i++)
        order.AddLineItem(ProductId.New(), Money.From(10, "USD"), 1);

    var result = order.AddLineItem(ProductId.New(), Money.From(10, "USD"), 1);

    result.IsFailure.Should().BeTrue();
    result.Error.Should().Be(OrderErrors.TooManyLineItems);
}

[Fact]
public void AddLineItem_ForExistingProduct_IncreasesQuantity()
{
    var order = Order.Create(customerId);

    order.AddLineItem(productId, Money.From(10, "USD"), quantity: 2);
    order.AddLineItem(productId, Money.From(10, "USD"), quantity: 3);

    order.LineItems.Should().HaveCount(1);
    order.LineItems[0].Quantity.Should().Be(5);
}

Summary

  1. Never expose mutable collections on domain entities - it bypasses invariant checks and domain events.
  2. Use a private backing field with IReadOnlyList<T> (via AsReadOnly()) as the public property.
  3. Provide explicit methods for add, remove, and update operations that enforce business rules.
  4. Configure EF Core to use PropertyAccessMode.Field so it hydrates the backing field directly.
  5. Use internal constructors and mutation methods to keep child changes inside the domain assembly, and route those changes through the root.
  6. For value objects with collections, set the collection once during construction and keep it immutable.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

Why should you not expose List properties on domain entities?

A public mutable list lets any caller add, remove, or clear items directly, bypassing validation, invariants, and domain events. The aggregate can no longer guarantee it is in a valid state.

How do you expose a read-only collection in C#?

Keep a private List backing field and expose it as IReadOnlyList through AsReadOnly(). The wrapper cannot be cast back to a mutable list, unlike returning the list directly as IReadOnlyList.

How does EF Core work with private collection fields?

EF Core can hydrate backing fields directly. Configure the navigation with UsePropertyAccessMode(PropertyAccessMode.Field), or rely on the convention that a field named with an underscore prefix matches the property name.

What is the difference between AsReadOnly and casting to IReadOnlyList?

AsReadOnly returns a ReadOnlyCollection wrapper that cannot be cast back to the underlying List. Returning the list itself typed as IReadOnlyList is unsafe because a caller can cast it back and mutate it.

How do you prevent child entities from being created outside the aggregate?

An internal constructor and mutation methods restrict ordinary access to the domain assembly. Other classes in that assembly still have access, so they must respect the aggregate root as the change boundary.

  • Aggregate Design in DDD - Rules, Boundaries, and Consistency

    Aggregates are the most important tactical pattern in Domain-Driven Design. They define consistency boundaries, enforce invariants, and protect your domain model. Here are the rules and practical guidance for designing aggregates in .NET.

  • Aggregate Root in DDD: Rules and Implementation

    The Aggregate Root is the gatekeeper of consistency in Domain-Driven Design. Here are the rules for designing aggregate roots and implementing them in C#.

  • The Always-Valid Domain Model

    An always-valid domain model enforces business invariants at construction and on every state change. Private constructors, validated value objects, and guarded methods reduce repeated validation while keeping persistence and concurrency checks explicit.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.