Skip to main content

Domain Events

A domain event records something that happened to an aggregate. Every event is written to the activity log automatically, and any event may be published to consumers in other modules, so its name and payload are a contract from the moment it is first raised. This page is the convention every module follows: how to design an event, how to wire it, how a consumer applies one, the rules behind them, and how the activity log records and reads them.

Designing an event​

Work through these steps in order when adding an event, or adding a field to one. They are what keeps events the same shape in every module; The rules in detail hold the reasoning for the rules that recur, and the wiring checklist covers the mechanics.

1. Name what happened. One event per occurrence, named for it and matching the domain method that raises it (ChangeLifecycle → ProjectLifecycleChangedEvent). Name the part of the record that changed, never the record alone:

  • An edit to the record's descriptive fields — name, description and the like, saved together from one form — is a DetailsUpdated event (ProjectDetailsUpdatedEvent). The name bounds what it may carry.
  • A transition is its own event, never an update carrying the new state: activating a strategic theme raises StrategicThemeActivatedEvent, because "archived from active" cannot be read off an update.
  • Changes that happen for different reasons are different events even when one form saves them together — a rename, a role change and a timeline change each have their own, so a consumer of one is never woken by the others and each can be versioned alone.

A bare XxxUpdatedEvent names no occurrence. It says only that something about the record is different, and every field added to the record later has nowhere else to go.

Raise only when the call changed something — see The aggregate decides whether anything changed.

2. Serve every consumer from the same events. An event records an occurrence, and every consumer reads that record: the activity log, a handler reacting to the change, a notification, and another module keeping its own copy of the record. A copy is built from the creation event, the details, the transitions and the deletion. The Work module keeps WorkProject from exactly that: ProjectCreatedEvent, ProjectDetailsUpdatedEvent, ProjectKeyChangedEventV2 and ProjectDeletedEvent. Never add an event whose purpose is to ship the whole record to a copy. The one event that carries a whole record is a baseline, and it is never delivered to anything. How the copy applies them when they arrive late, twice or out of order is in Consuming an event. Assume every event is published, whatever DurableEventRoutes says today — the routing table is a deployment detail, not the contract.

3. Make each field earn its place. A payload starts as identity alone — Id, and Key where the record has one. A field goes in only when it passes all four tests, and where the tests disagree, the earlier one wins.

  1. Is it a person? Then carry the id — EmployeeId, UserId — and never a name, email address or other personal detail, even one that was true at the time. The activity log is append-only and nothing migrates a payload once written, so personal data in an event cannot be corrected or erased; the reader resolves the person when it displays the entry. EventActor and RoleAssignmentChange are shaped this way. A record that is a person keeps the same rule on its own events: Employee and ExternalIdentityMapping carry ids and states, and a change to a name, address or handle is an event carrying the record's id and no values (EmployeeDetailsUpdatedEvent, ExternalIdentityMappingProfileChangedEvent). Check that an "id" really is one: a mapping's ExternalId is the person's address until a sync re-keys it, so no mapping event carries it.
  2. What could a consumer not do without it? Name the kind of consumer and what it would lose: the activity log rendering the entry, a handler reacting to the change ("tell the new Owner"), a replica keeping its copy. A kind of consumer, not a current subscriber — whether anything handles the event today does not decide its shape. "It is true right now", "it makes the event look complete" and "we might want it later" are not answers. This test is what keeps the record's Name off events that did not change it; see A payload carries the change and the identity.
  3. Is it the whole change? Carry both ends, the value before as well as after. A collection carries Added and Removed and the set afterwards, because a consumer reacting to the change and a replica keeping a copy need different things — see A change carries both ends.
  4. Is it this aggregate's own state? Carry another aggregate by its id. Its owner is the source of truth, and a copy in a payload is a second answer that stops agreeing with it. The exception is a value frozen at the moment of the event, below.

4. Check that it will survive. Every field is a contract — current code reads stored payloads forever, and consumers read published ones in the shape they were written against.

  • Prefer a representation that survives the changes you can already see coming: ProjectStatusChangedEventV2 carries a status as a name plus a LifecycleCategory rather than an enum value, so workflow-configurable statuses will not break it.
  • Make sure default can only mean "not recorded", because a payload written before a field existed reads it as default. Where a real value can be null, group the fields in a nullable record, as the *DetailsUpdatedEvents do with Previous.
  • Then declare the version, and know which change needs a new type — see Versioning an event.

5. Keep the recording out of the fact. Anything about recording an occurrence rather than the occurrence itself belongs on the ActivityLogEntry, not in the payload: its order (see Order lives on the ledger, not on the event), its category (see Categorising an entry), and a display label for the record when one is added.

When another aggregate's state belongs in the payload​

Test 4 has one exception: a value frozen at the moment of the event belongs in the payload even though another aggregate owns it. ProjectScoreRecordedEventV2.ScoringModelName is the case that set the rule. The score was recorded against the model as it was named then, and a reader of that score needs that name; looking the model up by ScoringModelId later answers a different question — what it is called now. The ProjectScore row freezes the name for the same reason, and the event carries the fact the record keeps.

Test 4 guards against a reference: a copy standing in for another aggregate's current state, which goes stale as that aggregate moves on. A frozen value never claimed to be current, so it cannot go stale. One question separates the two, and it is the same question behind every name a payload carries — would looking it up by id, whenever the payload is read, give the consumer what it needs?

