Domain-Driven Design: Aggregates, Events, and Commands¶
This guide explains how to implement domain-driven design (DDD) concepts using our event sourcing framework.
Table of Contents¶
Overview¶
Our event sourcing implementation uses three main components: - Commands: Represent intentions to change the system state - Events: Record facts about what has happened - Aggregates: Enforce business rules and maintain consistency
Creating a Strong-Typed ID¶
Each aggregate needs a strong-typed ID. IDs are declared as a readonly record struct
that wraps a single Guid Value and implements IId (defined in
LBS.Augment.EventSourcing). The interface only requires a Guid Value property and
an IsEmpty() method:
A typical ID adds an Empty value, implicit conversions to/from Guid, and a
deterministic factory built on GuidUtility.Create (an RFC 4122 name-based UUID), so the
same source data always produces the same ID:
using LBS.Anchor;
using LBS.Augment.EventSourcing;
public record struct SportId(Guid Value) : IId
{
private static readonly Guid sportIdNamespace = new Guid("{CB1746FF-B44F-42BA-A6DD-A5EDD727CE7A}");
// Deterministic, name-based creation: same name -> same Guid.
public SportId(string sportName) : this(GuidUtility.Create(sportIdNamespace, sportName)) { }
// When you need a fresh, non-deterministic identifier.
public static SportId NewSequential() => new(GuidUtility.NewSequentialGuid());
public static SportId Empty => new(Guid.Empty);
public bool IsEmpty() => this.Value == Guid.Empty;
public static implicit operator SportId(Guid id) => new(id);
public static implicit operator Guid(SportId id) => id.Value;
public override string ToString() => this.Value.ToString();
}
Because the type is a record struct, value equality, GetHashCode, and the
positional constructor (new SportId(guid)) are generated for you, so there is no need
to hand-write ==/!= operators. The wrapping struct is what lets
DomainCommand<TId>, DomainEvent<TId>, and AggregateRoot<TId> constrain their type
parameter with where TId : IId and stay strongly typed end to end.
Many domain IDs (for example
SportingEventIdandParticipantIdundersrc/Domain/LBS.Domain.Sport) expose a staticFrom(...)factory that derives the ID deterministically from source-feed data viaGuidUtility.Create, so re-ingesting the same fixture or participant resolves to the same aggregate.
Defining Commands¶
Commands represent intentions to change the system. They inherit from DomainCommand<TId>:
[DataContract(Name = "CreateSport", Namespace = TestNamespace.Default)]
public sealed record CreateSportCommand : DomainCommand<SportId>
{
[DataMember]
public int SportNumber { get; set; }
[DataMember]
public string? SportName { get; set; }
// Properties can be ignored using either attribute:
[IgnoreDataMember]
[JsonIgnore]
public bool HasBeenCreatedBefore { get; set; }
}
Key points for commands:
- Use [DataContract] and [DataMember] attributes for DataContractSerializer
- For JSON.NET, public properties are serialized by default
- Use [JsonIgnore] or [IgnoreDataMember] to exclude properties from serialization
- Inherit from DomainCommand<TId> where TId is your strong-typed ID
Creating Events¶
Events record facts about what has happened. They inherit from DomainEvent<TId>:
[DataContract(Name = "SportCreated", Namespace = TestNamespace.Default)]
public sealed record SportCreatedEvent : DomainEvent<SportId>
{
public SportCreatedEvent(CreateSportCommand command)
{
this.SportName = command.SportName;
this.SportNumber = command.SportNumber;
}
[DataMember]
public int SportNumber { get; set; }
[DataMember]
public string? SportName { get; set; }
}
Key points for events:
- Events should be named in past tense
- Include all necessary data to reconstruct the aggregate's state
- Consider creating constructors that accept related commands
- Mark properties with [DataMember] for serialization
Implementing Aggregates¶
Aggregates are the consistency boundary for your domain. They inherit from AggregateRoot<TId>:
public sealed class SportAggregateRoot : AggregateRoot<SportId>
{
// State
private string? sportName;
private int sportNumber;
private bool isCreated;
// Command Handler
public void Execute(CreateSportCommand command)
{
if (!this.isCreated)
{
this.RaiseEvent(new SportCreatedEvent(command));
}
}
// Event Handler
public void Apply(SportCreatedEvent domainEvent)
{
this.isCreated = true;
this.sportName = domainEvent.SportName;
this.sportNumber = domainEvent.SportNumber;
}
// Validation
public void Validate(SetSportNumberCommand command)
{
if (!this.isCreated)
{
throw new InvalidCommandException(command, "Sport is not created");
}
}
}
Key points for aggregates:
1. State Management:
- Keep state private
- Update state only in Apply methods
- Command Handling:
- Implement
Executemethods for each command - Validate business rules before raising events
-
Use
RaiseEventto create new events -
Event Handling:
- Implement
Applymethods for each event type - Update internal state based on event data
-
Don't perform validation in Apply methods
-
Validation:
- Create separate validation methods when needed
- Throw
InvalidCommandExceptionfor validation failures - Check aggregate state before allowing operations
Best Practices¶
- Immutability:
- Use records for Commands and Events
-
Make aggregate state private and immutable
-
Validation:
- Validate commands before raising events
-
Keep validation rules close to the aggregate
-
Naming Conventions:
- Commands: Imperative tense (CreateSport, UpdateSportNumber)
- Events: Past tense (SportCreated, SportNumberUpdated)
-
Aggregates: Suffix with AggregateRoot
-
Serialization:
- Always use DataContract and DataMember attributes
- Consider serialization implications when designing events