Domain-Driven Refactoring: Encapsulating Collections

Posts in this series:

In the last post, we looked at refactoring our domain model so that we have explicit entry points in order to mutate our domain object. I skipped one of members because it deserved a post all on its own:

public string FirstName { get; private set; }
public string LastName { get; private set; }
public string Email { get; private set; }

public List<Offer> AssignedOffers { get; set; } = new();

We've controlled mutation of the first three members but that last one is still wide open for...well, anything really! We can do things like:

var member = new Member("Jane", "Doe", "jane@doe.com");
member.Offers.Reverse();
member.Offers.Clear();

//or worst
member.Offers = null;

None of these operations are possible, or desired, through any kind of user interaction but the public API of my domain model allows this.

The solution to this is the Encapsulate Collection refactoring, which is a special case of our data encapsulations.

Modern collection types have a TON of functionality, and for lots of objects, that flexibility is great. But for domain models, where we're trying to enforce invariants and prevent our model from entering an invalid state, exposing a collection like this can easily lead to trouble.

Also, if we're trying to encapsulate entire operations, where manipulating the collection should only happen as a result of a set of other mutations, we might "forget" to do those operations when we modify the collection:

// member.NumberOfActiveOffers is 10

member.Offers.Remove(offer);

// member.NumberOfActiveOffers is still 10!

It may seem a small thing, but in the long run, these kinds of encapsulations pay off.

Encapsulating our list

Before removing our public collection, we first need to convert this auto-property to a property with a backing field. Our domain model still needs to modify the underlying collection, we just don't want those operations made public. ReSharper has this refactoring for us:

To property with backing field refactoring

This refactoring removes the auto-property and creates a backing field for us:

private List<Offer> _assignedOffers = new();
public List<Offer> AssignedOffers
{
    get => _assignedOffers;
    set => _assignedOffers = value;
}

The property initializer also moved to the field initializer. Next, we look for usages of this property inside this class and change them to use the field instead of the property:

public Offer AssignOffer(OfferType offerType, int value)
{
    var offer = new Offer(this, offerType, value);

    _assignedOffers.Add(offer);
    NumberOfActiveOffers++;

    return offer;
}

And:

public void ExpireOffer(Guid offerId)
{
    var offer = _assignedOffers.SingleOrDefault(o => o.Id == offerId)
                ?? throw new ArgumentException("Offer not found.", nameof(offerId));

    offer.Expire();

    NumberOfActiveOffers--;
}

Then we can come back to our property and properly "privatize" it by replacing the list with an enumerable and removing the public setter:

private readonly List<Offer> _assignedOffers = new();
public IEnumerable<Offer> AssignedOffers => _assignedOffers;

At this point, you might get compile errors because some client code was expecting to be able to access List<T> methods, so we have a couple of options:

  • Have the client code use LINQ
  • Expose a more usable, read-only version of our collection

The latter option is usually more desirable. We also have the issue where client code can cast our return value to a List<T> and manipulate away. It's not highly likely but easy to avoid by exposing a read-only version of our collection like:

private readonly List<Offer> _assignedOffers = new();
public IReadOnlyCollection<Offer> AssignedOffers 
    => _assignedOffers.AsReadOnly();

If we still have client code not compiling, it's likely because it's mutating our collection. In those cases, we would do what we've already done - extract/move method into our domain model so that we encapsulate ALL mutations of our collection.

Dealing with ORMs

The above works great but we still need to worry about persistence. Some ORMs like EF Core handle the above scenario without any additional configuration, but some may not.

In the case where your ORM may not handle this scenario, you may have to make compromises like:

  • Adding a private setter
  • Making the field protected
  • Making the properties virtual
  • Adding "shadow" properties
  • Adding additional configuration

Typically, I do the changes to make the encapsulated collection "work" and then evaluate the result to see if the end code still preserves the intent but doesn't add too much noise or extra work. If the resulting code is too obfuscated or obtuse with ORM workarounds (I ran into this with an older MongoDB library), then I tend to roll these changes back.

As always, it's important to use our judgement when making these sort of refactorings and ask ourselves if the overall codebase is more maintainable/understandable as a result.

In the next post, I'll look at some options of having our domain objects use domain services directly but without taking dependencies.