FieldLookup by id at read timeBelongs in the payload?
The record's Name on a role changeReturns the name, and the name is not part of the changeNo — carry the id
Name on a *CreatedEvent or *DetailsUpdatedEventReturns today's name, not the one created or renamed toYes — it is the change
Name on StrategicInitiativeDeletedEventFinds nothing; the record is goneYes
ScoringModelName on a recorded scoreReturns the model's current name, not the one scored againstYes — frozen
Previous and new lifecycle names on a lifecycle changeReturns current names, not the ones that appliedYes — frozen
An employee's name on a role changeReturns the current nameNo — test 1 wins; carry the id
An employee's old and new name on their own renameReturns the current nameNo — test 1 wins; the id alone records that it changed

The last row is why the tests are ordered. A person's name at the time is a frozen value too, and it still stays out, because personal data must remain correctable and erasable.

Wiring checklist​

Once the shape is decided, these are the mechanics. Most are enforced somewhere — by the compiler, by a convention test, or by a Testcontainers test you add — and the list says which, so it is clear what nothing will catch for you.

The type

  • It lives in Wayd.Common.Domain/Events/{Module}/, is a sealed record deriving from DomainEvent<TSelf> with itself as TSelf, and is named {Aggregate}{WhatHappened}Event in the past tense. Convention test.
  • It implements IDomainEventDescriptor and declares its category, public static ActivityCategory ActivityCategory => ActivityCategory.StatusChanged;. Compiler. See Categorising an entry for which one.
  • It implements the module's marker (IPpmEvent, IProductManagementEvent) or IAggregateEvent, with AggregateType and AggregateId marked [JsonIgnore]. AggregateType is the value the record's Activity section reads by — use the one the aggregate's other events already declare.
  • It passes its version explicitly, base(actor, "1.0"). Compiler.
  • Its deserialization constructor takes the event's own fields, each parameter named and typed exactly like the property it sets — including one that sets EventId, which must be called eventId. Where a convenience constructor takes a model, add a [JsonConstructor] alongside it. Convention test.

Raising it

  • The domain method raises it with AddDomainEvent, only when something changed, comparing after assignment. Domain test — assert both that it raises and that a no-op call does not.
  • An aggregate raising its first event joins EventedAggregates, and from then on every public method that changes its state raises. See An evented aggregate changes only through events. EventCoverageTests.
  • A creation event whose Key is database-assigned is raised in AddPostPersistenceAction, with every other value captured in a local at creation. Create-then-mutate domain test.
  • An event raised by a mutation before the record's first save defers itself while Key == 0, if its payload carries Key.
  • A previous value read from a navigation has an .Include in the handler and a guard in the aggregate that throws when it was not loaded. Testcontainers test with SavedEntityRecorder — the fakes do not model .Include.

Recording and delivering it

  • Nothing to register for the activity log; capture is automatic, and the entry records the category the type declares.
  • It is raised on the aggregate and never published directly: only the SaveChanges drain depends on IEventPublisher. CqrsPatternTests. See An aggregate that is not a BaseEntity for users and roles.
  • Where the fact concerns another record too, the event implements IRelatedAggregateEvent, computed from payload ids and marked [JsonIgnore], and a migration backfills the entries already written. The related record's query handler resolves RaisedOn. See An event about more than one record. Testcontainers test for the backfill.
  • Add the type to DurableEventRoutes only when a consumer needs asynchronous delivery. A durable handler is delivered at least once and in no guaranteed order, so it follows Consuming an event.
  • A member type the serializer may not handle — a NodaTime type, a value object, a collection of records — gets a round-trip test in DomainEventSerializationTests.

Superseding one

  • The new type is …EventV{n} at "{n}.0", and the old one is marked [Obsolete] with its shape and name untouched. DomainEventVersioningTests.
  • Every consumer handles both generations.
  • A payload written as the old type is stored in a test and still deserializes — ProjectReparentedEvent_PayloadWrittenBeforeItWasSuperseded_StillDeserializes is the pattern.

Baselining an aggregate

  • The type is {Aggregate}BaselinedEvent in the creation event's folder, a sealed record deriving from BaselineEvent<TSelf, TCreated> with the aggregate's creation event as TCreated. It passes the creation event's aggregate type and version to the base, and takes no actor and no event id. BaselineEventConventionTests.
  • Its properties and constructor are the creation event's, name for name and type for type, minus the actor, plus recordCreatedOn and recordCreatedById passed to the base. BaselineEventConventionTests.
  • It implements no module marker. BaselineEventConventionTests.
  • When the creation event gains a field or a generation, the baseline gains the same one in the same change. BaselineEventConventionTests.

DomainEventConventionTests also fails a new event named only for its record (ProjectUpdatedEvent) and a payload property that looks like personal data (Email, FirstName, DisplayName, …).

What a save commits​

A save commits the rows and everything that records them — audit trails, activity log entries and outbox envelopes — in one transaction, then delivers what those rows raised once it has committed. That is what makes a durable event worth anything: an envelope that commits separately from the row it describes can be lost while the row survives, which is the one failure an outbox exists to prevent. If anything in the save throws — an audit trail, an envelope — the rows roll back with it, so a caller never sees success for a change whose events were discarded.

