Domain Events


Domain events represent that something happened in the domain model. Domain events capture the changes in domain model state. Following are the characteristics of domain events:

  • Immutable: Once created, they cannot change the properties.
  • Represents domain model state: They indicate modifications in the state of a domain entity.
  • Event name in past tense: The use of past tense in naming domain events is essential, as it signifies that a particular event has already occurred.

The Crystal Sharp framework provides a seamless way for creating and triggering domain events. Refer to the code snippet below for an illustration of how to create a domain event:

public class OrderPlacedDomainEvent : DomainEvent
{
    public string OrderCode { get; set; }
    public decimal TotalPrice { get; set; }

    public OrderPlacedDomainEvent(Guid streamId, string orderCode, decimal totalPrice)
    {
        StreamId = streamId;
        OrderCode = orderCode;
        TotalPrice = totalPrice;
    }

    [JsonConstructor]
    public OrderPlacedDomainEvent(Guid streamId,
        string orderCode,
        decimal totalPrice,
        int entityStatus,
        DateTime createdOn,
        DateTime? modifiedOn,
        long version)
   {
        StreamId = streamId;
        OrderCode = orderCode;
        TotalPrice = totalPrice;
        EntityStatus = entityStatus;
        CreatedOn = createdOn;
        ModifiedOn = modifiedOn;
        Version = version;
   }
}

In the above code snippet, the OrderPlacedDomainEvent class is inherited from the DomainEvent base class. In the Crystal Sharp framework, it is mandatory that every domain event inherit from the DomainEvent base class.

In the above OrderPlacedDomainEvent class, there are two constructors. The first constructor will be called when a domain event triggers. In the first constructor, the streamId parameter must be the GlobalUId of an aggregate.

The second constructor, with the JsonConstructor attribute, is called during the deserialization of a domain event. An important point about the second constructor is that here are four additional parameters: entityStatus, createdOn, modifiedOn, and version are used internally during the deserialization when replaying events.

Every domain event class must have both constructors, one for triggering a domain event and a second constructor with the JsonConstructor attribute with four additional parameters for deserialization.

To raise a domain event from an aggregate, a Raise method is available from the AggregateRoot<TKey> base class. The following code snippet presents an illustration of how to raise a domain event from an aggregate:

public class Order : AggregateRoot<int>
{
    public string OrderCode { get; private set; }
    public decimal TotalPrice { get; private set; }

    public static Order PlaceOrder(string orderCode, decimal totalPrice)
    {
        Order order = new() { OrderCode = orderCode, TotalPrice = totalPrice };

        order.Raise(new OrderPlacedDomainEvent(order.GlobalUId, order.orderCode, order.totalPrice));

        return order;
    }

    public void ChangeTotalPrice(decimal totalPrice)
    {
        TotalPrice = totalPrice;

        Raise(new TotalPriceChangedDomainEvent(GlobalUId, TotalPrice));
    }
}

In the above code snippet, the Order aggregate is depicted with two methods: PlaceOrder and ChangeTotalPrice. The utilization of the Raise method from the base class is evident as it serves to trigger a domain event.

“Apply” Method - When, where and why?

When an aggregate participates in event sourcing and utilizes event stores provided by the Crystal Sharp framework, the presence of the Apply method becomes mandatory. To fulfill this requirement, each aggregate must include an Apply method for every domain event. The following code snippet illustrates the implementation of the Apply method in aggregate:

public class Order : AggregateRoot<int>
{
    public string OrderCode { get; private set; }
    public decimal TotalPrice { get; private set; }

    public static Order PlaceOrder(string orderCode, decimal totalPrice)
    {
        Order order = new() { OrderCode = orderCode, TotalPrice = totalPrice };

        order.Raise(new OrderPlacedDomainEvent(order.GlobalUId, order.orderCode, order.totalPrice));

        return order;
    }

    public void ChangeTotalPrice(decimal totalPrice)
    {
        TotalPrice = totalPrice;

        Raise(new TotalPriceChangedDomainEvent(GlobalUId, TotalPrice));
    }

    public void Apply(OrderPlacedDomainEvent @event)
    {
        OrderCode = @event.OrderCode;
        TotalPrice = @event.TotalPrice;
    }

    public void Apply(TotalPriceChangedDomainEvent @event)
    {
        TotalPrice = @event.TotalPrice;
    }
}

In the above code snippet, the Order aggregate is presented with two overloads of the Apply method. These overloads are intended to handle different types of domain events, namely OrderPlacedDomainEvent and TotalPriceChangedDomainEvent. It is essential to note that the Apply method should always include a parameter that matches the domain event type, as illustrated in the provided code.

Domain Event Handlers

Domain event handlers are used to handle domain events. A domain event can have one or multiple handlers. To implement the domain event handler class, a class must implement the Handle method of the ISynchronousDomainEventHandler<TNotification> interface; here, generic TNotification is the domain event that is being handled.

IMPORTANT

Domain event handlers are designed to be triggered only after the data has been successfully stored in the database or event store.

The following code snippet illustrates how to create a domain event handler:

public class OrderPlacedDomainEventHandler : ISynchronousDomainEventHandler<OrderPlacedDomainEvent>
{
    private readonly ILogger _logger;

    public OrderPlacedDomainEventHandler(ILogger logger)
    {
        _logger = logger;
    }

    public async Task Handle(OrderPlacedDomainEvent notification, CancellationToken cancellationToken = default)
    {
        _logger.Log($"Order code: {notification.OrderCode}");
        _logger.Log($"Total price: {notification.TotalPrice}");
    }
}

In the above code snippet, the OrderPlacedDomainEventHandler class implements ISynchronousDomainEventHandler<TNotification> and its Handle(TNotification notification, CancellationToken cancellationToken = default) method, where TNotification is the domain event that is being handled. As in the above code snippet, TNotification is replaced by OrderPlacedDomainEvent.

It is possible that a domain event can have multiple handlers. The following code snippet illustrates two domain event handlers for OrderPlacedDomainEvent, one for email and one for SMS:

public class OrderPlacedEmailDomainEventHandler : ISynchronousDomainEventHandler<OrderPlacedDomainEvent>
{
    private readonly IMailService _mailService;

    public OrderPlacedEmailDomainEventHandler(IMailService mailService)
    {
        _mailService = mailService;
    }

    public async Task Handle(OrderPlacedDomainEvent notification, CancellationToken cancellationToken = default)
    {
        string to = "some.email@some.server.com";
        string subject = "New Order";
        string message = $"New order received: {notification.OrderCode}";

        await _mailService.Send(to, subject, message);
    }
}
public class OrderPlacedSmsDomainEventHandler : ISynchronousDomainEventHandler<OrderPlacedDomainEvent>
{
    private readonly ISmsService _smsService;

    public OrderPlacedSmsDomainEventHandler(ISmsService smsService)
    {
        _smsService = smsService;
    }

    public async Task Handle(OrderPlacedDomainEvent notification, CancellationToken cancellationToken = default)
    {
        string to = "0123456789";
        string message = $"New order received: {notification.OrderCode}";

        await _smsService.Send(to, message);
    }
}

In the above code snippets, both classes OrderPlacedEmailDomainEventHandler and OrderPlacedSmsDomainEventHandler handle the OrderPlacedDomain domain event.