Integration: outbox + sagas (distributed reality)
Inside one aggregate stream, you can be strongly consistent. Across services, you can’t (without big tradeoffs).
So you design for:
- eventual consistency
- idempotency
- retries
- duplicates
Domain events vs integration events
- Domain event: used to rebuild state inside your bounded context.
- Integration event: used to notify other bounded contexts/services.
You often derive integration events from domain events, but you usually don’t publish domain events “as-is”.
Why?
- other services shouldn’t couple to your internal domain shape
- you may want different payload (less/more data)
- you may need versioning independently
The classic reliability problem
You do a write, then publish a message:
- append domain events to event store ✅
- publish integration event to broker ❌ (crash/network)
Now your system is inconsistent.
Outbox pattern (the fix)
Instead of “write then publish”, you do:
- write your source of truth
- also write an outbox record in the same transaction / atomic unit
- a background process publishes outbox records
Event sourcing makes step (1) naturally append-only. You still need a safe publish mechanism.
Minimal outbox model
public sealed record OutboxMessage(
Guid MessageId,
DateTimeOffset OccurredAt,
string Topic,
byte[] Payload,
string? ContentType,
bool Published
);
public interface IOutboxStore
{
Task Enqueue(OutboxMessage message, CancellationToken ct);
Task<IReadOnlyList<OutboxMessage>> GetUnpublished(int limit, CancellationToken ct);
Task MarkPublished(Guid messageId, CancellationToken ct);
}
public interface IMessageBus
{
Task Publish(string topic, byte[] payload, string? contentType, CancellationToken ct);
}
Deriving integration events from domain events
Example: when money is withdrawn, other services might care about “account balance changed”.
public sealed record AccountBalanceChangedIntegrationEvent(
Guid AccountId,
decimal NewBalance,
DateTimeOffset OccurredAt
);
Projection-like transformer:
public static class IntegrationEventFactory
{
public static OutboxMessage? TryCreate(
Guid accountId,
BankAccount currentState,
IDomainEvent domainEvent
)
{
if (domainEvent is MoneyDeposited or MoneyWithdrawn)
{
var integration = new AccountBalanceChangedIntegrationEvent(
AccountId: accountId,
NewBalance: currentState.Balance,
OccurredAt: DateTimeOffset.UtcNow
);
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(integration);
return new OutboxMessage(
MessageId: Guid.NewGuid(),
OccurredAt: integration.OccurredAt,
Topic: "account.balance.changed.v1",
Payload: payload,
ContentType: "application/json",
Published: false
);
}
return null;
}
}
In real code, you’d pass the time in, not call UtcNow in the factory.
Publishing outbox messages (background worker)
public sealed class OutboxPublisher
{
private readonly IOutboxStore _outbox;
private readonly IMessageBus _bus;
public OutboxPublisher(IOutboxStore outbox, IMessageBus bus)
{
_outbox = outbox;
_bus = bus;
}
public async Task RunOnce(int batchSize, CancellationToken ct)
{
var pending = await _outbox.GetUnpublished(batchSize, ct);
foreach (var msg in pending)
{
await _bus.Publish(msg.Topic, msg.Payload, msg.ContentType, ct);
await _outbox.MarkPublished(msg.MessageId, ct);
}
}
}
This is the minimum. Production concerns:
- retries with backoff
- poison message handling
- ordering guarantees (per topic or per key)
- exactly-once is rare; assume at-least-once
Sagas (when a business process spans services)
A saga coordinates multiple local transactions across services. Two broad styles:
- Orchestration: a central coordinator tells participants what to do.
- Choreography: services react to events and publish follow-up events.
Event sourcing fits well because you already represent changes as facts.
Next
If you can’t test ES cleanly, you don’t actually understand it yet.
Next: Testing event-sourced domain logic
Sources
https://learn.microsoft.com/en-us/azure/architecture/databases/guide/transactional-outbox-cosmoshttps://learn.microsoft.com/en-us/dotnet/architecture/microservices/architect-microservice-container-applications/asynchronous-message-based-communication#resiliently-publishing-to-the-event-bus