FireMidge.Libraries.Aggregates 1.0.5-beta

This is a prerelease version of FireMidge.Libraries.Aggregates.
dotnet add package FireMidge.Libraries.Aggregates --version 1.0.5-beta                
NuGet\Install-Package FireMidge.Libraries.Aggregates -Version 1.0.5-beta                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FireMidge.Libraries.Aggregates" Version="1.0.5-beta" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add FireMidge.Libraries.Aggregates --version 1.0.5-beta                
#r "nuget: FireMidge.Libraries.Aggregates, 1.0.5-beta"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install FireMidge.Libraries.Aggregates as a Cake Addin
#addin nuget:?package=FireMidge.Libraries.Aggregates&version=1.0.5-beta&prerelease

// Install FireMidge.Libraries.Aggregates as a Cake Tool
#tool nuget:?package=FireMidge.Libraries.Aggregates&version=1.0.5-beta&prerelease                

Domain Driven Design Aggregates

This library allows you quickly create new DDD Aggregates. These Aggregates can be configured to be event-sourced if required. Both event-sourced and non-event-sourced Aggregates publish Domain Events (but they don't have to).

Features:

  • Quick creation of new Aggregate Roots (event-sourced or not event-sourced)
  • Moving an Aggregate Root from being event-sourced to non-event-sourced and vice versa (if snapshotting is enabled)
  • Raising and applying of Domain Events
  • Restoring Aggregate Roots up to a specific event order (which is typically a timestamp)
  • Automatic creation of snapshots, to reduce the number of events that have to be loaded each time
  • Manual creation of snapshots (via method call)
  • Event history rewrite: Allows to add new events in the past, which will automatically invalidate later snapshots and recreate them from the new list of events.*
  • Create new event versions and use upcasting
  • Automatically execute Domain Event Listeners upon saving unpublished Domain Events

(*) If you use a sequential number as your event order, history rewrite won't be possible

Tech Stack

The library uses EF Core with PostgreSQL and currently doesn't support any other DB tool.

Usage

Note: This section may be incomplete, but will be expanded on over time.

Set-up Domain Events

To create a new Domain Event, create a new class and make it extend from FireMidge.Libraries.Aggregates.Domain.Ports.DomainEvent.DomainEvent. If you are not planning on using Event-Sourcing, you can just implement IDomainEvent directly instead of using the abstract.

This is an example of a Domain Event:

using System;
using System.Collections.Generic;
using System.Text.Json;
using FireMidge.Libraries.Aggregates.Domain.Ports.DomainEvent;
using FireMidge.Libraries.Aggregates.Domain.Ports.EventSourcing;

namespace APISkeleton.Domain.Identity.DomainModel.Event
{
    public class AccountEmailAddressChanged : DomainEvent
    {
        private readonly Data _data;

        public AccountEmailAddressChanged(string newEmailAddress, Guid accountId, Guid triggeredBy) 
            : base(
                accountId, 
                triggeredBy, 
                DateTimeOffset.Now
            )
        {
            _data = new Data(newEmailAddress);
        }

        public AccountEmailAddressChanged(EventEntity entity) : base(entity)
        {
            _data = new Data("placeholder@email.com");
            
            switch (entity.Version) {
                case 1:
                    var deserialisedData = JsonSerializer.Deserialize<Data>(entity.SerialisedEventData);

                    if (deserialisedData != null)
                        _data = deserialisedData;
                    
                    break;
                    
                default:
                    throw new NotSupportedException("Encountered unsupported event version");
            }
        }

        public static AccountEmailAddressChanged FromEventEntity(EventEntity entity)
        {
            return new AccountEmailAddressChanged(entity);
        }

        public string NewEmailAddress()
        {
            return _data.NewEmailAddress;
        }

        public override uint EventVersion()
        {
            return 1;
        }

        public override IEnumerable<string> EventNames()
        {
            // If this were used in event sourcing, it should
            // be a hardcoded string of event names used.
            return new string[1]
            {
                nameof(AccountEmailAddressChanged)
            };
        }

        public override object EventData()
        {
            return _data;
        }

        public class Data
        {
            public string NewEmailAddress { get; }

            public Data(string newEmailAddress)
            {
                NewEmailAddress = newEmailAddress;
            }
        }
    }
}
Constructor accepting EventEntity

The constructor which accepts an EventEntity instance is used by the Event Sourcing functionality. EventEntity is a stored domain event, serialised for DB storage and then deseralised back into a Domain Event.

In this constructor you also take care of event upcasting. This means if you've changed the structure of an event (and increased the version number), you can define in here how you want to handle deserialisation of an old event. If you added new properties, you would set defaults here, or if you removed properties, you can simply ignore them. If you changed property types, transform them. If you cannot upcast an event from a previous version to the current version, then it is a new event, not a new version of the same event.

EventVersion()

This method returns the current event version. If you change the structure of an event, but not in a breaking way (ie you can still upcast from the previous event to the new event without requiring new information), then increase this event version. There is no need to create a new class for an event unless you've introduced a breaking change.

EventNames()

Here is where you define the name(s) the event holds. It is recommended to hardcode the name, unless you are not planning on using Event Sourcing and instead just use these for fire-and-forget Domain Events only. You can use whichever name you choose here, although to make debugging easier, it is recommended to use the name of the current class (with or without namespace).

If you were to rename the class, or move its namespace, it won't break your ES events - simply add the new name to the EventNames() method, but remember to leave any previous names in. When an event is stored in the DB, it will be stored with whatever is the latest name in the EventNames() array, and when it is restored, it will be resolved into whichever DomainEvent contains its name. Restoration may still fail if instantiation has failed for whatever other reason (e.g. if you're throwing exceptions in the constructor).