Spanning several saves goes through BaseDbContext.BeginUnitOfWork, whose CommitAsync commits and dispatches together:

await using var unitOfWork = await dbContext.BeginUnitOfWork(cancellationToken);
// … several SaveChangesAsync calls …
await unitOfWork.CommitAsync(cancellationToken);

Never open a transaction with Database.BeginTransactionAsync instead. A save that joins a transaction it did not open cannot know when the rows became durable, so it leaves its events queued — and a caller who commits without going through the scope strands them permanently. TransactionScopeTests fails the build if one appears outside the context.

One consequence worth knowing: because the save opens the transaction itself, a retrying execution strategy cannot be used. EnableRetryOnFailure would refuse a user transaction it did not start, and would therefore break every save in the application.

Consuming an event​

Durable events go through the outbox to durable local queues, one per message type. Delivery is at least once, the queues run handlers in parallel, and nothing orders one queue against another, so a consumer sees events late, twice, and out of order. DurableEventFailurePolicy retries any exception at 1s, 5s and 15s, then dead-letters it to Settings → System → Messaging.

A handler must be correct under all of that. For a consumer that reacts to a change ("tell the new Owner"), that means being idempotent on its own or deduplicating on EventId. A module keeping a copy of another module's record — WorkTeam, PlanningTeam, PpmTeam, WorkProject, PlanningSprint, the PPM StrategicTheme — follows the rules below, and every existing copy does.

1. Apply absolute values, never deltas. A handler writes the values the event carries, so applying the same event twice leaves the same copy. Collections arrive as the set afterwards for the same reason (see A change carries both ends).

2. Skip a change older than the one the copy holds. Each copy keeps a watermark per group of fields: the Timestamp of the last change applied to that group. A group is the set of fields one kind of change writes — a team copy has Details (name, code) and Activation (the active flag). An event older than its group's watermark is skipped, because a newer change already replaced what it describes; a tie applies, so a redelivery re-applies cleanly. One watermark for the whole record would not work: a rename at 10:02 would make a deactivation from 10:01 look stale, although nothing newer ever wrote the active flag.

The comparison lives on the copy, in ApplyDetails, ApplyActivation, ApplyKey and the like, which return false when there was nothing to apply, so the handler skips the save. ReplicaWatermark.IsStale is the one rule they share. The watermarks are stored as a small JSON column, ConfigureReplicaTracking in Infrastructure.

3. A missing copy is built from the source. When an event reaches a copy that does not exist — the create is still in flight, or was processed out of order — the handler reads the record's current state through the owner's single-record query in Common.Application/Requests (GetSimpleTeamQuery, GetSimpleProjectQuery, …) and creates the copy from it. It does not throw and wait for the create: the source already holds everything the create would have written, and more. The new copy's watermarks are stamped with the triggering event's timestamp, not the handler's clock. The source is at least as new as that event, so older events still in flight are rightly skipped, while a change made during the read, timestamped before it but committed after, is still newer than the stamp and applies when its event arrives.

A creation event goes through the same path rather than applying its own payload, which is what stops a create redelivered after a delete from bringing the copy back.

4. Deletion needs no tombstone. A delete removes the copy, and a redelivered delete finds nothing. A late event for a deleted record finds no copy, asks the source, finds nothing there either, and does nothing. Ids are never reused, so "the source no longer has it" is final.

5. A bulk resync never moves a watermark backwards. The Hangfire syncs (SyncWorkTeamsCommand, …) take the time before reading the source and pass it as AsOf. For each group, a copy that already matches the source keeps its watermark — stamping it would skip a change timestamped before the read but committed after it. A group that differs is overwritten and stamped AsOf, unless it holds a change newer than the read, in which case the read is the older of the two and the group is left alone. A copy the source no longer lists is deleted, unless any group took a change after AsOf: that record was created after the read, not deleted.

6. Concurrent writers to one copy conflict rather than overwrite. The watermark column is written whole, so two handlers applying different groups of the same copy would each put back the other's old watermark. Each copy has a rowversion; the second save fails with a concurrency conflict, and the durable failure policy retries it against fresh state.

What this does not cover. Watermarks compare event timestamps, which come from each API instance's clock: two changes to the same group within the clock skew between instances can apply in the wrong order. Imports stamp a whole batch with one instant, so one command changing the same group of one record twice ties, and the later delivery wins. And a bulk resync that finds a group drifted and misses an uncommitted change to it stamps past that change. Each needs a coincidence on one group of one record, and the next resync corrects it.

When adding a copy: give it a {Copy}Watermarks record with a property per group, Apply{Group} methods and a Resync, a constructor that takes the source state and an asOf, ConfigureReplicaTracking in its entity configuration, a single-record query from the owning module, and tests that deliver its events out of order, twice, and after a delete.

The rules in detail​

Every raise is a fact; nothing collapses them​

BaseEntity offers one way to raise. A domain method that changed something raises an event, and that event survives — two calls that each changed something are two facts, and the aggregate does not try to work out that only the last one "really" counts.

Never add a method that drops a pending event of the same type so that, say, replacing a project's role list twice in one request records a single net change. The aggregate cannot see the save boundary. Events are drained and cleared by SaveChanges, so the same two domain calls would record one entry or two depending on where the handler put its save — a persistence detail deciding what the business record says. And "the earlier state was never committed" is a persistence claim about a state the aggregate genuinely held.

