SimpleOutbox 1.6.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package SimpleOutbox --version 1.6.0
                    
NuGet\Install-Package SimpleOutbox -Version 1.6.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SimpleOutbox" Version="1.6.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SimpleOutbox" Version="1.6.0" />
                    
Directory.Packages.props
<PackageReference Include="SimpleOutbox" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SimpleOutbox --version 1.6.0
                    
#r "nuget: SimpleOutbox, 1.6.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SimpleOutbox@1.6.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SimpleOutbox&version=1.6.0
                    
Install as a Cake Addin
#tool nuget:?package=SimpleOutbox&version=1.6.0
                    
Install as a Cake Tool

SimpleOutbox

A simple, standalone Transactional Outbox library for .NET applications using Entity Framework Core and PostgreSQL.

NuGet NuGet Downloads .NET PostgreSQL License GitHub


Why SimpleOutbox?

When you need to update your database and publish a message/event, you face the dual-write problem. If the message publish fails after the database commit, you have inconsistent state. SimpleOutbox solves this by storing messages in the same database transaction as your domain changes, then reliably delivering them asynchronously.

Database Transaction
├── Update Order status = "Paid"
├── Insert OutboxMessage (OrderPaidEvent)  ← Same transaction
└── Commit

Background Service (async)
├── SELECT ... FOR UPDATE SKIP LOCKED
├── Deserialize & Process message
└── Mark as Completed

Features

Feature Description
Transactional Outbox Messages persisted atomically with your domain changes
FIFO per Partition Messages with same PartitionKey processed in order
Concurrency Control Configurable MaxConcurrency per partition
Scheduling Delay message delivery with ScheduleAsync
Multi-Instance Safe PostgreSQL FOR UPDATE SKIP LOCKED for distributed locking
Retry with Backoff Exponential backoff on failures
Cleanup Service Automatic removal of old processed messages
Test-Friendly Synchronous mode for deterministic testing
Typed Handlers IMessageHandler<T> with auto-discovery from assemblies
Pipeline/Middleware IMessageMiddleware for logging, validation, etc.
Telemetry Observers IMessageObserver for metrics and monitoring

Quick Start

1. Install

dotnet add package SimpleOutbox

2. Configure

// Program.cs
builder.Services.AddSimpleOutbox<MyDbContext>(options =>
{
    options.Schema = "outbox";
    options.MaxRetries = 5;
    options.PollingInterval = TimeSpan.FromSeconds(5);
    
    // Auto-discover handlers from assembly
    options.AddHandlersFromAssembly(typeof(Program).Assembly);
});

3. Apply EF Configuration

public class MyDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyOutboxConfiguration();
    }
}

4. Use It

public class OrderService(MyDbContext db, IOutboxService outbox)
{
    public async Task CreateOrderAsync(Order order, CancellationToken ct)
    {
        db.Orders.Add(order);
        
        // Enqueue event in same transaction (use order.Id for idempotency)
        await outbox.EnqueueAsync(order.Id, new OrderCreatedEvent(order.Id), ct);
        
        // Both committed atomically
        await db.SaveChangesAsync(ct);
    }
}

5. Implement Handler

// Typed handler (recommended)
public class OrderCreatedHandler : IMessageHandler<OrderCreatedEvent>
{
    public async Task HandleAsync(OrderCreatedEvent message, CancellationToken ct)
    {
        // Process the order created event
        await SendWelcomeEmail(message.CustomerEmail, ct);
    }
}

// Or legacy handler for all message types
public class MyOutboxHandler(IMediator mediator) : IOutboxMessageHandler
{
    public async Task HandleAsync(object message, Type messageType, CancellationToken ct)
    {
        await mediator.Send(message, ct);
    }
}

Documentation

Topic Description
Getting Started Installation, setup, and first steps
Configuration All available options explained
Handlers & Pipeline Typed handlers, middleware, and observers
Partitioning & FIFO How to use partitions for ordered processing
Testing Synchronous mode and testing strategies

