Table of Contents

Outbox + RabbitMQ Setup

Derived page. The behaviour described here is specified by the outbox-and-messaging capability under openspec/specs/. That specification is the source; this page explains and illustrates it. Where the two disagree, the specification is right and this page is a bug.

Stratara.Outbox.RabbitMQ provides the IMessageBus implementation backed by a RabbitMQ broker. It uses publisher confirms + automatic reconnect + mandatory routing — failed-to-deliver messages are caught + retried from the outbox table.

Add the package

dotnet add package Stratara.Outbox.RabbitMQ

Configure

// appsettings.json
{
  "RabbitMq": {
    "HostName": "localhost",
    "Port": 5672,
    "VirtualHost": "/"
    // Username + Password come from env vars in production:
    //   RABBITMQ_USERNAME, RABBITMQ_PASSWORD
    // In Development only, the broker's default `guest/guest` is used.
  }
}

Fail-fast outside Development (v3.4.0; v3.0.14+ for Production only): if RABBITMQ_USERNAME / RABBITMQ_PASSWORD are missing on any host that is not in Development, publishing throws InvalidOperationException naming the environment. The guest/guest fallback is Development-only — same pattern as the key-store guard.

Changed in 3.4.0. The check used to be IsProduction(), which recognises exactly one name: Staging, QA, UAT, Preview, anything self-named, and even Production-EU and prod all fell through to guest. RabbitMQ restricts guest to localhost by default, so a remote broker refused the connection anyway — but a broker in the same container or network running a default configuration accepted it. If you deliberately want the default account outside Development, set RABBITMQ_USERNAME=guest and RABBITMQ_PASSWORD=guest explicitly; the configuration is the opt-in.

Wire the worker

A typical worker host wires both the outbox-drainer and the command consumer:

var builder = Host.CreateApplicationBuilder(args);

builder.AddOutboxWorkerServices();   // drains outbox_entry → publishes to bus
builder.AddCommandWorkerServices();  // subscribes to bus → dispatches commands

builder.Services.AddCommandHandlersFromAssemblyContaining<MyCommandMarker>();

await builder.Build().RunAsync();

Routing model

Topic and subscription names come from the Messaging configuration section. Every one has a default, so a host that configures nothing still works:

Topic Default name Default subscription(s) Who publishes / consumes
Command command command-subscription Your outbox (and other apps) publish; the command worker consumes
HeavyCommand heavy-command heavy-command-subscription IHeavyCommand commands; the heavy-command worker consumes
EventBundle event-bundle event-bundle-subscription, event-bundle-saga-subscription The write-store publishes; projection and saga workers consume
Notification notifications Consumer-defined notification fan-out

Override any of them by name:

{
  "Messaging": {
    "Topics": [
      {
        "Name": "Command",
        "Value": "myapp.command",
        "Subscriptions": [ { "Name": "CommandSubscription", "Value": "myapp.command.worker" } ]
      }
    ]
  }
}

Topics are fanout exchanges + per-subscription queues. Multiple worker hosts can scale out by sharing a queue — RabbitMQ does the work-stealing.

Backpressure

The OutboxWorker polls the outbox table every OutboxOptions.PollingIntervalSeconds (default 30) and publishes pending rows. If the broker is unreachable, rows sit in the table — at-least-once delivery preserved. The next poll-cycle retries.

A cycle takes one batch of each kind and ends. Rows the broker would not accept stay in the table and are retried on the next interval; a cycle never re-reads what it has just failed to publish. That bounds the work a cycle can do, and it is what stops an unreachable broker — or a suppressed drain during a projection replay — from turning a cycle into a loop over the same rows. The practical consequence: a large accumulated backlog drains at one batch per interval rather than in a single pass. With the defaults that is 20 000 rows a minute, and both knobs below are yours.

OutboxOptions.BatchSize (default 10_000) caps how many rows the worker claims per cycle, and LockLeaseSeconds (default 60) is how long a claimed batch stays leased to one worker. Bind them under the Outbox configuration section.

Connection health

Stratara.Outbox.RabbitMQ uses RabbitMQ.Client's automatic recovery + topology recovery. NetworkRecoveryInterval is set to a small default; consumers re-subscribe automatically after a reconnect.

On startup the bus fails fast in Production if the broker connection can't be established, rather than starting a worker that silently publishes nowhere.

Observability

The outbox plane records the outbox.published counter (on the Stratara.Service meter), tagged by entry kind — command or event — so you can watch command-dispatch and event-bundle throughput separately. It counts what the broker accepted, not what was read from the table: a row that could not be published is not counted, so the counter going flat while the table stays full is the signal that dispatch is stuck rather than busy. The Stratara.ServiceDefaults OpenTelemetry config wires the Stratara.Service meter and the Stratara.Application activity source automatically.

Failure paths (PublishReturnException on no-binding, broker-disconnect, …) emit warning-level log events from Stratara.Shared.Diagnostics.Extensions.LoggerOutboxExtensions — see the LogEvents Schema.