BitzOrcas uses DotNetCore.CAP to connect a SQL Server Outbox to RabbitMQ. This solves the dual-write decision between committing a database transaction and publishing a message. It does not turn distributed messaging into exactly-once delivery. Producers get a transactional Outbox; consumers still design for at-least-once delivery.
Critical path diagram
Follow the main path to identify responsibility handoffs, then use the prose to inspect failure branches and evidence.
Producer path
CapSqlSugarUnitOfWork opens a CAP-aware transaction on the shared DbConnection and injects the same DbTransaction into SqlSugar. Business writes and the CAP Outbox INSERT commit or roll back together.
Command ↓CAP-aware SQL transaction ├─ business rows └─ CAP Outbox row ↓ commitCAP dispatcher → RabbitMQ → consumerThe atomic dual-write guarantee applies only when CAP publish occurs while this transaction is open. The post-commit domain-event bridge is best effort; see Eventing Abstractions for that boundary.
Topics and contracts
The publisher owns the topic and payload contract. A typed event can pin its topic with [IntegrationTopic("tickets.ticket.opened")]. Without an explicit topic, the CAP adapter falls back to the type’s full name. Long-lived public topics should be explicit and have a version policy.
Contract evolution rules:
- Prefer additive optional fields; do not remove fields or change existing meaning.
- Do not publish database entities or internal enum ordinals.
- Event ID, tenant ID, and occurrence time support diagnosis and idempotency.
- Use a new topic or version for breaking change and allow old and new consumers to migrate in parallel.
Consumer modules expose subscribed-topic constants from Contracts or Application for module governance reports. Runtime consumers implement ICapSubscribe with [CapSubscribe] in Infrastructure. The reported catalog and runtime attributes must remain aligned.
Consumer rules
After validating contract and tenant identity, a consumer invokes an owner-local use case. Common idempotency patterns include:
- A unique processed record keyed by
eventId + consumer. - A conditional state transition on the target aggregate.
- An upsert using a business unique key for projections.
Commit the business effect and idempotency marker in the same transaction where possible. Do not mark a message handled before the side effect, and do not use an in-process HashSet as durable deduplication.
A consumer should throw on failure so CAP records and retries it. The current CAP configuration attempts failed delivery five times. Global CapConsumerAuditFilter writes execution audit, while the operations module can query and perform controlled retries of failed CAP messages.
Transactional publish use case
This use case keeps state persistence and event publication inside one Handler surrounded by the transaction behavior. TicketOpenedIntegrationEvent is a stable contract owned by Tickets Contracts.
public sealed class OpenTicketHandler( ITicketStore tickets, IIntegrationEventPublisher<TicketOpenedIntegrationEvent> events){ public async Task<Result> Handle(OpenTicket command, CancellationToken ct) { // The aggregate protects its transition. Expected failure returns and rolls back. var ticket = await tickets.GetAsync(command.TicketId, ct); var opened = ticket.Open(command.Subject); if (opened.IsFailure) return opened;
// Save and CAP Publish run while the CAP-aware transaction is still open. await tickets.SaveAsync(ticket, ct); await events.PublishAsync( TicketOpenedIntegrationEvent.From(ticket), ct);
return Result.Success(); }}The Handler does not call CommitAsync(). TransactionPipelineBehavior commits or rolls back from the Result and exception outcome. Moving PublishAsync into an after-commit callback would remove the atomic Outbox property used by this example.
Idempotent consumer use case
public sealed class TicketOpenedConsumer( IProcessedEventStore processed, IReportingTicketProjection projection){ [CapSubscribe(TicketOpenedIntegrationEvent.Topic)] public async Task Consume(TicketOpenedIntegrationEvent message, CancellationToken ct) { // eventId + consumerName needs a database unique constraint; process cache is insufficient. if (await processed.ExistsAsync(message.EventId, nameof(TicketOpenedConsumer), ct)) return;
// Commit the projection change and processed marker in one local transaction. await projection.UpsertAsync(message.TenantId, message.TicketId, message.Subject, ct); await processed.AddAsync(message.EventId, nameof(TicketOpenedConsumer), ct); }}The unique constraint is the final defense when the same event arrives concurrently. If AddAsync conflicts, treat it as idempotent success only after proving the competing execution committed the projection; otherwise fail and let CAP retry.
Failure boundaries
| Failure | Expected behavior |
|---|---|
| Business transaction rolls back | Business and Outbox rows both roll back |
| RabbitMQ is temporarily unavailable | Outbox remains and dispatch resumes later |
| Consumer throws | CAP retries and records failure |
| Consumer succeeds but acknowledgement is lost | Duplicate delivery is possible; business idempotency handles it |
| Post-commit best-effort publish fails | Business already succeeded; alert and explicit compensation are required |
Release gate
- SQL Server, CAP tables, and RabbitMQ configuration pass health and readiness checks.
- Topic catalog and runtime subscriptions agree; no orphan or misspelled topics remain.
- Every consumer has duplicate, out-of-order, old-version, and poison-message tests.
- Failed-message backlog, retry rate, consumer latency, and oldest-message age are monitored.
- A critical flow can be traced from event ID and CorrelationId through producer and consumer audit.
Source and verification entry points
- CAP-aware units of work:
CapSqlSugarUnitOfWorkandCapEfCoreUnitOfWork. - Typed publishing adapter:
CapIntegrationEventPublisher<TEvent>. - Domain-event bridge:
DomainEventDispatchPipelineBehaviorandIIntegrationTopicRegistry. - Consumer audit:
CapConsumerAuditFilter; failed-message operations live in OpsExtension. - Integration evidence covers business/Outbox commit and rollback, dispatch after broker recovery, duplicate consumption, and controlled retry.
See Eventing Abstractions for contract and port selection.