API Overview

IOutboxService

public interface IOutboxService
{
    // Enqueue for immediate processing (messageId for idempotency)
    Task EnqueueAsync<T>(Guid messageId, T message, CancellationToken ct = default);
    
    // Schedule for future processing (messageId for idempotency and cancellation)
    Task ScheduleAsync<T>(Guid messageId, T message, TimeSpan delay, CancellationToken ct = default);
    Task ScheduleAsync<T>(Guid messageId, T message, DateTime scheduledFor, CancellationToken ct = default);
    
    // Cancel a scheduled message
    Task<bool> CancelAsync(Guid messageId, CancellationToken ct = default);
}

IMessageHandler<T>

public class OrderCreatedHandler : IMessageHandler<OrderCreatedEvent>
{
    public Task HandleAsync(OrderCreatedEvent message, CancellationToken ct)
    {
        // Process typed message
    }
}

IPartitionedMessage

public record OrderEvent(Guid OrderId) : IPartitionedMessage
{
    public string PartitionKey => $"order:{OrderId}";
    public int MaxConcurrency => 1; // Strict FIFO
}

IMessageMiddleware

public class LoggingMiddleware(ILogger<LoggingMiddleware> logger) : IMessageMiddleware
{
    public async Task InvokeAsync(object message, Type messageType, Func<Task> next, CancellationToken ct)
    {
        logger.LogInformation("Processing {Type}", messageType.Name);
        await next();
        logger.LogInformation("Completed {Type}", messageType.Name);
    }
}

Architecture

┌─────────────────────────────────────────────────────────────┐
│                     Your Application                         │
│                                                              │
│   dbContext.Add(order);                                      │
│   await outbox.EnqueueAsync(messageId, new OrderCreatedEvent(...)); │
│   await dbContext.SaveChangesAsync();  // Atomic commit      │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                  OutboxDeliveryService                       │
│                   (BackgroundService)                        │
│                                                              │
│   1. SELECT ... FOR UPDATE SKIP LOCKED                       │
│   2. Check partition concurrency limits                      │
│   3. Deserialize and execute pipeline:                       │
│      Middlewares -> IMessageHandler<T> (or legacy handler)   │
│   4. Notify observers (telemetry)                            │
│   5. Mark as Completed or schedule retry                     │
│   6. Wait for notification or polling interval               │
└─────────────────────────────────────────────────────────────┘

Database Schema

SimpleOutbox creates a single table in your configured schema (default: outbox):

CREATE TABLE outbox.outbox_messages (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    message_id      UUID NOT NULL UNIQUE,
    message_type    VARCHAR(500) NOT NULL,
    payload         TEXT NOT NULL,
    partition_key   VARCHAR(200),
    max_concurrency INT DEFAULT 1,
    created_at      TIMESTAMP DEFAULT NOW(),
    scheduled_for   TIMESTAMP,
    processed_at    TIMESTAMP,
    retry_count     INT DEFAULT 0,
    last_error      TEXT,
    status          VARCHAR(20) DEFAULT 'Pending'
);

Indexes are automatically created for:

  • Pending message lookup
  • Partition FIFO ordering
  • Concurrency control
  • Cleanup queries
  • Message cancellation

Requirements

  • .NET 10.0+
  • PostgreSQL 14+
  • Entity Framework Core 9.0+

License

MIT License - see LICENSE for details.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.0 37 1/25/2026
1.6.0 37 1/25/2026
1.5.0 35 1/25/2026
1.4.0 37 1/25/2026
1.3.1 36 1/25/2026
1.3.0 40 1/25/2026
1.2.0 36 1/24/2026
1.1.0 37 1/24/2026
0.2.0 33 1/24/2026
0.1.0 35 1/24/2026
0.0.4 32 1/24/2026
0.0.3 38 1/24/2026