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
DetailsUpdatedevent (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.
- 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.EventActorandRoleAssignmentChangeare shaped this way. A record that is a person keeps the same rule on its own events:EmployeeandExternalIdentityMappingcarry 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'sExternalIdis the person's address until a sync re-keys it, so no mapping event carries it. - 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
Nameoff events that did not change it; see A payload carries the change and the identity. - Is it the whole change? Carry both ends, the value before as well as after. A collection carries
AddedandRemovedand 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. - 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:
ProjectStatusChangedEventV2carries a status as a name plus aLifecycleCategoryrather than an enum value, so workflow-configurable statuses will not break it. - Make sure
defaultcan only mean "not recorded", because a payload written before a field existed reads it asdefault. Where a real value can be null, group the fields in a nullable record, as the*DetailsUpdatedEvents do withPrevious. - 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?
| Field | Lookup by id at read time | Belongs in the payload? |
|---|---|---|
The record's Name on a role change | Returns the name, and the name is not part of the change | No — carry the id |
Name on a *CreatedEvent or *DetailsUpdatedEvent | Returns today's name, not the one created or renamed to | Yes — it is the change |
Name on StrategicInitiativeDeletedEvent | Finds nothing; the record is gone | Yes |
ScoringModelName on a recorded score | Returns the model's current name, not the one scored against | Yes — frozen |
| Previous and new lifecycle names on a lifecycle change | Returns current names, not the ones that applied | Yes — frozen |
| An employee's name on a role change | Returns the current name | No — test 1 wins; carry the id |
| An employee's old and new name on their own rename | Returns the current name | No — 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 asealed recordderiving fromDomainEvent<TSelf>with itself asTSelf, and is named{Aggregate}{WhatHappened}Eventin the past tense. Convention test. - It implements
IDomainEventDescriptorand 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) orIAggregateEvent, withAggregateTypeandAggregateIdmarked[JsonIgnore].AggregateTypeis 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 calledeventId. 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
Keyis database-assigned is raised inAddPostPersistenceAction, 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 carriesKey. - A previous value read from a navigation has an
.Includein the handler and a guard in the aggregate that throws when it was not loaded. Testcontainers test withSavedEntityRecorder— 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
SaveChangesdrain depends onIEventPublisher.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 resolvesRaisedOn. See An event about more than one record. Testcontainers test for the backfill. - Add the type to
DurableEventRoutesonly 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_StillDeserializesis the pattern.
Baselining an aggregate
- The type is
{Aggregate}BaselinedEventin the creation event's folder, asealed recordderiving fromBaselineEvent<TSelf, TCreated>with the aggregate's creation event asTCreated. 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
recordCreatedOnandrecordCreatedByIdpassed 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
AddDomainEventon any path. The aggregates it checks are listed inEventedAggregates, 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 madeinternal. - 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) andChildEntities(with the aggregate it belongs to), so a new table cannot arrive without one. - Nothing writes an evented aggregate's table in bulk.
ExecuteUpdate,ExecuteDeleteand rawUPDATE/DELETE/INSERT/MERGEnever 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:
| Change | Result |
|---|---|
Add a field whose default is a valid value | Same type, minor bump: 1.0 → 1.1 |
Add a field whose default is not a valid value | Not compatible — see the next row |
| Remove, retype, or repurpose a field | New 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:
| Category | Badge | The event records | Examples |
|---|---|---|---|
Created | Created | The record coming into existence | ProjectCreated, ProductAdded, ReleasePlanned, DeploymentStarted |
Updated | Updated | A change to part of the record that does not move it along its lifecycle | *DetailsUpdated, *RolesChanged, ProjectReparented, ProductTagsChanged |
ScheduleChanged | Schedule Change | Its dates moving | ProjectTimelineChanged, ReleaseTargetDateMoved, IterationDateRangeChanged |
StatusChanged | Status Change | A step along its lifecycle | ProjectStatusChanged, VersionCut, ReleaseWithdrawn, DeploymentFailed |
StateChanged | State Change | Being put into or taken out of use | TeamDeactivated, WorkflowPublished, StrategicThemeArchived, EnvironmentRetired |
Health | Health | A health signal recorded, changed or withdrawn | ProjectHealthCheckAdded, ProjectHealthCheckRemoved |
Removed | Removed | The record ceasing to exist | ProjectDeleted, ProductRemoved |
Baseline | Baseline | Tracking beginning for a record that already existed | ProjectBaselined — 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.