It's famously difficult to get consistency between database state changes and published events in distributed systems. The SQL outbox pattern solves this reliably by allowing you to write events and domain data in the same transaction so that no events are lost or published out of order.
In this article, I explain how to create a safe event-driven CQRS microservice with C# 14, the SQL outbox pattern, new language features, and fail-safe message handling.
Why Do You Need the Outbox Pattern?
The most significant issue it prevents: database update and event publishing must be either both succeed or both fail — something that isn't guaranteed when your message broker lies outside of your database.
The outbox pattern addresses this by handling.
- Scheduling the event as a record in an Outbox table allocated within the same DB transaction
- Asynchronous processing of the outbox table and publishing events
- Removing or marking already processed events to avoid duplication
Overview of CQRS Microservice Flow
- Write API: accepts commands, saves domain state + outbox in a single transaction.
- Outbox Processor: reads new events from the outbox, publishes to a broker (e.g., RabbitMQ), and marks as sent.
- Read API: Built on its own, calls a projection DB or a cache
Step 1. Domain Model using C# 14 Features.
Order Domain Entity
public class Order
{
public string Id { get; }
public decimal Total { get; }
public Order(string id, decimal total)
{
Id = id;
Total = total;
}
public OrderPlacedEvent ToEvent() => new(Id, Total);
}
public record OrderPlacedEvent(string OrderId, decimal Total);
Step 2. Persist Order + Outbox Transactionally.
public async Task PlaceOrderAsync(Order order)
{
using var conn = new SqlConnection(_dbConn);
await conn.OpenAsync();
using var tx = conn.BeginTransaction();
try
{
var insertOrderQuery = "INSERT INTO Orders (Id, Total) VALUES (@Id, @Total)";
var insertOutboxQuery = "INSERT INTO Outbox (Id, Type, Payload) VALUES (@Id, @Type, @Payload)";
// Insert order details
await conn.ExecuteAsync(insertOrderQuery, new { order.Id, order.Total }, tx);
// Prepare and insert event data
var evt = order.ToEvent();
await conn.ExecuteAsync(insertOutboxQuery, new
{
Id = Guid.NewGuid(),
Type = evt.GetType().Name,
Payload = JsonSerializer.Serialize(evt)
}, tx);
// Commit transaction
tx.Commit();
}
catch (Exception ex)
{
// Rollback transaction in case of error
tx.Rollback();
// Log or rethrow the exception as needed
throw new ApplicationException("An error occurred while placing the order.", ex);
}
}
Step 3. Asynchronous Outbox Processing.
Use a background service to poll and publish.
public async Task ProcessOutboxAsync()
{
using var conn = new SqlConnection(_dbConn);
var events = await conn.QueryAsync<OutboxRecord>("SELECT TOP 100 * FROM Outbox WHERE Processed = 0");
foreach (var record in events)
{
var evt = DeserializeEvent(record.Type, record.Payload);
await _publisher.PublishAsync(evt);
await conn.ExecuteAsync("UPDATE Outbox SET Processed = 1 WHERE Id = @Id", new { record.Id });
}
}
Pattern Matching to Deserialize Events.
object DeserializeEvent(string type, string payload) =>
type switch
{
nameof(OrderPlacedEvent) => JsonSerializer.Deserialize<OrderPlacedEvent>(payload)!,
_ => throw new InvalidOperationException($"Unknown event type {type}")
};
Deduplication (Optional Advanced Step)
- Insert an EventId GUID in another column in the consumers.
- Apply idempotent handlers.
- Use Redis SET or local cache for quick duplicate checks.
Advantages of This Pattern
- Strong consistency: event publishing is in the same DB transaction.
- Durable and retryable: events are stored in the outbox until processed.
- Decoupled services: event consumers are totally independent.
- Clean CQRS separation of write and read models.
Conclusion
By combining the SQL outbox pattern with new features in C# 14 like primary constructors, pattern matching, and brief record types, you can create strong and maintainable microservices that are both event-driven and strongly consistent. This setup also positions your service for scalability, message broker integration, and eventual consistency.