What keeps the log clean is not collapsing events after the fact but not raising them in the first place — see The aggregate decides whether anything changed.

The aggregate decides whether anything changed​

An event is raised only when the call actually changed something. A whole-record update sends every field on every save, so a method that raises because its code path ran records "the roles changed" on an edit that touched only the description — and the entries that did change something get buried.

That decision belongs to the aggregate, not to SaveChanges. EF's change tracker knows which columns are dirty and could drive it, but that would put a persistence mechanism in charge of what counts as a business fact — the same objection that applies to inferring events from the change tracker at commit time. It also cannot express "these five properties together mean one DetailsUpdated", and it does not see a role-assignment collection change as a property change at all.

The comparison runs after assignment, never against the arguments:

var before = new ProgramDetails(Name, Description);

Name = name; // the setters Guard, then Trim
Description = description;

if (before != new ProgramDetails(Name, Description))
{
AddDomainEvent(new ProgramDetailsUpdatedEvent(this, before, actor.ToEventActor(), timestamp));
}

Name and Description normalise in their setters, so if (Name == name) reports a change when a caller passes "Atlas " and "Atlas" is stored — raising an event whose payload is identical to the one before it. Comparing the stored values on both sides is what makes the guard correct rather than merely present.

Collections compare the same way, ignoring order: RoleManager.Diff for role assignments (the event is raised when it gained or lost anything, and carries both lists), a set difference for strategic themes.

An evented aggregate changes only through events​

Once an aggregate raises events, its activity log is its history, and one change that raises nothing makes that history incomplete — silently, because the row still saves. EventCoverageTests (in Wayd.ArchitectureTests) holds three lines:

  • Every public method that changes an evented aggregate's state raises. The test reads the compiled domain code, following each public method, factory and constructor through the domain calls it makes, and fails naming any that writes state with no AddDomainEvent on any path. The aggregates it checks are listed in EventedAggregates, and that list must match the types that raise: a type raising its first event fails the test until it is added. A child entity of an evented aggregate (ChildEntities) is part of its history, so its public methods are checked the same way; one that only the aggregate should call is made internal.
  • Every entity has a recorded decision. Each type a DbSet exposes is on exactly one of EventedAggregates, UneventedAggregates (with the reason, or the issue that will event it) and ChildEntities (with the aggregate it belongs to), so a new table cannot arrive without one.
  • Nothing writes an evented aggregate's table in bulk. ExecuteUpdate, ExecuteDelete and raw UPDATE/DELETE/INSERT/MERGE never load the aggregate, so they cannot raise. The test fails on one in shipped code or in a migration newer than the guard; load the records and change them through the aggregate instead.

A change that is deliberately not history goes on UneventedMutators with its reason, and a migration that accounts for its writes another way on SetBasedWriteMigrations. Shipped code that writes a column that is no part of the aggregate's history — LastSeenWriter stamping a personal access token's LastUsedAt on every authenticated request — goes on SetBasedWriteSourcesOutsideHistory. Both lists fail on an entry that no longer excuses anything. The method check is "raises somewhere", not "raises on every path": a method that raises in one branch and changes state unevented in another passes, so it is a floor, not a proof.

Synced data is not a reason to stay unevented. The sync is the moment Wayd learns the fact, the source system's own history is not queryable from Wayd, and an aggregate that raises only on a real change raises in proportion to change, not to how often it syncs. The reasons that do hold are narrower: a record that is a copy built from another module's events (those events are its history), a record that is itself a log or the state of a run, and genuine change at a volume that would dominate the log where the source keeps a richer history (WorkItem). Making an aggregate evented starts with its creation event and baseline, and ends with the baseline backfill once every change raises.

An aggregate that is not a BaseEntity​

ApplicationUser and ApplicationRole derive from ASP.NET Identity's IdentityUser and IdentityRole, so they implement IEntity themselves, and the drain collects their events like any other entity's. Their changes are saved by UserManager and RoleManager, which save through the same context, so an event raised before a manager call is drained by that call's save.

A manager call can fail without saving: UpdateAsync returns a failed IdentityResult when validation rejects the user, before it reaches the database. The caller then clears the events it raised. Left on the entity, they would be recorded by the next save in the scope, as though the change had happened. A flow that spans several manager calls, such as replacing a user's roles, runs them in one unit of work and raises its event before the last call, so the change and its record commit together or not at all.

A change carries both ends​

An event that records a change carries the value before as well as the value after. The change is the fact, and a consumer should not have to find an earlier event to learn what moved: "reverted from Active" cannot be recovered from the new status alone, and the earlier event may be one the consumer never received. Status changes carry From* and To*; timeline changes carry PreviousDateRange; lifecycle and scoring-model changes carry the previous id and name. A ledger entry — a health check added, a score recorded — is a new row rather than a value moving, so it has only one end.

Adding the previous value to a published event is a compatible change, a minor version, and a payload written before it reads the new field as default. Make sure default can only mean "not recorded". A flat PreviousBusinessCase could not: a business case may genuinely have been empty. So the *DetailsUpdatedEvents group what they replaced as one Previous record, which is null only on an older payload, and the nulls inside it are real.