EventData()

This returns all of the event-specific data (if there is any) for serialisation. It is recommended to create a separate (nested) Data class for each domain event, conveniently holding all event-specific data.

eventOrder

This is an argument that the FireMidge.Libraries.Aggregates.Domain.Ports.DomainEvent.DomainEvent constructor accepts. The event order does what it says on the tin - it allows events to be ordered so they can be re-applied to an Aggregate (when restoring) in the correct order. The event order defaults to the current timestamp, but you can override this by creating a new middleman between your Domain Events and the abstract DomainEvent mentioned above, or, by passing the argument into the base constructor from your events.

Currently, only doubles are allowed as eventOrder, which may change in the future to make it more configurable (e.g. by making DomainEvent and EventEntity generic). Another common option (apart from timestamp) is to use a sequential number, however this means you do not have the option to change history (which is useful in less common cases).

Non-event-sourced Aggregate

To create a new DDD Aggregate, create a new class and extend it from FireMidge.Libraries.Aggregates.Domain.Ports.DomainEvent.AggregateRoot.

Create 2 constructors - one requiring all the parameters that are needed to make your Aggregate valid, and the other one must be an empty one; The empty one is required by Entity Framework. The empty constructor can be made private (recommended).

Call the parent's RaiseEvent method to raise new domain events, which will be published automatically when saving your Aggregate. Example:

public void ChangeEMailAddress(string newEmailAddress, Guid triggeredBy)
{
    if (EMailAddress == newEmailAddress)
        return;
    
    EMailAddress = newEmailAddress;
    
    RaiseEvent(new AccountEmailAddressChanged(newEmailAddress, Id, triggeredBy));
}
Domain Repository

To create the matching repository for your non-ES Aggregate, create a new repository class and extend it from FireMidge.Libraries.Aggregates.Domain.Ports.DomainEvent.AggregateRepository.

This is an example of a domain repository of a non-ES Aggregate:

using System;
using System.Collections.Generic;
using System.Linq;
using APISkeleton.Domain.Identity.Port;
using FireMidge.Libraries.Aggregates.Domain.Ports;
using FireMidge.Libraries.Aggregates.Domain.Ports.DomainEvent;

namespace APISkeleton.Domain.Identity.DomainModel.Repository
{
    public class AccountV1Repository : AggregateRepository<Account>, IAccountRepository
    {
        private readonly IIdentityDatabaseAdapter _dbAdapter;
        
        public AccountV1Repository(IAccountDatabaseAdapter dbAdapter, IEventPublisher eventPublisher) : base(
            dbAdapter,
            eventPublisher
        )
        {
            _dbAdapter = dbAdapter;
        }

        public Account? FindById(Guid userId)
        {
            return _dbAdapter.GetAccounts().FirstOrDefault(u => u.Id == userId);
        }
        
        public Account GetById(Guid userId)
        {
            var instance = _dbAdapter.GetAccounts().FirstOrDefault(u => u.Id == userId);
            
            if (instance == null)
                throw InstanceNotFound.InstanceWithIdNotFound(userId, nameof(Account));

            return instance;
        }
    }
}

The database adapter you need to pass can be created by extending AggregateDbContext. In this library, we assume you will have one DBContext per Aggregate Root.

Example of a non-ES DB adapter:

using System.Linq;
using System.Threading.Tasks;
using APISkeleton.Domain.Identity.DomainModel;
using APISkeleton.Domain.Identity.Port;
using FireMidge.Libraries.Aggregates.Infrastructure.DbContexts;
using Microsoft.EntityFrameworkCore;

namespace APISkeleton.Infrastructure.DbContexts
{
    public class AccountDbContext : AggregateDbContext<Account>, IAccountDatabaseAdapter
    {
#pragma warning disable
        public IdentityDbContext(DbContextOptions<IdentityDbContext> options) : base(options) { } 
       
        /// <remarks>
        /// The name of this property must match the table name exactly.
        /// Normally, public properties would start with an uppercase letter, but because
        /// we changed the database tables to lowercase, this property must be lowercase too.
        /// 
        /// Since we encapsulated this inside of a public method anyway, nothing changes for the
        /// consuming code, even if we were to rename the table entirely.
        /// </remarks>
        public DbSet<Account> accounts { get; set; }
#pragma warning restore

        public DbSet<Account> GetAccounts()
        {
            return accounts;
        }

        protected override void CreateIfNotExists(Account aggregate)
        {
            if (accounts.FirstOrDefault(u => u.Id.Equals(aggregate.Id)) == null)
                accounts.Add(aggregate);
        }

        public override async Task Remove(Account aggregate)
        {
            var existingAccount = accounts.FirstOrDefault(u => u.Id.Equals(aggregate.Id));
            
            if (existingAccount != null)
                accounts.Remove(existingAccount);
            
            await SaveChangesAsync();
        }
    }
}

Event-Sourced Aggregate

Note that non-event-sourced Aggregates can in theory be switched to be event-sourced at any time. You can also switch them back from event-sourced to non-event-sourced should you change your mind. This only works if you had enabled snapshotting, and have a snapshot representing the current state before switching over to non-event-sourced.

TODO.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
1.0.5-beta 151 4/23/2022
1.0.0-beta 150 1/3/2022