FireMidge.Libraries.Aggregates
1.0.0-beta
See the version list below for details.
dotnet add package FireMidge.Libraries.Aggregates --version 1.0.0-beta
NuGet\Install-Package FireMidge.Libraries.Aggregates -Version 1.0.0-beta
<PackageReference Include="FireMidge.Libraries.Aggregates" Version="1.0.0-beta" />
paket add FireMidge.Libraries.Aggregates --version 1.0.0-beta
#r "nuget: FireMidge.Libraries.Aggregates, 1.0.0-beta"
// Install FireMidge.Libraries.Aggregates as a Cake Addin #addin nuget:?package=FireMidge.Libraries.Aggregates&version=1.0.0-beta&prerelease // Install FireMidge.Libraries.Aggregates as a Cake Tool #tool nuget:?package=FireMidge.Libraries.Aggregates&version=1.0.0-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 double
s 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 | Versions 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. |
-
net6.0
- Microsoft.EntityFrameworkCore (>= 6.0.1)
- Microsoft.Extensions.DependencyInjection (>= 6.0.0)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 6.0.2)
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 | 153 | 4/23/2022 |
1.0.0-beta | 151 | 1/3/2022 |