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.

Overview of CQRS Microservice Flow

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)

Advantages of This Pattern

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.