This directory contains reference implementations for the Relay pattern, which is used in Java ezddd 4.1.0 to publish events from the event store to message brokers while maintaining strict adherence to the Transactional Outbox pattern.
The Transactional Outbox pattern guarantees at-least-once delivery of domain events to message brokers, even when the broker experiences temporary failures.
- Repository: Saves aggregate state + events to database atomically (transaction)
- Event Store/Outbox Table: Persists events durably
- Relay (Background Service): Polls event store and publishes events to message broker
- Automatic Retry: Relay catches exceptions and retries until success
- Eventual Consistency: All events WILL BE published eventually
Without Relay (direct publishing in Repository):
- ❌ No eventual consistency guarantee
- ❌ Message broker failure affects save operation
- ❌ No automatic retry mechanism
- ❌ Violates Outbox Pattern completeness
With Relay (independent background service):
- ✅ Eventual consistency guaranteed
- ✅ Message broker failure isolated from save
- ✅ Automatic retry on failure
- ✅ Complete Outbox Pattern implementation
IEventStore.cs- Interface for event stores supporting relay pollingInMemoryEventStore.cs- In-memory implementation for testingIMessageProducer.cs- Minimal producer abstraction used by the relay (example-local, see below)EventStoreRelay.cs- Background service that polls event store and publishes eventsREADME.md- This file
The ezDDD.NET core packages do not contain a message producer abstraction. Upstream Java
ezddd 6.0.0 (commit 67686ac) moved the MessageProducer interface out of the core library
into the external ezddd-gateway artifact, and ezDDD.NET mirrors that module boundary
(ADR-0029).
The official .NET counterpart will be provided by the ezDDD.Gateway package (planned
post-1.0). Until then, this example carries its own minimal IMessageProducer<TMessage>
declaration (IMessageProducer.cs), and applications that need a producer port should do the
same in their composition root:
public interface IMessageProducer<in TMessage> : IDisposable
{
Task PostAsync(TMessage message);
}// Startup.cs or Program.cs
public void ConfigureServices(IServiceCollection services)
{
// Register event store
services.AddSingleton<IEventStore, InMemoryEventStore>();
// Register message producer (Kafka, RabbitMQ, etc.)
services.AddSingleton<IMessageProducer<DomainEventData>>(sp =>
new KafkaMessageProducer(Configuration.GetSection("Kafka")));
// Register EventStoreRelay as hosted service
services.AddHostedService<EventStoreRelay>();
// Register repositories (NO MessageProducer parameter)
services.AddScoped<IRepository<Order, OrderId>>(sp =>
new EsRepository<Order, OrderId>(
sp.GetRequiredService<IRepositoryPeer<EventStoreData<OrderId>, OrderId>>()));
}// Configure polling interval
services.AddHostedService<EventStoreRelay>(sp =>
new EventStoreRelay(
sp.GetRequiredService<IEventStore>(),
sp.GetRequiredService<IMessageProducer<DomainEventData>>(),
sp.GetRequiredService<ILogger<EventStoreRelay>>(),
pollingIntervalMs: 100 // Poll every 100ms
));// Simple test double implementing the example-local IMessageProducer<TMessage>
public sealed class FakeMessageProducer<TMessage> : IMessageProducer<TMessage>
{
private readonly List<TMessage> _postedMessages = [];
public IReadOnlyList<TMessage> PostedMessages => _postedMessages;
public Task PostAsync(TMessage message)
{
_postedMessages.Add(message);
return Task.CompletedTask;
}
public void Dispose() { }
}
[Fact]
public async Task OrderCreated_EventPublishedViaRelay()
{
// Arrange
var eventStore = new InMemoryEventStore();
var producer = new FakeMessageProducer<DomainEventData>();
var repository = new EsRepository<Order, OrderId>(
new InMemoryEventStorePeer(eventStore));
var relay = new EventStoreRelay(
eventStore,
producer,
NullLogger<EventStoreRelay>.Instance,
pollingIntervalMs: 50);
await relay.StartAsync(CancellationToken.None);
try
{
// Act
var order = new Order(orderId, customerId);
await repository.SaveAsync(order);
// Wait for relay to poll and publish (50ms + buffer)
await Task.Delay(100);
// Assert
Assert.Single(producer.PostedMessages);
Assert.Equal("OrderCreated", producer.PostedMessages.First().EventType);
}
finally
{
await relay.StopAsync(CancellationToken.None);
}
}┌─────────────────────────────────────────────────────────────┐
│ 1. Poll event store for new events (every 100ms) │
│ ↓ │
│ 2. For each new event: │
│ a. Convert to DomainEventData │
│ b. Publish to MessageProducer │
│ c. If success: increment index, continue │
│ d. If failure: log error, break, retry on next poll │
│ ↓ │
│ 3. Sleep for polling interval │
│ ↓ │
│ 4. Repeat until cancelled │
└─────────────────────────────────────────────────────────────┘
The relay uses a fail-safe approach:
- Publish Failure: Caught, logged, stops current batch, retries on next poll
- Event Store Failure: Caught, logged, continues polling
- Any Other Exception: Caught, logged, continues polling
Key Guarantee: The relay never gives up. Failed events will be retried indefinitely until successful.
Polling Interval Recommendations:
- Production: 100-500ms (balance latency and load)
- Testing: 10-50ms (faster feedback)
- High-throughput: 50-100ms (minimize latency)
- Low-priority: 500-1000ms (reduce load)
Database Optimization:
- Index the sequence/timestamp column used by
GetEventsAfterAsync - Use efficient range queries (avoid full table scans)
- Consider partitioning for high-volume scenarios
public class SqlEventStore : IEventStore
{
private readonly string _connectionString;
public async Task<IReadOnlyList<IInternalDomainEvent>> GetEventsAfterAsync(
int afterIndex,
CancellationToken cancellationToken = default)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
var command = new SqlCommand(
"SELECT EventData FROM EventStore WHERE SequenceNumber > @AfterIndex ORDER BY SequenceNumber",
connection);
command.Parameters.AddWithValue("@AfterIndex", afterIndex);
var events = new List<IInternalDomainEvent>();
using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var eventData = reader.GetString(0);
var @event = JsonUtil.Deserialize<IInternalDomainEvent>(eventData);
events.Add(@event);
}
return events.AsReadOnly();
}
}public class PostgresEventStore : IEventStore
{
private readonly string _connectionString;
public async Task<IReadOnlyList<IInternalDomainEvent>> GetEventsAfterAsync(
int afterIndex,
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new NpgsqlCommand(
"SELECT event_data FROM event_store WHERE sequence_number > $1 ORDER BY sequence_number",
connection);
command.Parameters.AddWithValue(afterIndex);
var events = new List<IInternalDomainEvent>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var eventData = reader.GetString(0);
var @event = JsonUtil.Deserialize<IInternalDomainEvent>(eventData);
events.Add(@event);
}
return events.AsReadOnly();
}
}- Java ezddd InMemoryEventStoreRelay: Reference implementation in Java
- Transactional Outbox Pattern: Chris Richardson - Microservices.io
- ADR-0025: MessageProducer Refactoring
- ADR-0029: MessageProducer Removal from Core & Gateway Package Deferral
A: Direct publishing violates the Outbox Pattern and provides no eventual consistency guarantee. If the message broker is down during SaveAsync, the event is saved to the database but never published.
A: The relay maintains a currentIndex that tracks the last successfully published event. When restarted, it resumes from that index. Events are never lost (assuming durable event store).
A: The current implementation does not support distributed locking. For multiple instances, implement a distributed lock (Redis, database lock) or use message broker partitioning.
A: The relay provides at-least-once delivery. Events may be published multiple times if the relay crashes after publishing but before incrementing the index. Your consumers should implement idempotency.
A: The relay logs all operations at different levels:
- Information: Start, stop, event counts
- Debug: Individual event publishing (enable for troubleshooting)
- Error: Publish failures with retry information
Use structured logging (Serilog, NLog) to send logs to monitoring systems (Application Insights, Datadog, etc.).
This example code is part of ezDDD.NET and follows the same license as the main project.