MessagingKit.Outbox 0.2.2

dotnet add package MessagingKit.Outbox --version 0.2.2
                    
NuGet\Install-Package MessagingKit.Outbox -Version 0.2.2
                    
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="MessagingKit.Outbox" Version="0.2.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MessagingKit.Outbox" Version="0.2.2" />
                    
Directory.Packages.props
<PackageReference Include="MessagingKit.Outbox" />
                    
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 MessagingKit.Outbox --version 0.2.2
                    
#r "nuget: MessagingKit.Outbox, 0.2.2"
                    
#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 MessagingKit.Outbox@0.2.2
                    
#: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=MessagingKit.Outbox&version=0.2.2
                    
Install as a Cake Addin
#tool nuget:?package=MessagingKit.Outbox&version=0.2.2
                    
Install as a Cake Tool

MessagingKit

Transactional outbox and inbox for .NET 10, on EF Core and PostgreSQL.

Reference it from a module and that module can send and receive durably. Messages between modules in one host travel over the same outbox → transport → inbox seam they would use across a network, so moving a module into its own deployment later changes one registration and no module code.

The framework knows nothing about brokers, HTTP, or any other transport. It owns durability and the state machine; you implement IMessageTransport and plug in whatever moves the bytes. An in-process transport ships in the box.

The problem

You cannot atomically write to your database and talk to another system:

db.Invoices.Add(invoice);
await db.SaveChangesAsync();     // committed
await broker.PublishAsync(msg);  // process dies here — invoice exists, nobody was told

Swap the order and you get the opposite bug: the message goes out, the transaction rolls back, and you've announced an invoice that does not exist.

The outbox closes that gap by making the intent to send part of the same transaction. The inbox closes the matching gap on the receiving side, where a retried delivery would otherwise be processed twice.

Outbox means don't lose it. Inbox means don't do it twice. A broker replaces neither — it only carries the message in between.

Packages

Package Contents
MessagingKit AddMessaging() — outbox, inbox, and in-process delivery in one registration
MessagingKit.Abstractions MessageEnvelope, IMessageTransport, IMessageHandler<T>, serializer, type registry
MessagingKit.Outbox IOutbox.Add, EF model config, SKIP LOCKED dispatcher, retry and dead-lettering
MessagingKit.Inbox durable deduplication, background processor, handler dispatch, retry and dead-lettering
MessagingKit.InProcess InProcessTransport — delivers outbox rows straight into the inbox
MessagingKit.Testing DrainMessagingAsync() — run both halves to completion inside a test

Take MessagingKit for the whole thing, or the halves on their own if you only send or only receive.

Modules

A module registers what it handles. Nothing else in the host needs to know:

A module never names the host's DbContext — it only declares what it sends and handles, so it depends on MessagingKit.Abstractions alone:

// inside the email module
public static IServiceCollection AddEmailModule(this IServiceCollection services) =>
    services.AddMessageHandler<SendEmail, SendEmailHandler>();
// inside the billing module — sends, does not handle
services.AddMessageContract<SendEmail>();

The host owns the context, declares it once, and wires the modules:

builder.Services.AddMessaging<AppDbContext>(builder.Configuration);

builder.Services.AddEmailModule();
builder.Services.AddBillingModule();

Order does not matter — both sides only add to the service collection.

For a single-module host the fluent form does the same thing in one statement:

builder.Services.AddMessaging<AppDbContext>(builder.Configuration)
    .Handles<SendEmail, SendEmailHandler>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.AddMessaging();   // messaging.outbox + messaging.inbox
}

The tables land in your DbContext, so dotnet ef migrations add picks them up in your own migration history — the package ships no migrations of its own.

AddMessaging is safe to call once per module. The dispatcher and processor are registered once no matter how many modules call it, and in-process delivery is the default transport.

Billing then sends inside its own transaction, and the email module handles it:

db.Invoices.Add(invoice);
outbox.Add(new SendEmail(customer.Email, "Your invoice"));
await db.SaveChangesAsync();    // both rows commit, or neither does

Message names

A message carries a name on the wire. It is derived from the type — SendEmail becomes send-email, HTTPRequest becomes http-request — so Handles<TMessage, THandler>() registers the same name for the sender and the handler and they cannot drift apart.