Collections carry both the change and its result, because they serve two kinds of consumer. Added and Removed are the fact — who gained and who lost which role, which themes were tagged and untagged — and are what a consumer reacting to the change needs ("tell the new Owner"). Roles and StrategicThemes are the set afterwards, in the encoding the Created events use, and are what a consumer keeping a copy needs: applying the latest set is correct however deliveries were ordered or repeated, whereas applying deltas in the wrong order produces the wrong set. The two are redundant by design; the full set is what makes a replica self-healing.

A payload carries the change and the identity, not a description of the record​

A payload holds what changed plus the identity needed to locate the aggregate — Id, and Key where the record has one, since that is how the UI and the importers address it. It does not carry the record's Name. A name is a reference to the aggregate rather than a fact of the event, it goes stale against the record, and because the Activity section diffs an entry against the previous one, a rename otherwise shows up inside the diff of an unrelated change.

The exception is where the name is the change: *CreatedEvent and *DetailsUpdatedEvent carry it, because "created as X" and "renamed to X" are the facts those events record — and the Work module builds its WorkProject projection straight off those two payloads. The same reasoning keeps a name on StrategicInitiativeDeletedEvent: the record is gone, so the payload is the only remaining description of what stopped existing.

A record's display label for the activity log is a separate, unsolved concern. The conventional home for it is the envelope — ActivityLogEntry already carries AggregateType and AggregateId and would gain a label alongside them, the way CloudEvents uses subject — rather than a Name repeated through every payload.

A creation event records the record as created​

Most creation events are deferred with AddPostPersistenceAction, because Key is assigned by the database and an event raised at construction would carry zero. The action runs when SaveChanges completes, so anything it reads off the aggregate is the aggregate as saved, not as created — and a caller that creates a record and moves it on before the first save is ordinary: every import does it. The portfolio import creates a Proposed portfolio and activates it, the version import walks a new version to the status its row describes, the team import creates a team active (the only way the domain allows) and then deactivates an already-retired one.

Read at execution time, the creation event then records the later state as the creation and duplicates a transition that already has its own event. So capture every value in a local when the aggregate is created, and read only Key inside the action:

var portfolio = new ProjectPortfolio(name, description, ProjectPortfolioStatus.Proposed, roles);

// Captured now, not when the action runs.
var createdName = portfolio.Name;
var createdDescription = portfolio.Description;
var createdStatus = (int)portfolio.Status;
var createdRoles = portfolio.RoleMap();

portfolio.AddPostPersistenceAction(() => portfolio.AddDomainEvent(new ProjectPortfolioCreatedEvent(
portfolio.Id, portfolio.Key, createdName, createdDescription, createdStatus, createdRoles,
actor, timestamp)));

Where the key is not database-assigned — Project supplies its own — build the event outright and defer only the raise. Where nothing mutates a new record before its first save today, capture anyway: it costs nothing and the next caller does not have to know the rule to stay correct. The pattern has a test per aggregate that has such a caller, which creates the record, mutates it, executes the post-persistence actions and asserts the creation event still describes the creation.

Events raised by a mutation before the first save are the other half of this, and the mirror-image problem: their values are already the ones the caller just set, but Key is still zero. Deployment, DeploymentEnvironment, the teams (BaseTeam.AddKeyedDomainEvent), Iteration, StrategicTheme, StrategicInitiative, StatusWorkflow, ScoringModel, PlanningInterval, PlanningIntervalObjective and Risk defer those raises for that reason (if (Key == 0)); the release, version and package transitions do not yet, so an import that ships a record in the same pass records those events with Key 0.

Versioning an event​

An event's published shape is a contract with two kinds of reader, and each forces a rule:

  • Stored payloads are read by current code, forever. The activity log is append-only and nothing migrates a payload once written, so every event must stay readable by every later version of itself.
  • Published events are read by consumers written against the shape they were published with. Assume every event has such a consumer, whether or not one is subscribed today — the routing table is a deployment detail, not the contract.

Every event declares its version explicitly (base(actor, "1.0")); DomainEvent's constructor has no default, so an event that omits it does not compile. What a change does to it:

ChangeResult
Add a field whose default is a valid valueSame type, minor bump: 1.0 → 1.1
Add a field whose default is not a valid valueNot compatible — see the next row
Remove, retype, or repurpose a fieldNew type at the next generation: XxxEventV2, "2.0"

Why a breaking change is a new type rather than a major bump on the same one. Consumers dispatch on the type. A "2.0" payload of the same type still reaches every consumer written against "1.0", and with System.Text.Json a member the payload no longer carries binds to default without error — so the consumer receives a null where its type promised a value. The version number labels that break but protects nobody unless every consumer checks it before deserializing. A new type is the only thing that keeps an old consumer from receiving a shape it cannot read. The type suffix carries the major version; EventVersion carries it too, so the log reads correctly on its own, and its minor carries the compatible revisions within that generation.

The superseded type is frozen, never deleted. It is marked [Obsolete] so nothing raises it, and its published shape and class name stay exactly as they were — payloads written as it must still deserialize, and ActivityLogEntry.EventType stores the class name, so renaming it would orphan every existing row. A consumer handles both generations until nothing can still deliver the old one: ProjectSyncHandler keeps its ProjectKeyChangedEvent handler for envelopes written before the switch and still in the outbox.

The activity log's summary drops the generation suffix, so ProjectReparentedEventV2 still reads "Project Reparented"; EventType keeps it, because that is what consumers dispatch on.

