I'm always excited to take on new projects and collaborate with innovative minds.

Social Links

Solving the Outbox Pattern in .NET with DotNetCore.CAP: A Hands-On Demo

A practical walkthrough of the DotNetCore.CAP event bus in .NET 6 — how the transactional outbox pattern saves your messages from the "record saved, message never sent" trap, complete with a real publish–consume pipeline you can run locally.

If you have ever built a system where a database write and a message publish need to happen together, you have likely encountered the dual-write problem.

Consider a typical scenario: you write an order to the database and then attempt to publish a NewOrder event to your message queue. If the publish step fails due to a network blip, an application crash, or expired credentials, your system ends up in an inconsistent state. The order exists in your database, but your message bus remains unaware of it. The record is saved, but downstream services are never notified.

This is the exact problem DotNetCore.CAP addresses. CAP is a distributed event bus for .NET that implements the Transactional Outbox Pattern. This article explores an end-to-end .NET 6 API implementation that demonstrates how CAP handles this pipeline cleanly.

1. The Dual-Write Problem

In a typical e-commerce architecture, placing an order involves two main steps:

  1. Writing the order record to the primary database.
  2. Publishing an event to Azure Service Bus so downstream services (like fulfillment) can process it.

Because step two occurs after the database transaction commits, a failure during message publication leaves the database out of sync with the rest of the system.

Attempting to wrap both operations in a single distributed transaction (using mechanisms like MSDTC) introduces significant complexity, performance bottlenecks, and infrastructure constraints. Modern cloud environments and databases rarely handle distributed transactions well.

2. The Solution: The Transactional Outbox Pattern

The Transactional Outbox Pattern resolves this by shifting when and where the message is written:

  • Database Write: The business data and an outbox record (the message) are written within the same database transaction.
  • Atomic Commit: Both records commit together, ensuring that if the business data exists, the message record is guaranteed to exist.
  • Background Delivery: A background process reads from the outbox table and delivers the message to the message bus.
  • Consumer Handling: Subscribers receive and process the message.

Because the outbox record is part of the local database transaction, data loss between state changes and message emission is eliminated. If message delivery fails, the background worker can safely retry until successful.

CAP automates this pattern. It integrates with your database connection, manages the underlying outbox tables, delivers messages to your chosen transport (Azure Service Bus, RabbitMQ, Kafka, AWS SQS), tracks execution status, and manages retries automatically.

3. Demo Application Overview

The accompanying sample is an ASP.NET Core API demonstrating the complete publish, store, deliver, and consume lifecycle:

  1. An HTTP GET /WeatherForecast request is triggered.
  2. The controller publishes a helloWorld message inside an active database transaction.
  3. CAP stores the message in a SQL Server outbox table as part of that same transaction.
  4. CAP reads the table and forwards the message to Azure Service Bus.
  5. A subscriber decorated with [CapSubscribe("helloWorld")] receives and processes the payload.

4. Implementation: Publishing Messages

Publishing relies on injecting ICapPublisher into your controller or service. CAP integrates directly into your database transaction:

using(var connection = new MySqlConnection(_mySqlConnectionString))
{
    using(var transaction = connection.BeginTransaction(_capPublisher))
    {
        await _capPublisher.PublishAsync("helloWorld", "TestDemo");
        transaction.Commit();
    }
}

Passing _capPublisher into connection.BeginTransaction(...) binds the published event to the local database transaction.

  • If transaction.Commit() succeeds, the outbox entry is saved.
  • If the transaction rolls back, the message is discarded alongside the database changes.

In production applications, the payload ("TestDemo") is typically a strongly typed DTO representing an event such as order.created or payment.succeeded.

5. Implementation: Consuming Messages

On the consuming side, a background service implements ICapSubscribe and marks subscriber methods with the CapSubscribe attribute matching the event name:

public class ReceiverService : IReceiverService, ICapSubscribe
{
    private readonly ILogger<ReceiverService> _logger;

    public ReceiverService(ILogger<ReceiverService> logger)
    {
        _logger = logger;
    }

    [CapSubscribe("helloWorld")]
    public void Receive(string value)
    {
        _logger.Log(LogLevel.Debug, value);
    }
}

CAP handles topic subscription, payload deserialization, and method execution automatically without requiring custom polling logic or broker-specific client libraries.

6. Pipeline Configuration

Configuration occurs in Program.cs. Three primary extension methods configure CAP's storage, transport, and monitoring mechanisms:

builder.Services.AddCap(x =>
{
    x.UseSqlServer(sqlServerConnectionString);
    x.UseAzureServiceBus(azureServiceBusConnectionString);
    x.UseDashboard();
});
  • UseSqlServer: Specifies where CAP creates and manages its outbox and inbox tables.
  • UseAzureServiceBus: Configures the transport layer responsible for message delivery.
  • UseDashboard: Enables an integrated web dashboard to inspect real-time message states, execution logs, and failure retries.

7. Execution and Monitoring

Running the application requires .NET 6, access to SQL Server, and an Azure Service Bus connection string (or local emulator).

Once running, two endpoints become accessible:

  • Swagger UI (/swagger): Used to execute requests against GET /WeatherForecast.
  • CAP Dashboard (/cap): Provides real-time visibility into message states (Published, Delivered, Received, or Failed), processing timestamps, and retry metrics.

Executing requests via Swagger populates the CAP dashboard, illustrating the progression of messages through the outbox workflow.

8. Technology Stack

  • .NET 6: Application framework and runtime.
  • DotNetCore.CAP (7.0.2): Transactional outbox implementation and event bus.
  • DotNetCore.CAP.SqlServer: Persistent storage provider for outbox and inbox tables.
  • DotNetCore.CAP.AzureServiceBus: Messaging transport provider.
  • DotNetCore.CAP.Dashboard: Web interface for operational monitoring.
  • Swashbuckle (Swagger): API documentation and testing interface.

9. Key Considerations and Best Practices

CAP is well-suited for distributed systems using relational databases and message brokers where consistency across services is critical.

Key considerations when adopting this pattern include:

  • At-Least-Once Delivery: Outbox delivery guarantees that messages are published at least once. Consumers should be designed to be idempotent to handle potential duplicate deliveries gracefully.
  • Database Overhead: CAP introduces state tables to your database to manage message lifecycle data. The minor overhead on database storage and background polling is standard trade-off for transactional consistency.

10. Conclusion

This demo illustrates how DotNetCore.CAP simplifies the Transactional Outbox Pattern in .NET applications. By coupling message publishing directly to database transactions and abstracting transport logic, CAP mitigates data inconsistency issues in distributed architectures.

5 min read
Feb 22, 2023
By Dheer Gupta
Share

Leave a comment

Your email address will not be published. Required fields are marked *