Renaming the class renames the message, which orphans rows already queued. Pin the name on anything in production:

[Message("email.send.v2")]
public record SendEmail(string To, string Subject);

The string overloads are still there when you want to be explicit: AddMessage<T>("name"), AddHandler<T, THandler>("name").

Transports and routing

In-process delivery is the default. Route specific types elsewhere as modules move out of the host:

services.AddMessaging<AppDbContext>(config)
    .Handles<SendEmail, SendEmailHandler>()          // stays local
    .UseTransportFor<BrokerTransport, InvoiceIssued>();  // this one goes to the broker

Or set a different default and keep only some types local:

.UseInProcessTransport("send-email")   // named types stay in the host
.UseTransport<BrokerTransport>()       // everything else goes to the broker

A routing key matches either a message's destination or its type, so a module can address another module directly and override the type's usual route:

outbox.Add(new SendEmail(...), destination: "email-module");
.UseTransport<BrokerTransport>("email-module")   // everything addressed there
.UseInProcessTransport("send-email")             // everything of this type

Resolution runs destination first, then message type, then the default, then any IMessageTransport registered directly in DI. A message with no route at all is dead-lettered immediately rather than retried — a missing registration is a wiring bug, and retrying it for ten attempts only buries the cause.

Implementing a transport is one method:

public sealed class BrokerTransport(IBrokerClient broker) : IMessageTransport
{
    public async Task<TransportResult> SendAsync(MessageEnvelope envelope, CancellationToken ct = default)
    {
        try
        {
            await broker.PublishAsync(envelope.Destination ?? envelope.Type, envelope.Payload, ct);
            return TransportResult.Ok();
        }
        catch (BrokerException ex)
        {
            return TransportResult.Transient(ex.Message);
        }
    }
}

Return Permanent for failures retrying cannot fix — a malformed destination, a rejected payload. Those skip the retry ladder and go straight to Dead.

Receiving

Handlers take the deserialized message plus a MessageContext carrying the message id, type, attempt count, and headers:

public sealed class SendEmailHandler(IEmailClient client) : IMessageHandler<SendEmail>
{
    public Task HandleAsync(SendEmail message, MessageContext context, CancellationToken ct = default) =>
        client.SendAsync(message.To, message.Subject, ct);
}

With InProcessTransport the outbox hands messages to the inbox for you. When something else receives them — a broker subscriber, an HTTP endpoint — call the inbox yourself:

if (await inbox.TryStoreAsync(envelope, ct))
{
    // stored; the processor will handle it
}
// false means already seen — ack and move on

TryStoreAsync is the deduplication point: the message id is the primary key, so a redelivery cannot create a second row. Acknowledge the transport immediately; the background InboxProcessor resolves IMessageHandler<T>, runs it, and marks the row Processed. A throwing handler is retried with backoff, then dead-lettered.

À la carte

Outbox and inbox work on their own if you do not want both:

builder.Services.AddOutbox<AppDbContext>(builder.Configuration)
    .AddMessage<SendEmail>()
    .UseTransport<BrokerTransport>();

builder.Services.AddInbox<AppDbContext>(builder.Configuration)
    .AddHandler<SendEmail, SendEmailHandler>();
modelBuilder.AddOutbox();   // messaging.outbox
modelBuilder.AddInbox();    // messaging.inbox

Tracing

Trace context is captured when a message is staged and carried in its headers, so the send and the handling that follows it belong to one trace rather than two unrelated ones. Subscribe to the source:

builder.Services.AddOpenTelemetry().WithTracing(t => t
    .AddSource(MessagingDiagnostics.ActivitySourceName)
    .AddAspNetCoreInstrumentation());

You get a send {type} producer span at dispatch and a handle {type} consumer span at processing, both parented to whatever was active when the caller wrote the row — with the message id, type, and attempt number as tags, and failures marked as errors. Without this, an async boundary makes debugging strictly worse than a method call.

Compile-time checks

MessagingKit.Abstractions ships an analyzer, so referencing it is all it takes:

Rule Severity Catches
MK1001 Error Two message types resolving to the same wire name — the receiver cannot tell them apart
MK1002 Warning A handler nothing registers, so it never runs
MK1003 Error [Message("")], which throws when the attribute is read at startup
MK1004 Off by default A message with no [Message], where renaming the type renames it on the wire

