Versioned Aggregates in Event-Driven Architecture: Maintaining Consistency and Integrity

In the realm of event-driven architecture (EDA), aggregates play a pivotal role. Aggregates are clusters of domain objects that are treated as a single unit for the purpose of state changes and data consistency. As an EDA system evolves, managing modifications to these aggregates while maintaining consistency is crucial. This is where the concept of versioned aggregates enters the picture. For more information on Aggregates See my What Is Domain Driven Design article.

What is a Versioned Aggregate

As mentioned in other articles an aggregate is A collection of Related entities that are treated like a single unit, a versioned aggregate is an evolution of this with the purpose of tracking change over time. Each time the aggregate is updated this should trigger an event and result in a new version. The advantages of this are:

  • Accuracy : Due to transient issues events may not always be processed in order, having a version number is a reliable way of ensuring that you are operating on a newer version than one you are already aware of. If Order is important please read my article on Azure Service Bus Sessions.
  • Auditing : If every change results in a new version the we have full history of all changes.
  • Managing Concurrent Updates : In highly collaborative systems, versioning can help to manage concurrent updates to an aggregate, if an update was made while you were making an update, this is easily identifiable as the version has been incremented.
  • Point-in-time Analysis : Tracking data versions allows for analysis of how the aggregate’s state has evolved.
  • Rollback : In complex systems, errors can slip in. Data versioning provides a reliable mechanism for correcting mistakes. You can roll back to previous states of an aggregate if needed, promoting data integrity.

Example

Consider the Finance industry when calculating interest you need to know the interest rate on a particular date. If Savings Product is an aggregate, it can be used to store the interest rate, each version would have:

  • Interest Rate
  • Effective Date
  • Version

Every time that and Interest update was scheduled this would create a new version and send a Savings Product New Version Event, downstream systems could take according actions such as notifying users of rate changes. When trying to trying to find the Interest rate for a particular date this is now easy as you can query the version on a date.

Techniques for versioning

There are a few ways of achieving versioning inside of your aggregate, the best will depend on the application.

Version as part of state

In this approach any data that can change is stored in an array internal object. While this is one of simplest ways of versioning it can consume significant space and if not implemented correctly cause performance issues.

Advantages :

  • Simple to implement and manage.
  • Once loaded into memory computationally easy to get data for a particular version.
  • Some consumers may only care about the latest version of an aggregate and as such they do not need to store and know about previous versions.

Disadvantages :

  • As you are storing essentially a snapshot of all data that can change with every change, this leads to duplication of all data that is not changing and as such larger storage requirements.
  • As the storage requirements can bloat, this can cause Read performance issues, there are methods to get around this however they can be tricky.

Event Sourcing

A powerful pattern intrinsically linked to EDA, event sourcing stores the complete sequence of events that have shaped an aggregate in an append-only log. The current state is derived by replaying these events. Event sourcing naturally provides data versioning capabilities.

Advantages :

  • Does not necessarily bloat storage as only the change is being stored

Disadvantages :

  • More computationally complex as the object needs to be built with all changes being applied from the beginning of time in order even if all that is required it the current state.

Code Exampling using Versioning as a part of state

Using the example above of a Savings Product a Version would have everything that is able to change

public record SavingsProductVersion
{
    public int Version { get; init; }
    public decimal InterestRate { get; init; }
    public DateOnly EffectiveDate { get; init; }
    public bool IsClosedToNewClients { get; init; }
}

The Aggregate contains only data that is immutable and accessors to version data. Following the principals of encapsulation there should be no public access to any internals (nothing external to the aggregate should be accessing a version directly, the user of this class should not be aware that we are maintaining a list of versions, this is not a concern to them and we should be able to change that without any effect or knowledge to the consumer of the aggregate).

public class SavingsProduct
{
    private List<SavingsProductVersion> _versions = []; // All versions
    private SavingsProductVersion LatestVersion => _versions.MaxBy(v => v.Version); // Latest Version
    
    public int Id { get; init; } // The Database generated Id
    public string ExternalReference { get; init; } // The external Id for this product
    public int Version => LatestVersion.Version; // The newest version of the product
    public decimal InterestRate => LatestVersion.InterestRate; //The interest rate of the newest version

    /// <summary>
    /// Get the interest rate on a particular date
    /// </summary>
    /// <param name="date">Date to retrieve the interest rate for</param>
    /// <returns>Interest Rate on the requested date</returns>
    public decimal GetInterestRateOnDate(DateOnly date) =>
        _versions.Where(v => v.EffectiveDate < date).MaxBy(v => v.Version).InterestRate;

    /// <summary>
    /// Add an upcoming rate change
    /// </summary>
    /// <param name="newRate">The new Interest Rate</param>
    /// <param name="effectiveDate">The date the new rate will be effective</param>
    public void AddUpcomingRateChange(decimal newRate, DateOnly effectiveDate)
    {
        _versions.Add(new SavingsProductVersion()
        {
            EffectiveDate = effectiveDate,
            InterestRate = InterestRate,
            Version = Version + 1,
            IsClosedToNewClients = LatestVersion.IsClosedToNewClients
        });
        //Send Event Here
    }
    ...
}

A Savings Product New Version Event would contain a flattened representation of the aggregate at the point in time of the version. Please note that the Database generated Id is absent, Database generated Ids are internals of the source system and should not leave said system, for this reason there should always be an external References that can uniquely identify the aggregate.

{
    "ExternalReference": "3MFTD-0127",
    "Version": 3,
    "InterestRate" : 4.27,
    "EffectiveDate" : "2024-03-22"
}

Conclusion

Data versioning within event-driven aggregates provides a powerful mechanism for enhancing Accuracy, audibility, concurrency, and Data Rollback. By carefully considering the techniques, their trade-offs, and your domain requirements, you can unlock new levels of insight and control within your EDA system.