Skip to content

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

  1. Overview
  2. Creating a Strong-Typed ID
  3. Defining Commands
  4. Creating Events
  5. Implementing Aggregates

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:

public interface IId
{
    Guid Value { get; }

    bool IsEmpty();
}

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 SportingEventId and ParticipantId under src/Domain/LBS.Domain.Sport) expose a static From(...) factory that derives the ID deterministically from source-feed data via GuidUtility.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

  1. Command Handling:
  2. Implement Execute methods for each command
  3. Validate business rules before raising events
  4. Use RaiseEvent to create new events

  5. Event Handling:

  6. Implement Apply methods for each event type
  7. Update internal state based on event data
  8. Don't perform validation in Apply methods

  9. Validation:

  10. Create separate validation methods when needed
  11. Throw InvalidCommandException for validation failures
  12. Check aggregate state before allowing operations

Best Practices

  1. Immutability:
  2. Use records for Commands and Events
  3. Make aggregate state private and immutable

  4. Validation:

  5. Validate commands before raising events
  6. Keep validation rules close to the aggregate

  7. Naming Conventions:

  8. Commands: Imperative tense (CreateSport, UpdateSportNumber)
  9. Events: Past tense (SportCreated, SportNumberUpdated)
  10. Aggregates: Suffix with AggregateRoot

  11. Serialization:

  12. Always use DataContract and DataMember attributes
  13. Consider serialization implications when designing events