Avoid needing a new generation. Most breaking changes can be made compatible by adding before removing: add the replacement field, move consumers onto it, and retire the old field in a later generation only if you still need to. Design payloads so they survive the changes you can see coming — ProjectStatusChangedEventV2 carries a status as a name plus a LifecycleCategory rather than an enum value, so workflow-configurable statuses will not break it.

A new field that existing records get a value for is recorded as a System change. When a migration gives every existing record a value for a new field — TeamOperatingModelSettings.WorkingDays, which every operating model received as Monday to Friday — the stored payloads before it stay as they are, with the field null, and the field's documentation says what null meant. The migration then writes one entry per record: the event a user's change to that field raises, by EventActor.System, with the field null before and the assigned value after. The record's history then shows when Wayd began recording the value instead of a value appearing unexplained. The payload is hand-built T-SQL with a derived EventId, as for a baseline, and TeamOperatingModelWorkingDaysBackfillTests is the pattern for proving it matches the factory's entry.

DomainEventVersioningTests enforces two of these rules across every event: the type's generation and its version's major must agree, and every superseded generation must still exist and be [Obsolete]. The rest — that a new field's default is valid, that a frozen type's shape never moves — only a stored golden payload can prove; ProjectReparentedEvent_PayloadWrittenBeforeItWasSuperseded_StillDeserializes is the pattern.

Sharing an id with the record a transition writes​

ProjectStatusChangedEventV2 takes its EventId from the ProjectStatusHistory row that recorded the transition, rather than a fresh value. The two describe one fact, and sharing an identity is what lets history written before the event existed be replayed into the log exactly once — the Backfill-Project-Status-Activity migration inserts WHERE NOT EXISTS (… WHERE Id = h.Id), so it can never duplicate a transition that already raised its own event. The identity holds across generations: the backfill writes its rows as the frozen ProjectStatusChangedEvent and live transitions raise V2, and both take the row's id. Reach for this wherever an aggregate writes both a durable record and an event about the same occurrence.

Only Project has such a record. Program and ProjectPortfolio assign their status directly, so ProgramStatusChangedEvent and ProjectPortfolioStatusChangedEvent take fresh ids and their Activity sections start from the release that introduced them — there is no row to share an id with and nothing to backfill from. Adding a history table to either is a separate decision, not a gap in this one.

Baselining a record that predates its events​

Most records existed before their aggregate raised a creation event, so their history in the log starts partway through: a change to a state the log never recorded. A baseline records that tracking began and what the record looked like at that moment. From it onwards, a record's history starts from a known state, and once the aggregate raises an event for every change it can be read, and replayed, without a gap.

It is called a baseline, not a snapshot, because in event sourcing a snapshot is a cached fold of a stream, kept to save replaying it. A baseline is the opposite: it is the start of the stream, like an opening balance.

The shape is the creation event's. ProjectBaselinedEvent carries exactly ProjectCreatedEvent's payload at the same version, so anything folding a history starts the same way from either. The two differ in meaning only. A creation event says the record came into existence. A baseline says it already existed when tracking began. BaselineEvent<TSelf, TCreated> names the creation event, and BaselineEventConventionTests fails a baseline whose properties, version, generation or aggregate type drift from it. So a field added to the creation event is added to the baseline in the same change.

It adds only when and by whom the record was created. A creation event records these as its own Timestamp and actor, and a baseline has neither, so BaselineEvent declares RecordCreatedOn and RecordCreatedById once for every baseline. RecordCreatedById is an employee id, like ReportedById. The writer reads the values from the record's SystemCreated and SystemCreatedBy shadow properties, and maps the creating account to its linked employee. Both are null when they were not recorded or no employee is linked. They are the only fields outside the creation event's payload, and they describe the creation, not the record at the baseline's Timestamp.

It is the bounded exception to "never ship the whole record". That rule exists because a routed event carrying the whole record becomes a second, drifting copy that consumers depend on. A baseline is recorded in the activity log and never delivered: BaseDbContext writes its entry and publishes it nowhere, neither inline nor to the outbox. A baseline implements no module marker such as IPpmEvent, because a projection handling the marker would receive it. Nothing is lost by this, since nothing happened to the record for a consumer to react to. Tests 1 and 4 still apply: people and other aggregates are carried by id, as they are in the creation event it mirrors.

Its id is derived, so it can only be written once. EventId is a name-based UUID (version 5) of the aggregate type and id, from BaselineEventId.For. Writing a record's baseline a second time produces the same id and collides with the first row in ActivityLogs rather than adding a duplicate. This is the same property the status backfill gets by sharing the history row's id. The aggregate type is part of the name because replicas reuse their source record's id. The id does not depend on the type's generation, so a record keeps one baseline across generations.

It is recorded by the system, at the moment it is written. The actor is always EventActor.System: the platform started tracking, and no person changed the record. The timestamp is when the baseline was written, because that is the moment the payload describes. The Activity section badges the baseline Baseline, not Created, and explains the entry when it is selected. Should a record ever have entries older than its baseline, the section marks them as possibly incomplete.

It is versioned with its creation event. There is one baseline type per aggregate. When the creation event takes a minor version, the baseline takes the same one. When the creation event gets a new generation, the baseline gets one too, and the old baseline is frozen and marked [Obsolete] like any superseded type.

