Ticket messaging has moved beyond common metadata. Aggregate events implement IIntegrationEvent, a generated registry supplies topics and field mappings, and the transaction pipeline writes them to the CAP Outbox. This guide separates that implemented chain from the unresolved id-assignment, broker-binding, and Webhook boundaries.
1. Six lifecycle events
| Behavior | Domain event | Topic | Business fields |
|---|---|---|---|
| Open | TicketOpened | ticket.opened | TicketId, TenantId, RequesterId, Subject, PriorityName, OccurredAt |
| Assign | TicketAssigned | ticket.assigned | TicketId, TenantId, AssigneeId, AssignedBy, OccurredAt |
| Start | TicketStarted | ticket.started | TicketId, TenantId, StartedBy, OccurredAt |
| Resolve | TicketResolved | ticket.resolved | TicketId, TenantId, ResolvedBy, OccurredAt |
| Close | TicketClosed | ticket.closed | TicketId, TenantId, ClosedBy, OccurredAt |
| Reopen | TicketReopened | ticket.reopened | TicketId, TenantId, ReopenedBy, OccurredAt |
Each record derives from DomainEvent, implements IIntegrationEvent, and carries [IntegrationTopic]. DomainEvent supplies EventId; public event properties supply business fields. Start and Reopen are no longer event gaps.
2. Transactional publication
DomainEventDispatchPipelineBehavior is the inner post-phase of the Transaction pipeline. For a successful Command it:
- reads pending events from
IDomainEventCollector; - requires each event to implement
IIntegrationEvent; - obtains topic and generated business parameters through
IIntegrationTopicRegistry.TryBuildPublication; - adds reserved
eventIdandeventTypefields; - publishes the parameter dictionary through CAP before commit.
A missing mapping, reserved-field collision, or publication exception propagates. The Transaction pipeline rolls back and retains aggregate events for explicit retry; only a successful commit clears them. This is not post-commit best effort.
{ "eventId": "019c48cb7ba47000953d5af574ddcb81", "eventType": "TicketOpened", "ticketId": "851987654321", "tenantId": "tenant-a", "requesterId": "user-100", "subject": "Cannot sign in", "priorityName": "High", "occurredAt": "2026-08-14T08:00:00.0000000+00:00"}Architecture tests fix generated field mappings for TicketOpened and TicketStarted. The CAP publisher calls PublishAsync(topic, (object)parameters), so the wire body is serialized from a dictionary; the producer does not instantiate TicketOpenedIntegrationEvent directly.
3. Opened-id defect
Ticket.Open constructs the aggregate with Id="0" and immediately executes:
// ticket.Id is still the persistence placeholder "0" at this point.ticket.Raise(new TicketOpened( ticket.Id, ticket.TenantId, ticket.RequesterId, ticket.Subject, ticket.Priority.Name, // The immutable event will not change when persistence assigns the final id. nowUtc));Persistence later assigns the final snowflake id, but the immutable event is not rewritten. The framework already provides RaiseWhenPersistenceIdAssigned; Ticket does not use it. An opened message can therefore carry ticketId: "0", while assigned/started/etc. use the final id, splitting one ticket across Reporting keys.
Repair should reuse delayed Raise or allocate the business id before Ticket.Open. Acceptance must capture the real Outbox message and assert its TicketId equals the API response.
4. Reporting typed consumer
Reporting subscribes to all six topics through Ticket*IntegrationEvent parameters:
- opened creates or replaces the base Mart row;
- assigned sets AssigneeId and Assigned state;
- started, resolved, closed, and reopened update state;
- reopened clears
ClosedAt; - an EventId equal to current
LastEventIdis skipped; - non-opened events older than
LastUpdatedAtare skipped; - store Failure, a missing predecessor, and retryable exceptions are thrown for CAP retry.
The integration test directly constructs all six consumer DTOs and checks the full sequence, a duplicate reopened event, and a stale closed event. It proves the consumer state machine, but does not prove that CAP binds the producer dictionary to those DTOs with the actual serializer. Field names are currently compatible; that is a source inference, not byte-level contract evidence.
LastEventId only detects a direct duplicate of the current last event. Two distinct events with equal OccurredAt can overwrite in arrival order, and opened has no stale-time guard. Monotonic projection still needs Ticket Version/sequence or a deterministic equal-time tie-breaker.
5. Webhook is not bridged
Tickets publishes ticket.opened. WebhookPlatformEventConsumer subscribes to tickets.opened and expects PlatformWebhookEventEnvelope(EventId, TenantId, PayloadJson, OccurredAt). No visible source adapter converts the topic and envelope.
A repair must define three layers: internal Ticket topic, platform Webhook envelope, and external event name. Matching the strings is insufficient; payload conversion must preserve TenantId/EventId/OccurredAt and be tested through the real serializer.
6. Notification failure semantics
Assign, state changes, and AddComment call NotificationService.CreateAsync after the business mutation. Helpers now inspect every Result:
- the assignment subject can be a direct user or group; a group expands to active current-tenant members;
- state/comment notifications merge requester and assignee members and exclude the actor;
- a HashSet deduplicates recipients;
- any notification Failure becomes the Ticket Command failure;
- the outer Transaction pipeline rolls back Ticket changes, notification facts, and uncommitted Outbox work for a failed Result;
- titles and bodies remain hard-coded Chinese strings rather than template/localization catalog entries.
This guarantees transactional notification intent, not delivered email/SMS/realtime messages. Channel delivery remains a Notifications outbox/consumer/attempt concern.
Batch assignment currently saves and emits search projections without reusing the single-ticket participant notification, domain-event collection, or Ticket audit flow. Batch and single-item behavior are not equivalent and need an explicit product contract.
7. Production audit adapter
Application has NullTicketAuditSink for test/development compositions that omit audit infrastructure. API production persistence registration executes:
services.RemoveAll<ITicketAuditSink>();services.AddSingleton<ITicketAuditSink, PersistentTicketAuditSink>();PersistentTicketAuditSink converts TicketAuditRecord to EntityChangeRecord and writes IEntityChangeAuditSink. The production readiness guard lists NullTicketAuditSink as forbidden, so resolving Null fails readiness.
Records include TicketId, TenantId, Actor, Action, Before, After, and OccurredAt. The adapter fixes CorrelationId to null, and some commands record only status/id values; detail updates can omit field-level before/after values. Durable storage is therefore not the same as complete forensic context.
The port returns Task rather than Result. Exceptions propagate and roll back the command transaction. The effective production policy is fail-closed, not silent degradation.
8. Search projection is a separate chain
Open, detail update, assignment, state changes, and other writes publish SearchIndexChangedIntegrationEvent for the Search owner. It is distinct from the six Reporting topics: search events describe index upsert/remove work; Reporting events describe lifecycle facts.
Operate Search projection lag and Reporting Mart lag separately. A healthy one does not prove the other, and search rebuild cannot replace audit or Reporting replay.
9. Transaction outcome table
| Failure point | Current outcome | Business data |
|---|---|---|
| Repository returns Failure | Command Failure | rolled back |
| Notification creation returns Failure | Command Failure | rolled back |
| Audit throws | exception | rolled back |
| Generated topic mapping is absent | configuration exception | rolled back |
| CAP Outbox publish throws | exception | rolled back |
| UnitOfWork commit fails | exception/unknown commit outcome | framework handles it; events remain uncleared |
| Downstream Reporting consumption fails | CAP retry | Ticket transaction is already committed |
10. Required contract tests
- topics and public field mappings for all six domain events;
- final opened TicketId, never
"0"; - actual CAP serializer output binds to all six Reporting DTOs;
- store Failure/exception retries and duplicate EventId does not rewrite;
- deterministic equal-time and out-of-order behavior;
- explicit Webhook topic/envelope mapping;
- notification Failure and audit exception both roll back Ticket;
- production Profile cannot resolve
NullTicketAuditSink; - expected event/notification/audit differences between batch and single commands.
11. Observability
Record outbox enqueue/publish, consumer attempt, Reporting/Search projection lag, notification intent/attempt, and audit append. Use module/topic/result/error_code tags and controlled hashes for tenant/user. Never log subject, description, comment text, notification body, or StorageKey.
Alert on oldest outbox age, typed binding/deserialization failure, Reporting/Search lag, Null audit in production, notification-intent failures, and opened ticketId="0".
12. Review commands
# Six events, generated business payload, and transactional dispatch.rg -n "IntegrationTopic|TryBuildPublication|BuildParameters|RaiseWhenPersistenceIdAssigned" \ src/Platform/Tickets src/Framework tests -g '*.cs'
# Reporting and Webhook topic/envelope contracts.rg -n "CapSubscribe|Ticket.*IntegrationEvent|tickets\.opened|PlatformWebhookEventEnvelope" \ src/Platform/Reporting src/Platform/Webhooks -g '*.cs'
# Development null, production replacement, and readiness deny-list.rg -n "ITicketAuditSink|NullTicketAuditSink|PersistentTicketAuditSink" src/Platform/Tickets src/Hosts tests -g '*.cs'Back to Tickets · Reporting · Notifications · Testing and GA