MK1002 is a warning rather than an error because the registration may legitimately live in another project; suppress it there. Turn on MK1004 once messages are in production and a class rename would orphan queued rows:

dotnet_diagnostic.MK1004.severity = warning

Startup validation

A message delivered in-process with no handler registered fails the host at boot:

MessagingKit is misconfigured:
  - 'send-email' is delivered in-process but no IMessageHandler<SendEmail> is registered.
    Register a handler with Handles<SendEmail, THandler>(), or route it to another transport.

That is a failed deploy instead of a dead-lettered row discovered days later. Messages routed to another transport are skipped — they are handled wherever they land.

Configuration

Both halves bind the same shape, under Outbox and Inbox:

Key Default Notes
Enabled true false leaves the tables in place but runs no background job
Schema / TableName messaging / outbox, inbox Must match what you passed to AddMessaging() / AddOutbox() / AddInbox()
BatchSize / Concurrency 50 / 4 Rows claimed per tick, and how many run in parallel
PollIntervalSeconds 5 Skipped while batches come back full
LockDurationSeconds 120 Must exceed the slowest realistic delivery
MaxAttempts 10 Then Dead
BaseRetryDelaySeconds / MaxRetryDelaySeconds 10 / 3600 Doubles per attempt, capped

Operational notes

  • Delivery is prompt, not instant. Committing a transaction that staged outbox rows wakes the dispatcher, and storing to the inbox wakes the processor, so a message does not wait out the poll interval. The interval is the fallback — a missed signal costs latency, never correctness.
  • Ordering is not guaranteed. Messages dispatch in parallel; if you need per-key ordering, that is a transport concern.
  • Delivery is at-least-once. A process can die after the transport accepts a message but before the row is marked Sent. That is precisely why the receiver needs an inbox.
  • LockDurationSeconds must exceed your slowest send, or a second worker reclaims a row still in flight and the message goes out twice.
  • Both tables grow forever. Add a retention job that deletes Sent and Processed rows past your audit window; keep Dead until someone has looked at them.
  • One inbox table per host, shared by every module and routed by message type. A module gets its own inbox when it gets its own database.
  • Multiple replicas are safe — SKIP LOCKED guarantees a row is claimed by exactly one worker.

Testing against it

MessagingKit.Testing drives both halves to completion so a test asserts on the effect of a message rather than sleeping until the background loops happen to run:

outbox.Add(new SendEmail("ada@example.com", "Your invoice"));
await db.SaveChangesAsync();

await services.DrainMessagingAsync();

Assert.AreEqual(1, mailbox.Sent.Count);

It drives the same dispatcher and processor production uses, so what the test exercises is what ships. DrainOutboxAsync() and DrainInboxAsync() run one half when you want to assert on what was delivered before anything handles it.

Tests

dotnet test                                                     # every kit
dotnet test kits/messaging-kit/tests/MessagingKit.UnitTests     # no Docker needed
dotnet test kits/messaging-kit/tests/MessagingKit.IntegrationTests   # needs Docker

MessagingKit.UnitTests covers naming, registration, signalling, startup validation, and the analyzer — no infrastructure, so it runs in about a second and parallelises.

MessagingKit.IntegrationTests runs against a real PostgreSQL container using TestingKit, with a fake in-memory transport — the framework is transport-agnostic, so the tests need no broker. It is [assembly: DoNotParallelize]: the suite shares one database and resets between tests.

Test doubles shared by both live in MessagingKit.TestSupport so they cannot drift apart.

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 (3)

Showing the top 3 NuGet packages that depend on MessagingKit.Outbox:

Package Downloads
MessagingKit.InProcess

In-process transport for MessagingKit: delivers outbox messages straight into the inbox, so modules in one host talk over the same seam they would use across a broker.

MessagingKit

Outbox and inbox in one registration. Reference this to give a module durable send and receive with in-process delivery by default.

MessagingKit.Testing

Test helpers for MessagingKit: drive the outbox and inbox to completion inside a test instead of waiting on background polling.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.2 139 7/31/2026
0.2.0 119 7/31/2026