# Encapsulating Collections in Domain Entities

> Exposing mutable collections on domain entities breaks encapsulation and lets callers bypass business rules. Here is how to encapsulate collections properly in C#.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/encapsulating-collections-domain-entities

**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**](https://milanjovanovic.tech/blog/aggregate-root-ddd) lets callers skip the root's rules.

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

This is dangerous because anyone can modify the collection directly:

```csharp
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**](https://milanjovanovic.tech/blog/what-invariants-are-and-why-a-domain-model-is-the-best-place-to-enforce-them).
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:

```csharp
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**](https://milanjovanovic.tech/blog/how-to-use-domain-events-to-build-loosely-coupled-systems).

![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](https://milanjovanovic.tech/blogs/articles/encapsulating-collections-domain-entities/collection-encapsulation.png)

## Configuring EF Core to Use Backing Fields

[**EF Core**](https://milanjovanovic.tech/blog/dbcontext-configuration-best-practices) needs to know about the backing field to persist the collection. Use the Fluent API:

```csharp
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**](https://learn.microsoft.com/en-us/ef/core/modeling/relationships/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>`:

```csharp
// 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:

```csharp
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:

```csharp
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()`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.collectionextensions.asreadonly?view=net-9.0) 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:

```csharp
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.

```csharp
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](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/internal) 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**](https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals).
For example, an `Address` has a list of address lines, or a `Schedule` has a set of time slots.

```csharp
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:

```csharp
[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.
