Skip to main content

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:

  1. append domain events to event store ✅
  2. publish integration event to broker ❌ (crash/network)

Now your system is inconsistent.

Outbox pattern (the fix)

Instead of “write then publish”, you do:

  1. write your source of truth
  2. also write an outbox record in the same transaction / atomic unit
  3. 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-cosmos
  • https://learn.microsoft.com/en-us/dotnet/architecture/microservices/architect-microservice-container-applications/asynchronous-message-based-communication#resiliently-publishing-to-the-event-bus