A baseline records the state before tracking, so it is only as trustworthy as the tracking after it. An aggregate that can still change without raising an event makes a baseline look like the start of a complete history when it is not.

Backfilling baselines​

The Backfill-Baseline-Activity migration gave every record of the nine evented aggregates that had no creation entry a baseline: projects, programs, portfolios, strategic initiatives, strategic themes, iterations, teams (teams of teams included), status workflows and products. Soft-deleted teams were skipped. A record that already had a creation entry was left untouched.

For a record it baselined, the migration first removed every entry the record already had. Those entries were partial history ahead of an unknown starting state and could never be replayed, so each record's history now begins at its baseline. The status transitions it removed are still in ProjectStatusHistory for projects and in StatusWorkflows.StatusTransitions for workflow-driven records such as products.

It writes each payload by hand in T-SQL, frozen at the shape its baseline type had when it shipped, with the derived id from BaselineEventId.For. It selects only records with neither a creation nor a baseline entry, so a second run writes nothing.

Baselining an aggregate that gains events later is a new migration of the same shape. Add its {Aggregate}BaselinedEvent, select the records with neither its creation nor its baseline entry, clear their entries, write the baselines, and give the migration a test that compares each row with the factory's entry (SecurityConfigurationBaselineBackfillTests shows the comparison). Do this only once the aggregate raises an event for every change. Backfill-Planning-Baseline-Activity is the first: it baselined planning intervals, planning interval objectives and risks, skipping soft-deleted records and an interval's soft-deleted iterations along with the sprint mappings that still pointed at them. Backfill-Scoring-Model-Baseline-Activity baselined scoring models; it builds each scale's levels into a temporary table first, because SQL Server cannot aggregate an expression that contains another aggregate. Backfill-Security-Configuration-Baseline-Activity baselined personal access tokens, OIDC providers and connections, skipping revoked tokens and soft-deleted connections, and filed each token's baseline under its owner as well. A baseline may implement IRelatedAggregateEvent when its creation event does, so it is listed wherever the creation event would be; it still implements no module marker. A connection's settings are read out of its configuration JSON, where the default serializer stored enums as numbers, so the migration maps them to the names the events carry; SecurityConfigurationBaselineBackfillTests compares each row with the factory's entry by running the migration's SQL against the current schema. Backfill-Employee-And-Identity-Mapping-Baseline-Activity baselined employees and external identity mappings, skipping soft-deleted employees; both payloads are ids and states only, as their events are. Backfill-Roadmap-Baseline-Activity baselined roadmaps with their managers, colors and items. Colors are read out of the owned JSON column, under EF's property names. A milestone stores its date in Start and has no End, so its range is that single day, as the events carry it. Managers are ordered by id and items by type, order, start and id; RoadmapBaselineBackfillTests orders the expected arrays the same way, comparing ids as SQL Server sorts them (SqlGuid), because array order is part of the payload.

A seeder that creates an evented aggregate records its creation too. The backfill runs once, before any seeder, so a record a seeder creates on a fresh install is never baselined. Seeders run during InitializeDatabases, before Wolverine can route, so they cannot let SaveChanges drain and publish the events. Instead, a seeder takes the aggregate's deferred creation events off it, saves, raises them once the key exists, and writes them straight into the activity log through ActivityLogEntryFactory, all in one transaction. ProductManagementWorkflowSeeder is the pattern.

Activity log​

Every domain event drained by SaveChanges is also captured as an ActivityLogEntry row — automatically, in BaseDbContext via ActivityLogEntryFactory, with no per-aggregate wiring. An event implementing IAggregateEvent declares the aggregate type and id the entry belongs to; anything else falls back to the raising entity's type name and id. That is what the record pages' Activity section reads.

Capture happens whether the event is routed durably or inline, and EventPublisher treats a subscriber-less event as a no-op. A new event therefore earns a full audit trail without a handler and without a DurableEventRoutes entry — add it to that allow-list only when a real consumer needs it delivered asynchronously.

Categorising an entry​

Each entry records an ActivityCategory, which the Activity section turns into the entry's badge. The event type declares it as a static member of IDomainEventDescriptor, so it is never part of a payload, and DomainEvent<TSelf> requires that interface, so an event without one does not compile. The factory copies it onto the ActivityLogEntry; the timeline reads it and knows nothing about event names.

Pick by what happened, not by the words in the name:

CategoryBadgeThe event recordsExamples
CreatedCreatedThe record coming into existenceProjectCreated, ProductAdded, ReleasePlanned, DeploymentStarted
UpdatedUpdatedA change to part of the record that does not move it along its lifecycle*DetailsUpdated, *RolesChanged, ProjectReparented, ProductTagsChanged
ScheduleChangedSchedule ChangeIts dates movingProjectTimelineChanged, ReleaseTargetDateMoved, IterationDateRangeChanged
StatusChangedStatus ChangeA step along its lifecycleProjectStatusChanged, VersionCut, ReleaseWithdrawn, DeploymentFailed
StateChangedState ChangeBeing put into or taken out of useTeamDeactivated, WorkflowPublished, StrategicThemeArchived, EnvironmentRetired
HealthHealthA health signal recorded, changed or withdrawnProjectHealthCheckAdded, ProjectHealthCheckRemoved
RemovedRemovedThe record ceasing to existProjectDeleted, ProductRemoved
BaselineBaselineTracking beginning for a record that already existedProjectBaselined — declared by BaselineEvent, never chosen

