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);