Skip to content

Managing Child Collections

It is quite common that our Aggregate Roots have a list of child entities. For example, a User Group has a list of Users, a Spare Request has a list of lines, etc.

As we covered in Idempotence, it is important that messages themselves are idempotent so that a consumer can be idempotent.

In order to make our messages idempotent when dealing with collections, we will receive the final list of child entities that the Aggregate Root should have. That is the easiest way to make our consumers idempotent, because if we were to consume a message twice, the Aggregate Root would end up with the same list of child entities.

Customizing the domain

In our domain, the Equipment is the Aggregate root. We will add a relation with a MaintenanceTask entity which is just a name for now.

C#
namespace ITsynch.Suite.Equipments.Domain
{
    public class MaintenanceTask : ICorrelatedEntity
    {
        public MaintenanceTask(Guid correlationId)
        {
            this.CorrelationId = correlationId;
        }

        public Guid CorrelationId { get; private set; }

        public string Job { get; private set; } = null!;

        public Equipment Equipment {get; private set;} = null!;

        public void SetJob(string newJob)
        {
            this.Job = newJob;
        }

        public void SetEquipment(Equipment equipment)
        {
            this.Equipment = equipment;
        }
    }
}

Now that we have our MaintenanceTask Entity, we can modify our Equipments entity to have a list of MaintenanceTasks.

C#
namespace ITsynch.Suite.Equipments.Domain
{
    public class Equipment : ICorrelatedEntity
    {
        // ...

        public ICollection<MaintenanceTask> MaintenanceTasks { get; private set;} = new List<MaintenanceTask>();

        //...
    }
}

Tip

Always use ICollection<T> when you need an abstraction over a list that has add/remove methods. Use IEnumerable<T> when you just need to loop through the results.

Customizing Persistence

In the Domain, a MaintenanceTask cannot exist by its own without it being related to an Equipment. We can say that the Equipment OwnsMany MaintenanceTasks.

EFCore supports this with Owned Entities

In our Equipment class mapping, we need to:

C#
builder.OwnsMany(e => e.Tasks)
        .WithOne(t=> t.Equipment);

We also need to create a new EntityTypeConfiguration for our MaintenanceTask that does:

C#
builder.HasOne(t=> t.Equipment)
        .OnDelete(DeleteBehavior.Restrict);

Customizing the Application

Let's add support for relating Equipments to MaintenanceTasks. Like we mentioned previously, we wanna apply what the incoming message says to our Aggregate Root. The state of the message is always the final state of the entity.

Our message could look like this:

C#
1
2
3
4
5
6
7
8
9
public record CreateOrUpdateMaintenanceTask(
    Guid CorrelationId,
    string JobName);

public record CreateOrUpdateEquipment(
    Guid CorrelationId,
    string DisplayName,
    string Code,
    IEnumerable<CreateOrUpdateMaintenanceTask> Tasks);

Now in our CreateOrUpdateEquipmentConsumer we need to apply the changes from the message to our entity. Usually, that would be as simple as doing something like:

C#
// ..
// REMARKS: Non functional pseudo code
var equipment = this.repo.GetEquipment(message.CorrelationId);
equipment.Tasks.Clear();
foreach (var task in message.Tasks)
{
    equipment.Tasks.Add(new MaintenanceTask(
        // ..
    ))
}
//..

However, if you have worked with an ORM like EntityFramework or NHibernate you would know that this will most certainly end up in deleting all rows and inserting them when changes are applied to the database.

In order to avoid that, we can't just Clear and recreate the collection. Nor we can assign a new collection instance. We need to:

  1. Create new MaintenanceTasks
  2. Update existing MaintenanceTasks with the new Job
  3. Delete MaintenanceTasks

In order to simplify this process, the Suite Framework includes a CollectionAdapter<TEntity, TInput> that we can use.

The adapter works by receiving a reference to the collection that we want to update and an implementation for CreateEntity, UpdateEntity and Compare. The adapter's job is to compare the incoming inputs against the Aggregate Root's collection. It does so by executing Compare against all elements.

It will then call CreateEntity when there are entities in the input not present in the Aggregate Root. It will call UpdateEntity for entities already present in the Aggregate Root.

It will also Remove entities from the Aggregate Root that are no longer present in the input collection. This can be customized by overriding DeleteEntity, however a default implementation is provided.

The implementation of the collection adapter should be placed in the Application layer.