Status and state are the two that need care. A status is a position in the record's lifecycle — proposed, active, completed; planned, cut, released — and moving it is progress, even when the move is a withdrawal or a failure. A state is whether the record is in use at all, independent of that position: an archived workflow and a retired environment keep their history and simply stop being offered.

The category belongs to the type, so it is part of the type's meaning rather than its published shape. It can be corrected without a new generation, but the rows already written keep the category they were recorded with.

Order lives on the ledger, not on the event​

The events of one command land microseconds apart where each domain call reads IDateTimeProvider.Now separately, and share a Timestamp exactly where the caller reads it once for a whole batch — the import definitions and the sync handlers. Either way the timestamp does not order them, and at the granularity the Activity section displays they are simply simultaneous. Each ActivityLogEntry therefore also carries an Ordinal, counted as the events are drained, and reads sort by Timestamp, then Ordinal, then Id. All three are needed: Ordinal puts one command's entries in the order they were raised, and Id settles two commands that collide on an instant — arbitrarily, but identically on every read, which is what stops Skip/Take returning an entry on two pages or on neither.

The ordinal is counted in the drain, not generated by the database. EF sorts the inserts of a save by primary key, and ActivityLogs is keyed on a Guid, so the order rows reach the table is unrelated to the order the events were raised in; an identity column records that shuffle faithfully and is useless.

The event envelope carries no ordering, and must not grow one. Durable delivery is at-least-once through the outbox with no guarantee of order between messages, so a sequence number on the published shape would promise something the transport does not keep — which is also why payloads carry the set after a change rather than a delta. And the only place that knows the true order is AddDomainEvent, where numbering across aggregates needs an ambient counter, in a layer that resolves nothing for itself. Order is a property of recording a fact, not of the fact.

Within one aggregate that order is faithful, because its events are drained from an ordered list in the order they were raised. Between aggregates in one command there is no defined order — ChangeTracker is in EF's own tracking order. That shows only where a record's Activity section lists entries raised on other records: two such entries from one save that tie on Timestamp appear in tracking order rather than the order they were raised.

Reading the activity log​

Reading is an IActivityLogReader service, not a standalone query. Each module writes its own thin query handler that resolves and authorizes its record first, then reads the history inside that record's security boundary:

var projectId = await _ppmDbContext.Projects
.Where(request.IdOrKeyFilter)
.Select(p => (Guid?)p.Id)
.FirstOrDefaultAsync(cancellationToken);

if (projectId is null)
{
return Result.Success<PagedResponse<ActivityLogDto>?>(null);
}

var activities = await _activityLogReader.Read(
projectId.Value, aggregateType: "Project", page: request.Page, pageSize: request.PageSize,
cancellationToken: cancellationToken);

A generic /api/activities?aggregateId= endpoint would have to re-derive which permission guards which aggregate type; this way the module that already knows answers it, and the controller carries the same [MustHavePermission] as the record's own GET.

The aggregate type is the event's, not the entity's. Two cases differ today and both are load-bearing: a TeamOfTeams raises the same Team* events, so its entries carry "Team" and only the id separates them; and StatusWorkflow's events name the aggregate "Workflow". Reading with the entity's own class name would silently return nothing. Adding an Activity section to a new record means checking what its events actually declare — grep 'AggregateType =>' lists every value in use.

An event about more than one record​

Some facts concern a record besides the one they are raised on. A product moving from one parent to another changes the product, but both parents gained or lost a child, and each parent's Activity section should say so. An event says this by implementing IRelatedAggregateEvent and listing those records in RelatedAggregates. The factory stores them in App.ActivityLogRelatedAggregates beside the entry, and IActivityLogReader returns an entry for any record it was raised on or names.

[JsonIgnore]
public IReadOnlyCollection<AggregateReference> RelatedAggregates =>
[.. new[] { FromParentId, ToParentId }.OfType<Guid>().Select(parentId => new AggregateReference(AggregateType, parentId))];

One event filed under several records, never a second event for the other side. Nothing happened to the parent that its own event could describe, and a mirror event would reach every consumer as a second occurrence. The different point of view belongs to the reader, not to the event.

Compute the list from ids the payload already carries, and mark it [JsonIgnore]. Kept out of the payload, adopting it changes no published shape, so it needs no version bump. Derived from the payload, entries written before the event adopted it can be backfilled from what they recorded — Backfill-Product-Reparent-Related-Activity is the pattern, and a backfill is part of adopting it. An AggregateReference uses the same type name the related record's own events declare, or its section's typed read will not find the entry. The event's own aggregate and repeats are dropped.

Relate only records read under the same permission. A related entry is shown with its full payload on the other record's Activity section, which is authorized by that record's permission. Relating a record guarded by a different permission would show one to readers who were only allowed the other.

On the read side, ActivityLogDto.IsRelated marks an entry raised on another record, and AggregateType and AggregateId are then that record. The log holds ids only, so the module's query handler names it: it sets RaisedOn for the records it can resolve, as GetProductActivitiesQueryHandler does, and the page passes raisedOnHref to ActivityLogTimeline to link it. An entry whose record cannot be resolved, because it has since been removed, is still listed and described by its type. The timeline compares an entry only with earlier entries raised on the same record, since two related records' events share a type but not a subject.