Skip to main content

Domain events

When an order is cancelled, other things must follow: the stock reserved for it is released, the customer gets a message, and the payment is refunded. If Cancel does each of these itself, the aggregate needs a mailer, a stock service and a payment client. The aggregate then depends on every part of the system that reacts to it, and each new reaction means a change to the order.

A domain event records that something happened. The order records that it was cancelled, and the code that cares reacts to that event. The side effects stay out of the aggregate. Other aggregates and other modules can react without the aggregate knowing about them.

The toolkit gives every event an id and a timestamp. Only the aggregate that owns an event can raise it, and only the persistence layer can drain it. This page shows how to declare, raise and read an event. It then covers delivery, the id and the timestamp, and the stable name an event needs once it is stored.

Declaring an event​

An event is a record that derives from DomainEvent. Name it after what happened:

using DTS.DDD.BaseTypes;

public sealed record OrderCancelled(OrderId OrderId, CancellationReason Reason) : DomainEvent;

DomainEvent supplies two properties:

public Guid EventId { get; init; } // Guid.CreateVersion7(), time ordered
public DateTimeOffset OccurredAt { get; init; } // UtcNow

An event needs no attribute and no partial. It is an ordinary record. The event id and the timestamp explains what the two properties are for.

Raising an event​

RaiseDomainEvent is protected on AggregateRoot<TId>, so only the aggregate the event describes can create it:

[AggregateRoot<OrderId>]
public partial class Order
{
public void Cancel(CancellationReason reason)
{
if (Status == OrderStatus.Shipped)
{
throw new InvalidOperationException("A shipped order cannot be cancelled.");
}

Status = OrderStatus.Cancelled;
RaiseDomainEvent(new OrderCancelled(Id, reason));
}
}

No code outside the aggregate can add an event to it. This matters when events are your integration contract. An event that says an order was cancelled should exist only if the order was cancelled.

Child entities marked [Entity<TId>] have no event list. A change to a child is a change to its aggregate, so the root raises the event.

Reading and draining​

IReadOnlyList<IDomainEvent> pending = order.DomainEvents;

DomainEvents is a read-only view. It is marked [Internal], the toolkit's marker for infrastructure members, so it is left out of your tables, your JSON and your GraphQL schema.

Draining the events is hard to reach on purpose. IHasDomainEvents is implemented explicitly:

public interface IHasDomainEvents
{
IReadOnlyList<IDomainEvent> DomainEvents { get; }
IReadOnlyList<IDomainEvent> DequeueDomainEvents(); // returns and empties
void ClearDomainEvents(); // discards
}
var events = ((IHasDomainEvents)order).DequeueDomainEvents();

You normally do not write that line. The Entity Framework integration drains the events during the save. The cast is required on purpose. Application code that could call ClearDomainEvents() without the cast could save a change and drop the event for it. When events are your integration contract, that is a data integrity problem.

Delivery​

Raising an event adds it to a list on the aggregate. DTS.DDD.EntityFramework delivers it to the handlers, in one of two ways. In the diagram, purple marks the save, where the two ways split:

Show the code: choosing how events are delivered

One line in the registration chooses the way. The aggregate does not change:

// in process: handlers run inside the save
builder.Services.AddDDDToolkitEntityFramework(options => options.DispatchWithMediator());

// through the outbox: rows in the same transaction, delivered afterwards
builder.Services.AddDDDToolkitEntityFramework(options =>
{
options.DispatchWithMediator();
options.UseOutbox(outbox => outbox.RegisterEventsFromAssemblyContaining<Program>());
});
builder.Services.AddOutboxBackgroundService<OrderingContext>(TimeSpan.FromSeconds(2));

// either way: the module's handlers, which the toolkit's generator registers
builder.Services.AddOrderingHandlers();

Delivering domain events shows both ways step by step, with the rest of the setup.

Every domain event is a notification of the toolkit's mediator, because IDomainEvent derives from INotification. A handler implements INotificationHandler<TEvent> from DTS.DDD.Mediator:

