BitzOrcas uses separate abstractions for three intentions: what happened inside an aggregate, what a module promises to publish, and what the notification system should deliver. Calling all three simply “events” hides important transaction and compatibility differences.
Contract types
| Contract | Primary boundary | Purpose |
|---|---|---|
IDomainEvent / DomainEvent | Domain | Aggregate state-change fact; never a direct cross-module contract |
IIntegrationEvent and owner-local Contracts types | Module contract | Stable cross-module or cross-process message |
INotificationPublisher / NotificationMessage | Application port | Publish a notification code and parameters without depending on CAP |
Integration events live in the Contracts project that owns the fact, such as Identity, Tickets, or Authorization. They are not collected into a global SaaS.Contracts package. Consumers depend only on the publisher’s contract boundary.
Lifecycle and transaction boundary
This diagram describes the automatic domain-event bridge, not explicit integration-event publication inside the transaction. Both paths can coexist, but choose according to the business need for an atomic Outbox instead of hiding the distinction behind “event published.”
Domain event lifecycle
An aggregate stores an event through Entity<TId>.Raise(IDomainEvent). The handler must pass the relevant entity to scoped IDomainEventCollector.Track(entity). The collector uses strongly typed closures to read and clear events, with no runtime reflection scan.
note.Raise(new NoteCreatedDomainEvent(note.Id, note.Title));domainEvents.Track(note);A complete aggregate behavior raises a fact only after state changes successfully:
public Result Rename(NoteTitle title, IAppClock clock){ // Protect the invariant first. Failure changes neither state nor events. if (title == Title) return Result.Failure(NoteErrors.TitleUnchanged);
Title = title; ModifyTime = clock.UtcNow; // Carry stable facts instead of exposing the aggregate or ORM state. Raise(new NoteRenamedDomainEvent(Id, TenantId, title.Value)); return Result.Success();}The Handler saves and tracks the same aggregate instance. Raising without tracking leaves the collector unaware; calling event-producing public behavior during restore can incorrectly replay a historical fact.
The current DomainEventDispatchPipelineBehavior applies only to successful Commands. After the transaction pipeline returns, it collects events, resolves compile-time topic registrations through IIntegrationTopicRegistry, and calls INotificationPublisher.
A domain event without a topic mapping is skipped, allowing internal events to remain internal. Cross-module propagation should use an explicit stable integration contract rather than rely on matching CLR type names.
Typed integration-event port
IIntegrationEventPublisher<TEvent> lets Application publish a concrete contract without referencing ICapPublisher. CapIntegrationEventPublisher<TEvent> prefers a topic declared by [IntegrationTopic] and otherwise falls back to the type’s full name.
An explicit topic is safer for a long-lived contract because namespace refactoring cannot change it silently. Events should carry a stable event ID, occurrence time, tenant identity, and only the business data consumers need. Never serialize a persistence entity as the public message.
[IntegrationTopic(Topic)]public sealed record TicketOpenedIntegrationEvent( // EventId, TenantId, and occurrence time support idempotency, isolation, and diagnosis. Guid EventId, string TenantId, string TicketId, string Subject, DateTimeOffset OccurredAt){ // Topic is an operational contract and does not follow CLR namespace refactoring. public const string Topic = "tickets.ticket.opened.v1";}The public payload contains only stable scalars needed by consumers. Adding an optional field is usually compatible; deleting a field, changing meaning, or reusing an enum ordinal is breaking and requires a new version with a parallel migration window.
Notification port
INotificationPublisher accepts NotificationMessage(Code, Parameters). CapNotificationPublisher publishes the Code as the CAP topic with a parameter dictionary. This suits template-driven notification requests; it is not a substitute for a strongly typed business integration event.
Local or minimal compositions may register NullNotificationPublisher and NullIntegrationEventPublisher<T>. Null adapters support optional capability and test isolation. If a commercial flow depends on delivery, production readiness must reject a Null composition instead of silently accepting message loss.
Selection guide
- Describe an aggregate-internal fact: domain event.
- Require reliable consumption by another module: owner-local typed integration event.
- Request template or channel notification: notification port.
- Require atomic commit with a business write: publish inside the CAP-aware transaction, not the post-commit best-effort bridge.
Failure-semantics comparison
| Path | Business state after failure | Caller action |
|---|---|---|
| Domain behavior returns failure | Neither state nor event is produced | Return a typed Result |
| In-transaction integration publish fails | Business write and Outbox roll back | Preserve the exception for transaction/retry policy |
| Post-commit automatic bridge fails | Business state is already committed | Alert, audit, and compensate by explicit design |
| Null notification port | Depends on whether capability is optional | Degrade explicitly or fail startup for required delivery |
| Consumer fails | Publisher state does not roll back | Throw so CAP retries and records failed delivery |
Test checklist
- Aggregate failure changes no state and collects no domain event.
- A successful behavior raises once; restore raises nothing.
- The Handler tracks the correct aggregate and successful collection clears it.
- An internal event with no topic does not cross a module boundary.
- Explicit topic, module-governance declaration, and runtime subscription agree.
- In-transaction publication failure rolls back business and Outbox rows.
- A failed post-commit bridge does not pretend to roll back business state and emits an observable alert.
- Null adapters appear only in explicitly allowed editions and environments.
Source entry points
Domain-event bases and collection methods live under BitzOrcas.Domain/Entities; the collector, topic registry, and pipeline live in BitzOrcas.Application; the CAP publisher adapter lives under BitzOrcas.Infrastructure.Outbox.Cap/Events (standalone package); the IIntegrationEvent contract itself lives in BitzOrcas.Modularity.Governance. A contract change also requires publisher contract tests, consumer contract tests, and the commercial GA gate.
See Eventing building block for transport, Outbox, and consumer semantics.