Skip to main content
Version: Next

Outbox Relay Service

The relay is a background service that polls the outbox table for pending messages and forwards them to the actual transport channel.

How the Relay Works

The relay is an IHostedService registered automatically when you call .WithRelay(…). It runs in a continuous loop:

┌─────────────────────────────────────────────────────────────┐
│ Relay Service Loop │
│ │
│ ┌─────────────┐ │
│ │ Sleep │ ◄────────────────────────────┐ │
│ │ (Interval) │ │ │
│ └──────┬──────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ Query Pending │ Get messages where │ │
│ │ Messages │ Status = Pending AND │ │
│ └──────┬──────┘ │ NextRetryAt <= Now │ │
│ │ └──────────────────────────┘ │
│ ▼ ▲ │
│ ┌─────────────────┐ │ │
│ │ For each msg: │ │ │
│ │ - Set Sending │ │ │
│ │ - Publish │ │ │
│ │ - On success: │ │ │
│ │ Delivered │ │ │
│ │ - On error: │ │ │
│ │ Retry/Fail │ │ │
│ └─────────────────┘ │ │
│ │ │
│ Continue until MaxBatchSize reached ──────────┘ │
└─────────────────────────────────────────────────────────────┘

Registration

With Inline Configuration

builder.Services
.AddEventPublisher()
.AddOutbox<OrderOutboxMessage>()
.WithEntityFramework(opts => opts.UseSqlServer(connectionString))
.WithFactory<OrderOutboxMessageFactory>()
.WithRelay(relay =>
{
relay.Interval = TimeSpan.FromSeconds(15);
relay.MaxBatchSize = 100;
});

From Configuration

builder.Services
.AddEventPublisher()
.AddOutbox<OrderOutboxMessage>()
.WithEntityFramework(opts => opts.UseSqlServer(connectionString))
.WithFactory<OrderOutboxMessageFactory>()
.WithRelay("Events:OutboxRelay");
{
"Events": {
"OutboxRelay": {
"Interval": "00:00:15",
"MaxBatchSize": 100
}
}
}

OutboxRelayOptions

PropertyTypeDefaultDescription
IntervalTimeSpan00:00:30How often the relay polls for pending messages
MaxBatchSizeint0Maximum messages per poll cycle (0 = no limit)
TransportPublisherNamestring""Name of downstream publisher pipeline for forwarding

Interval

The Interval controls how frequently the relay wakes up to check for pending messages:

  • Shorter interval (e.g., 5 seconds) → Lower latency, more database queries
  • Longer interval (e.g., 60 seconds) → Higher latency, fewer database queries

Recommendation: Start with 15-30 seconds for most applications. Adjust based on:

  • Acceptable delivery latency
  • Database load tolerance
  • Expected message volume

MaxBatchSize

Controls how many messages the relay processes per poll cycle:

  • 0 or negative → No limit (processes all pending messages)
  • Positive value → Limits batch size to prevent long-running cycles

Recommendation: Set a reasonable limit (50-200) to:

  • Prevent the relay from monopolizing resources
  • Ensure the relay completes within the interval
  • Allow graceful shutdown between batches

TransportPublisherName

Specifies which publisher pipeline the relay uses to forward messages:

  • Empty string (default) → Uses the default publisher pipeline
  • Named pipeline → Targets a specific publisher configuration

This is useful when you want to separate the outbox persistence from the transport configuration.


Deployment Topologies

Same-Process Deployment

The relay runs inside the same application host as the publisher:

builder.Services
.AddEventPublisher()
.AddOutbox<OrderOutboxMessage>()
.WithEntityFramework(opts => opts.UseSqlServer(connectionString))
.WithFactory<OrderOutboxMessageFactory>()
.WithRelay() // Relay runs in this process
.AddRabbitMq(opts => { /* transport config */ });

Pros:

  • Simple setup — one deployment
  • No additional infrastructure
  • Good for development and small-scale applications

Cons:

  • Relay competes for resources with the main application
  • Relay restarts when the application restarts
  • Cannot scale relay independently

When to use:

  • Development and testing
  • Low to medium message volumes
  • Applications where simplicity is paramount

Cross-Process Deployment

The relay runs as a dedicated worker process separate from the main application:

Main Application (Publisher Only)

// Main application Program.cs
builder.Services
.AddEventPublisher()
.AddOutbox<OrderOutboxMessage>()
.WithEntityFramework(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("Shared")));
// No relay, no transport channel registered here

Relay Worker (Separate Process)

// Relay worker Program.cs
builder.Services
.AddEventPublisher()
.AddOutbox<OrderOutboxMessage>()
.WithEntityFramework(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("Shared")))
.WithFactory<OrderOutboxMessageFactory>()
.WithRelay(relay =>
{
relay.Interval = TimeSpan.FromSeconds(10);
relay.MaxBatchSize = 200;
})
.AddRabbitMq(opts =>
{
opts.ConnectionString = "amqp://guest:guest@rabbitmq:5672";
opts.ExchangeName = "events";
});

Pros:

  • Independent scaling of relay workers
  • Isolated failures — relay crashes don't affect the main application
  • Dedicated resources for relay processing
  • Can deploy relay updates without redeploying the main application

Cons:

  • More complex deployment (separate worker process)
  • Requires shared database access
  • Additional infrastructure to manage

When to use:

  • High message volumes
  • Mission-critical applications requiring high availability
  • When relay processing is resource-intensive
  • When you need to scale relay independently

Relay Behavior

Message Claiming

When the relay picks up a pending message, it sets Status = Sending before attempting delivery. This prevents:

  • Duplicate delivery — Concurrent relay instances won't process the same message
  • Race conditions — Clear ownership during the delivery attempt

Retry Logic

On transient failures, the relay:

  1. Increments RetryCount
  2. Sets NextRetryAt based on a back-off strategy
  3. Sets Status = Pending to make the message eligible for future attempts

On permanent failures (retry limit exceeded):

  1. Sets Status = Failed
  2. Records the final ErrorMessage
  3. Requires manual intervention or dead-letter processing

Graceful Shutdown

The relay respects CancellationToken and will:

  1. Stop polling for new messages
  2. Complete processing of in-flight messages (if time permits)
  3. Shut down cleanly without losing message state

Monitoring the Relay

Key Metrics to Track

MetricDescriptionAlert Threshold
PendingMessageCountNumber of messages waiting to be delivered> 1000 for > 5 minutes
FailedMessageCountNumber of messages in Failed statusAny failures
AverageDeliveryLatencyTime from EventTime to Delivered status> 5 minutes
RelayCycleDurationHow long each poll cycle takes> Interval
RetryRatePercentage of messages requiring retry> 10%

Querying Outbox Status

// Using EF Core
var pendingCount = await _db.OutboxMessages
.CountAsync(m => m.Status == OutboxMessageStatus.Pending);

var failedMessages = await _db.OutboxMessages
.Where(m => m.Status == OutboxMessageStatus.Failed)
.OrderByDescending(m => m.LastStatusAt)
.Take(100)
.ToListAsync();

var averageLatency = await _db.OutboxMessages
.Where(m => m.Status == OutboxMessageStatus.Delivered
&& m.EventTime > DateTimeOffset.UtcNow.AddHours(-1))
.AverageAsync(m => (m.LastStatusAt - m.CreatedAt).TotalSeconds);