internal sealed class ReserveStock(InventoryContext db) : INotificationHandler<OrderPlaced>
{
public ValueTask Handle(OrderPlaced notification, CancellationToken cancellationToken)
{
// react to the order
return default;
}
}

The generator writes Add{Module}Handlers() into every project with a handler, so the class needs no registration of its own. The events of the supporting packages are notifications as well. See Dispatching with the toolkit's mediator.

In-process dispatch runs the handlers during SaveChanges, before the commit. Changes that a handler makes to the same DbContext are saved in the same transaction, and a handler that throws cancels the save. This is simple and transactional. It is also best effort. Nothing survives a process crash, and handlers must be fast, because the transaction stays open while they run.

Outbox delivery writes one row per event in the same transaction as the aggregate. A separate reader delivers the rows afterwards. The rows are written together with the data, so an event is never lost, and never published for a transaction that rolled back. Delivery is at least once, so handlers must be idempotent, keyed on EventId.

Use in-process dispatch for side effects in the same database. Use the outbox for anything that leaves the process. Domain event delivery has the configuration for both.

The event id and the timestamp​

EventId is the idempotency key. Any at-least-once delivery hands the same event to a handler twice at some point, and the handler needs a stable value to recognize it. OccurredAt records when the thing happened. A handler may run much later.

Both properties are init, so code that creates the event can set its own values:

var evt = new OrderCancelled(id, reason) { OccurredAt = recorded };

This is useful when you replay events you already have. It does not help in a test, because the aggregate creates the event, and the test never calls the event's constructor. In a test, DomainEventClock.Use(timeProvider) sets the clock that both default values read (Deterministic time in tests).

You can implement IDomainEvent directly instead of deriving from DomainEvent. Then you provide EventId and OccurredAt yourself.

Stable names​

An event that lives only in memory needs no name. Once it is stored or published, a name is written with it, in an outbox row or on a message. Later code reads that name back, and the class may have been renamed in the meantime. So every event has a name. You rarely have to write it.

The default name​

An event without a name of its own is named after its module and its class, both in kebab case. The module is the one the assembly declares, with <DDD_Module>Ordering</DDD_Module> in its project file or with [assembly: Module] (Modules):

[assembly: Module("Ordering")]

public sealed record OrderPlaced(OrderId OrderId, CustomerId Customer) : DomainEvent; // ordering.order-placed
public sealed record HTTPCallbackReceived(string Url) : DomainEvent; // ordering.http-callback-received
DomainEventName.For<OrderPlaced>(); // "ordering.order-placed"
DomainEventName.For(someEvent); // same, from an instance

In an assembly without a module, the name has no module part: order-placed. The namespace is not part of the name, so you can move a class to another namespace or folder without changing its name.

The version in the class name​

A class name that ends in V and a number is that version of its event. The suffix is not part of the name, so every version of one event has the same name:

ClassNameVersion
OrderPlacedordering.​order-placed1
OrderPlacedV2ordering.​order-placed2
Level2Reachedordering.​level2-reached1, because the digits do not follow a V

A class can also state its version with [IntegrationEvent(Version = n)]. A stated version wins over the version in the name. Somebody wrote it on purpose, and the suffix is the convention for when nobody did. When a class has both and they differ, the suffix is ignored and the build warns with DDD00034. Its code fix renames the class to the version it is. A suffix that cannot be a version, such as V0 or V01, fails the build with DDD00035. Versioning and upcasting explains what a version is for.

Renaming a class​

The default name follows the class. Rename OrderPlaced to PlacedOrder, and new rows are written as ordering.placed-order. The rows already in the outbox, and every consumer, still use ordering.order-placed. So when you rename the class, pin the old name:

[DomainEventName("ordering.order-placed")]
public sealed record PlacedOrder(OrderId OrderId, CustomerId Customer) : DomainEvent;