C#
namespace ITsynch.Suite.Equipments.Application
{
    public class EquipmentMaintenanceTasksCollectionAdapter
        : CollectionAdapter<MaintenanceTask, CreateOrUpdateMaintenanceTask>
    {
        // Notice how we require the aggregate root to create the adapter
        // instead of the collection.
        public EquipmentMaintenanceTasksCollectionAdapter(
            Equipment entity)
            : base(entity.Tasks)
        {
        }

        protected override Task UpdateEntity(MaintenanceTask entity, CreateOrUpdateMaintenanceTask input)
        {
            // We receive an instance of an already existent MaintenanceTask
            // and we need to update it according to the input we receive.
            entity.SetJob(input.DisplayName);
            return Task.CompletedTask;
        }

        protected override Task<MaintenanceTask> CreateEntity(CreateOrUpdateMaintenanceTask input)
        {
            // We need to provide a MaintenanceTask for the input we receive.
            var entity = new MaintenanceTask(input.CorrelationId);

            // Here we would set all fields that can be set on creation only
            // and not modified afterwards.

            // Then, we can reuse code by calling UpdateEntity to handle the fields
            // that can be updated after the MaintenanceTask is created.
            this.UpdateEntity(entity, input);
            return Task.FromResult(entity);
        }

        protected override bool Compare(MaintenanceTask entity, CreateOrUpdateMaintenanceTask input)
        {
            // Usually we wanna compare CorrelationIds, but we would use whatever
            // unique identifier the Child Entity has over the Aggregate Root
            return entity.CorrelationId == input.CorrelationId;
        }
    }
}

The adapter can be unit tested, however we recommend just testing the consumer. We don't care how the consumer does the job, so long as it does so.

Now, in our consumer we need to instantiate the adapter and ApplyChanges using the input.

C#
public async Task Consume(ConsumeContext<CreateOrUpdateSampleDomainEntity> context)
{
    var entityData = context.Message;
    var entity = await this.repository.FindByIdAsync(entityData.CorrelationId);

    // Try creating the entity when it's not found.
    if (entity == null)
    {
        // ..
    }
    else
    {
        // ..
    }

    // Update all entity fields to what the incoming message says.
    entity.SetDisplayName(entityData.DisplayName);

    // Create a new instance of the adapter using the Equipment
    var tasksAdapter = new EquipmentMaintenanceTasksCollectionAdapter(
        entity);

    // Apply changes from the input message to the equipment using the adapter.
    await tasksAdapter.ApplyChanges(entityData.Tasks);

    // ..
}

Support for relations between Aggregate Roots

In the previous example, Equipments ownsMaintenanceTasks. The tasks themselves cannot exist without them being related to an Equipment. Equipment is the Aggregate Root and the MaintenanceTask is a child entity.

In many cases we do need to have an Aggregate Root related to another Aggregate Root, in which case the first Aggregate Root has a reference to another Aggregate Root, without owning it. For example, we could have an EquipmentGroup which has a list of Equipments. Let's quickly model that:

C#
namespace ITsynch.Suite.Equipments.Domain
{
    public class EquipmentGroup : ICorrelatedEntity
    {
        public EquipmentGroup(Guid correlationId)
        {
            this.CorrelationId = correlationId;
        }

        public Guid CorrelationId { get; private set; }

        public string DisplayName { get; private set; } = null!;

        public ICollection<Equipment> Equipments {get; private set;} = new List<Equipment>();

        public void SetDisplayName(string newName)
        {
            this.DisplayName = newName;
        }
    }
}

Our Equipment entity, needs to have a reference to its EquipmentGroups it belongs to. Let's model it as a many-to-many:

C#
namespace ITsynch.Suite.Equipments.Domain
{
    public class Equipment : ICorrelatedEntity
    {
        // ...

        public ICollection<EquipmentGroup> Groups { get; private set;} = new List<EquipmentGroup>();

        //...
    }
}

In the EntityTypeConfiguration for Equipment, we need to:

C#
builder.HasMany(g=> g.Equipments)
    .WithMany(e=> e.Groups);

In our application layer, we would receive an input message like this:

C#
1
2
3
4
public record CreateOrUpdateEquipmentGroup(
    Guid CorrelationId,
    string DisplayName,
    IEnumerable<Guid> Equipments);

In the consumer, we can use the CorrelatedEntityRelationalCollectionAdapter, as long as our child entity implements ICorrelatedEntity.

The consumer only needs to ApplyChanges to the adapter, which we instantiate giving it a reference to the collection and the Equipment repository.

C#
public async Task Consume(ConsumeContext<CreateOrUpdateEquipmentGroup> context)
{
    var entityData = context.Message;
    var entity = await this.repository.FindByIdAsync(entityData.CorrelationId);

    // Try creating the entity when it's not found.
    if (entity == null)
    {
        // ..
    }
    else
    {
        // ..
    }

    // Update all entity fields to what the incoming message says.
    entity.SetDisplayName(entityData.DisplayName);

    // Create a new instance of the adapter using the Equipment
    var tasksAdapter = new CorrelatedEntityRelationalCollectionAdapter<Equipment>(
        entity.Equipments, this.equipmentRepository);

    // Apply changes from the input message to the equipment using the adapter.
    await tasksAdapter.ApplyChanges(entityData.Tasks);

    // ..
}

The adapter will do a single DB Query to fetch all Equipments and then it will add them to the collection, when they are not present. It will remove them from the collection when they are no longer present in the input, and will do nothing in updates.