Skip to main content
Version: v1.2.8

Sample: OrderService — Split Outbox + MassTransit RabbitMQ

Location: samples/outbox-relay/
Framework: ASP.NET Core 9 Minimal API · .NET 9 Worker Service
Transport: MassTransit over RabbitMQ — Hermodr.Publisher.MassTransit
Pattern: Transactional Outbox (external relay process)


Overview​

This sample demonstrates the split Transactional Outbox pattern using two separate processes that share a SQLite database:

ProcessProjectRole
Minimal APIOrderService.ApiAccepts HTTP requests; writes CloudEvents to the outbox only
Console workerOrderService.RelayWorkerPolls the shared outbox; forwards events to RabbitMQ via MassTransit

The API has no knowledge of RabbitMQ or MassTransit — it only writes to the outbox. This clean separation means the transport layer can be swapped (e.g. Azure Service Bus, Amazon SQS) by changing the worker, without touching the API.

Architecture​

HTTP request
│
▼
OrderService.Api
│ IEventPublisher.PublishAsync(event)
▼
OutboxPublishChannel ──── INSERT into shared SQLite (outbox.db)

┌── separate process ──────────────────────────────┐
│ OrderService.RelayWorker │
│ │
│ OutboxRelayService (BackgroundService) │
│ polls outbox.db every 5 s │
│ │ │
│ ▼ │
│ MassTransitPublishChannel │
│ │ │
└──────│───────────────────────────────────────────┘
│
▼
RabbitMQ broker

What this sample demonstrates​

1. API — outbox write path only​

Program.cs in OrderService.Api calls .AddEntityFrameworkOutbox() with .WithFactory<>() but without .WithRelay():

var events = builder.Services.AddEventPublisher(options =>
{
options.Source = new Uri("https://example.com/services/order-service");
});

events
.AddEntityFrameworkOutbox(opts =>
opts.UseSqlite(builder.Configuration.GetConnectionString("Outbox")))
.WithFactory<OrderOutboxMessageFactory>();
// ↑ No .WithRelay() — forwarding is handled by the external relay worker

The API project references only Hermodr.Publisher.Outbox.EntityFramework — there is no dependency on any transport package.

2. Worker — relay + MassTransit​

Program.cs in OrderService.RelayWorker first configures MassTransit with the RabbitMQ transport, then registers the outbox channel with .WithRelay() and adds the MassTransit publish channels:

// ── MassTransit ──────────────────────────────────────────────────────────
builder.Services.AddMassTransit(mt =>
{
mt.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host(host, h =>
{
h.Username(username);
h.Password(password);
});

cfg.ConfigureEndpoints(ctx);
});
});

// ── Hermodr outbox relay → MassTransit ─────────────────────────────
var events = builder.Services.AddEventPublisher(options =>
{
options.Source = new Uri("https://example.com/services/order-service");
});

// Outbox repository — same database as the API
events
.AddEntityFrameworkOutbox(opts =>
opts.UseSqlite(builder.Configuration.GetConnectionString("Outbox")))
.WithFactory<OrderOutboxMessageFactory>()
.WithRelay(opts =>
{
opts.Interval = TimeSpan.FromSeconds(5);
opts.MaxBatchSize = 50;
});

// MassTransit channels — used only by the relay
events
.AddMassTransit() // generic catch-all channel
.AddMassTransit<OrderCreated>() // typed per-event channels
.AddMassTransit<OrderConfirmed>()
.AddMassTransit<OrderShipped>()
.AddMassTransit<OrderDelivered>()
.AddMassTransit<OrderCancelled>();

MassTransit must be registered before the Hermodr channels so that IPublishEndpoint and ISendEndpointProvider are available in DI when the MassTransitPublishChannel resolves them.

3. Event types — no AMQP coupling in the API​

Event classes in OrderService.Api carry only the [Event] annotation:

[Event("order.created", "1.0", Description = "A new order was placed by a customer")]
public sealed class OrderCreated
{
public Guid OrderId { get; set; }
public string CustomerId { get; set; } = default!;
public IReadOnlyList<OrderCreatedItem> Items { get; set; } = [];
public decimal TotalAmount { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

The worker defines only stub event marker types — topic routing is handled by MassTransit's topology, not by AMQP annotations:

[Event("order.created", "1.0")]
public sealed class OrderCreated { }

4. Shared outbox database​

Both processes point at the same SQLite file:

ProcessConnectionStrings:Outbox
APIData Source=outbox.db (written in the API working directory)
WorkerData Source=../OrderService.Api/outbox.db (relative path)

Production note: replace SQLite with SQL Server or PostgreSQL when running the two processes on different hosts or containers. SQLite file-locking is not safe for concurrent cross-process writes under high load.


Prerequisites​

ToolVersion
.NET SDK9.0 +
Dockerany recent version

Running the sample​

1. Start RabbitMQ

cd samples/outbox-relay
docker compose up -d

The RabbitMQ management UI is available at http://localhost:15672 (guest / guest).

2. Start the API (terminal 1)

cd samples/outbox-relay/OrderService.Api
dotnet run

The API listens on http://localhost:5000. The SQLite database (outbox.db) is created automatically.

3. Start the relay worker (terminal 2)

cd samples/outbox-relay/OrderService.RelayWorker
dotnet run

The worker connects to the same outbox.db via the relative path in appsettings.json and starts polling every 5 seconds.

Tip: override the path with an environment variable if the working directories differ:

ConnectionStrings__Outbox="Data Source=/absolute/path/outbox.db" dotnet run

Exercise the full lifecycle​

# 1. Create an order
ORDER_ID=$(curl -s -X POST http://localhost:5000/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": "cust-42",
"items": [
{ "productId": "sku-001", "productName": "Widget", "quantity": 2, "unitPrice": 9.99 }
]
}' | jq -r '.id')

echo "Created order: $ORDER_ID"

# 2. Confirm
curl -s -X PUT "http://localhost:5000/orders/$ORDER_ID/confirm" | jq

# 3. Ship
curl -s -X PUT "http://localhost:5000/orders/$ORDER_ID/ship" \
-H "Content-Type: application/json" \
-d '{ "trackingNumber": "1Z999AA10123456784", "carrier": "UPS" }' | jq

# 4. Deliver
curl -s -X PUT "http://localhost:5000/orders/$ORDER_ID/deliver" | jq

Within ~5 seconds after each request, the relay worker logs a Sending update followed by a Sent confirmation. The messages appear in the RabbitMQ management UI as MassTransit-formatted messages on the Hermodr exchange topology.


Configuration reference​

API (OrderService.Api/appsettings.json)​

KeyDefaultDescription
ConnectionStrings:OutboxData Source=outbox.dbSQLite outbox database (written by the API)

Worker (OrderService.RelayWorker/appsettings.json)​

KeyDefaultDescription
ConnectionStrings:OutboxData Source=../OrderService.Api/outbox.dbPath to the shared SQLite database
Events:MassTransit:RabbitMq:HostlocalhostRabbitMQ broker hostname
Events:MassTransit:RabbitMq:UsernameguestRabbitMQ username
Events:MassTransit:RabbitMq:PasswordguestRabbitMQ password

Comparison with the in-process topology​

In-process relay (outbox-inapp)External relay (this sample)
Process count1 — API + relay in the same host2 — API and relay run independently
TransportRabbitMQ (direct AMQP channel)MassTransit (broker-agnostic abstraction)
API dependency on transportYes — Hermodr.Publisher.RabbitMqNo — API only refs the outbox package
Relay restartRestarts the entire API processRelay restarts independently
Independent scaling❌ Relay scales with the API✅ Scale relay separately
Swap broker laterRequires API changesChange worker only

Choose the external-relay topology when fault isolation, independent scaling, or transport-agnosticism are priorities.