A published contract pins its name in its own attribute, [IntegrationEvent("ordering.order-placed")]. The version still comes from the class name, so PlacedOrderV2 with that attribute is ordering.order-placed version 2.

Two events with one name​

Two classes of one module can have the same name in different namespaces. The default name is then the same for both. An outbox row or a message could not say which of the two it is, so the compiler reports an error, DDD00036, on both classes:

namespace Ordering.Orders { public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; }
namespace Ordering.Returns { public sealed record OrderPlaced(OrderId OrderId) : DomainEvent; }
// error DDD00036: 'Ordering.Returns.OrderPlaced' and 'Ordering.Orders.OrderPlaced' are both stored or
// published as 'ordering.order-placed' version 1 ...

The code fix pins another name on the class you run it on, built from its namespace or its containing type: [DomainEventName("ordering.returns-order-placed")]. You can also rename one of the classes. The toolkit does not make the names unique by itself, for example from the namespace. Then a name would change when a class moved, or when a second class with the same name appeared, and the rows written under the old name would never be read, without a warning.

The check compares events of one kind only:

These twoError?
Two domain events with one name and versionYes
Two published contracts with one name and version, such as OrderPlaced and OrderPlacedV1Yes
A domain event and the contract it is published as, OrderPlaced and OrderPlacedV1No. This is the normal pattern.
OrderPlacedV1 and OrderPlacedV2No. These are versions.

The check runs where the module compiles, so it sees the events of that assembly. Two assemblies that declare the same module without referencing each other are compared when the application starts. The outbox registry then refuses the second event.

Name constants​

For every name that the module's events are stored or published under, the generator writes a constant into a class named after the module:

EventNames.g.cs

public static class OrderingEventNames
{
/// <summary><c>ordering.order-placed</c>: OrderPlaced (version 1), OrderPlacedV2 (version 2).</summary>
public const string OrderPlaced = "ordering.order-placed";
}

Use the constants where you write a name by hand: a topic binding, a test, a log query. Do not use them in an event's own [DomainEventName] or [IntegrationEvent]. The generator reads those attributes to write the constants, so a constant cannot name its own event. Write the literal there.

A constant is named after the event's name. So a renamed class that pins its old name keeps its old constant. When every event the class names is published, as in a contracts assembly, the generator marks the class [ModuleContract], so other modules can use the constants.

Where the name is stored​

  • The outbox row stores the name in EventName. The generated registration contains the name as a literal, written when the module compiles, so nothing reads an attribute at start-up.
  • The published message carries the name in Name. An inbox and a pgmq topic route on it.
  • A broker exchange, with MassTransit or Wolverine and UseIntegrationEventNames(), is named with the name and the version: ordering.order-placed.v1 (Transports).

IntegrationEventExtensions.g.cs, shortened:

public static OutboxOptions AddOrderingIntegrationEvents(this OutboxOptions outbox)
{
ArgumentNullException.ThrowIfNull(outbox);
outbox.RegisterEvent<Ordering.OrderCancelled>("ordering.order-cancelled", 1);
outbox.RegisterEvent<Ordering.OrderPlaced>("ordering.order-placed", 1);
return outbox;
}

The 1 is the version of the event's shape. It matters when the shape changes. The generated registration shows what else the method registers.

Rows written by an older build​

Before events were named by convention, an event without [DomainEventName] was stored under its bare class name, OrderPlaced. The outbox still finds such a row. Every registered type also matches its class name, as long as no current name has the same spelling and no other registered class has that class name. The outbox reads such a row as that class, upcasts it if the class is an older shape, and publishes it under the type's current name.

Testing​

With events, you can test an aggregate without a database. Call a method, then check which events it raised. DTS.DDD.Testing does the checking, and reports only the events that the call under test raised:

AggregateScenario.Given(order)
.When(o => o.Cancel(CancellationReason.OutOfStock))
.RaisedExactly<OrderCancelled>();

See Testing aggregates. It also shows how to fix the clock an event is stamped with, in Deterministic time in tests.