OrionPatch.Testing 0.4.2

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

<p align="center"> <img src="docs/logo.png" alt="OrionPatch" width="150" /> </p>

<h1 align="center">OrionPatch</h1>

<p align="center"> Transactional outbox primitive for .NET. Enqueue inside SaveChanges, dispatch at-least-once through a pluggable sink. </p>

<p align="center"> <a href="https://www.nuget.org/packages/OrionPatch"><img src="https://img.shields.io/nuget/v/OrionPatch?style=flat-square&color=blue" alt="NuGet" /></a> <a href="https://www.nuget.org/packages/OrionPatch"><img src="https://img.shields.io/nuget/dt/OrionPatch?style=flat-square&color=green" alt="Downloads" /></a> <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-yellow?style=flat-square" alt="License" /></a> <img src="https://img.shields.io/badge/.NET-8.0%20%7C%209.0%20%7C%2010.0-purple?style=flat-square" alt="Target" /> </p>


What it does

OrionPatch is a transactional outbox primitive for .NET. You enqueue a message inside an EF Core SaveChanges call; it commits in the same transaction as your domain data; a background dispatcher hands it to a pluggable IOutboxSink at-least-once.

The current release is 0.3.0. The "What ships" and "does NOT do" sections below describe the original v0.1.0 surface as historical record; capabilities added since then (inbox, concrete broker sinks, and the v0.3.0 dead-letter store and archival APIs) are documented in their own sections and in the CHANGELOG.

The package is deliberately small. It does NOT ship a broker — RabbitMQ, Azure Service Bus, Kafka, NATS sinks live in separate opt-in sub-packages on the v0.2+ roadmap. v0.1.0 ships one concrete sink: ChannelOutboxSink (in-process System.Threading.Channels, zero external dependency, useful for monoliths and tests).

It is also deliberately scoped. No inbox in v0.1.0 - inbox idempotency, dedup tables, broker-side consumer wrappers are v0.2 work. v0.1.0 owns one thing well: getting a message from "I just did a domain mutation" to "the sink received it, exactly once per row, even if my process crashes between commit and send."

How it works

A domain event is enqueued by application code, persisted by the EF Core interceptor inside the same transaction as your data, then handed to the sink asynchronously by a hosted dispatcher.

sequenceDiagram
    autonumber
    participant App as Application code
    participant Ob as IOutbox
    participant EF as DbContext
    participant Int as OrionPatch<br/>SaveChangesInterceptor
    participant DB as Outbox table<br/>(same DB, same tx)
    participant Disp as Dispatcher<br/>(hosted service)
    participant Sink as IOutboxSink

    App->>Ob: Enqueue(OrderConfirmed)
    App->>EF: SaveChangesAsync()
    EF->>Int: SavingChanges
    Int->>DB: INSERT OrionPatch_Outbox row
    EF->>DB: INSERT/UPDATE domain rows
    DB-->>EF: COMMIT (atomic)
    EF-->>App: rows affected

    loop poll interval
        Disp->>DB: Claim ready rows (lease)
        DB-->>Disp: OutboxEnvelope batch
        Disp->>Sink: SendAsync(envelope)
        Sink-->>Disp: ok
        Disp->>DB: CompleteAsync(id)
    end

    Note over Disp,Sink: On crash between SendAsync and<br/>CompleteAsync the lease expires and<br/>another dispatcher re-delivers.<br/>Sinks must be idempotent.

The diagram shows the at-least-once contract clearly: the outbox row and the domain rows commit together, but the sink call happens outside the transaction.

Why OrionPatch?

Feature OrionPatch DIY interceptor MassTransit Wolverine
Transactional enqueue Yes Yes Yes Yes
At-least-once dispatch Yes Maybe Yes Yes
EF Core SaveChangesInterceptor Yes Yes Optional -
Multi-provider claim (SQL Server/Postgres/MySQL/SQLite) Yes (v0.2 for native SKIP LOCKED) Maybe Optional Yes
Pluggable sink (no broker bundled) Yes - Bundled Bundled
Built-in retry + dead-letter Yes Maybe Yes Yes
Dead-letter store (route exhausted rows out of the hot outbox) Yes (v0.3) Maybe Yes Yes
Outbox archival / retention (reap processed rows) Yes (v0.3) Maybe Optional Optional
OpenTelemetry Yes Maybe Yes Yes
In-process test sink Yes - Yes Yes
Saga / process manager No (out of scope) - Yes Yes
Standalone primitive (no framework) Yes Yes No No

OrionPatch is a primitive, not a framework. If you want sagas, request/response, or a built-in mediator, reach for MassTransit or Wolverine. If you want transactional outbox without adopting a messaging framework, OrionPatch is the package.

30-second quick start

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionPatch.Abstractions;
using Moongazing.OrionPatch.DependencyInjection;
using Moongazing.OrionPatch.EntityFrameworkCore;
using Moongazing.OrionPatch.EntityFrameworkCore.DependencyInjection;

services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseNpgsql(connectionString);
    options.UseOrionPatch(sp);
});

services.AddOrionPatch()
    .UseEntityFrameworkCore<AppDbContext>()
    .UseSink<MyKafkaSink>();   // or .UseChannelSink() for in-process

Apply the entity configuration in OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.ApplyOrionPatchConfiguration();

Enqueue from your service code:

public class OrderService
{
    private readonly AppDbContext _db;
    private readonly IOutbox _outbox;

    public async Task ConfirmOrderAsync(Guid orderId, CancellationToken ct)
    {
        var order = await _db.Orders.FindAsync([orderId], ct);
        order.Confirm();

        _outbox.Enqueue(new OrderConfirmed(order.Id, order.TotalCents));

        await _db.SaveChangesAsync(ct);   // outbox row + order update commit together
    }
}

Implement a sink:

public sealed class MyKafkaSink : IOutboxSink
{
    public async Task SendAsync(OutboxEnvelope envelope, CancellationToken ct)
    {
        // External publish — keep this the last statement of the implementation so
        // a failure after publish does not silently lose acknowledgement.
        await _producer.ProduceAsync(envelope.MessageType, envelope.Payload, ct);
    }
}

That's it. The dispatcher runs as a hosted service; messages flow from your transaction into the sink.

What ships in v0.1.0

Package Description
OrionPatch Core: IOutbox, IOutboxSink, IOutboxStorage, dispatcher hosted service, telemetry, options. Includes ChannelOutboxSink.
OrionPatch.EntityFrameworkCore EF Core storage backend: OrionPatch_Outbox table, provider-aware claim (SKIP LOCKED for SqlServer/Postgres/MySQL deferred to v0.2; SQLite + unknown providers use a portable compare-and-swap fallback today), SaveChangesInterceptor for transactional enqueue.
OrionPatch.Testing Test helpers: in-memory storage, deterministic dispatcher, capturing sink, test clock, fluent assertions. Zero EF Core dependency.

What v0.1.0 does NOT do

  • No inbox / dedup table (v0.2).
  • No concrete broker sinks (RabbitMQ / Azure Service Bus / Kafka / NATS) — those are opt-in sub-packages on the v0.2+ roadmap.
  • No saga / process manager (that is OrionFlow territory).
  • No distributed transactions across heterogeneous sinks.
  • No push-based dispatch (PostgreSQL LISTEN/NOTIFY, SQL Server Service Broker) — v0.3+ work.

At-least-once contract

OrionPatch guarantees at-least-once delivery. Duplicates occur in two known scenarios:

  1. The sink succeeds but the subsequent CompleteAsync write fails or the process crashes before it runs. The row stays Claimed, the lease expires, another dispatcher re-delivers.
  2. The sink call exceeds OrionPatchOptions.LeaseDuration (default 2 minutes). Another dispatcher may claim and re-deliver the row mid-flight.

Consumer sinks MUST be idempotent. Typical patterns: deduplicate at the destination on OutboxEnvelope.Id, or use upserts. Keep the external publish the last statement of the sink implementation so a failure after publish does not silently lose acknowledgement.

Dead-letter store and archival (v0.3.0)

v0.3.0 adds two outbox maintenance capabilities. Both are SPIs on the storage backend, not separate services: a storage type opts in by implementing the interface, and the dispatcher uses it when present.

Dead-letter store (IDeadLetterStore)

When a row exhausts OrionPatchOptions.MaxAttempts, the dispatcher prefers to route it OUT of the hot outbox into a dedicated dead-letter store instead of flipping it to DeadLettered in place. Routing removes the source row from the active outbox (so it can never be reclaimed or retried) and appends a DeadLetteredMessage snapshot carrying the final failure context: payload, headers, correlation id, enqueue time, total attempt count, final error, and the dead-letter instant.

Routing is idempotent on the row id. A redelivered or crash-replayed terminal-path call for an already-routed row is a no-op, so a message lands in the store exactly once and produces no duplicate metrics or alerts. Storage that does not implement IDeadLetterStore keeps the prior in-place status flip, so this is backward compatible.

This is distinct from the v0.2.18 IDeadLetterSink observer. The sink is a fire-and-forget triage notification (Slack, PagerDuty); the store is the durable destination the message is moved into.

// InMemoryOutboxStorage (and any storage that implements IDeadLetterStore) is detected by the
// dispatcher automatically. To inspect or replay abandoned messages, query the store directly:
if (storage is IDeadLetterStore deadLetterStore)
{
    IReadOnlyList<DeadLetteredMessage> abandoned =
        await deadLetterStore.GetDeadLetteredAsync(ct);

    foreach (var message in abandoned)
    {
        // message.Id, message.MessageType, message.Payload, message.FinalError,
        // message.AttemptCount, message.DeadLetteredAtUtc ...
    }
}

Archival (IOutboxArchivalStore)

Successfully dispatched (Processed) rows accumulate in the hot outbox; an ever-growing table degrades claim-query planning and storage cost. ArchiveProcessedAsync reaps Processed rows whose ProcessedAtUtc is at or before nowUtc - retention out of the active outbox and returns the count moved. Pending, Claimed, and DeadLettered rows are never touched, and a processed row still inside the retention window is never touched. The reap is idempotent and incremental, so it is safe to call on a schedule.

OrionPatchOptions.ArchiveRetention (default 7 days, validated non-negative) expresses the retention horizon. ArchiveProcessedAsync is operator-invoked maintenance: OrionPatch does not start a background reaper, so call it from your own scheduled job (a hosted BackgroundService, Quartz.NET, Hangfire, or a cron-triggered endpoint).

// Run from a scheduled maintenance job, e.g. nightly.
if (storage is IOutboxArchivalStore archivalStore)
{
    int reaped = await archivalStore.ArchiveProcessedAsync(
        options.Value.ArchiveRetention, DateTime.UtcNow, ct);
}

The bundled InMemoryOutboxStorage supports an archive mode (default; reaped rows are observable via GetArchivedAsync) and a purge mode (new InMemoryOutboxStorage(purgeOnArchive: true); reaped rows are discarded).

Telemetry

  • ActivitySource and Meter named Moongazing.OrionPatch.
  • Spans: OrionPatch.Dispatch per envelope, tagged with orionpatch.message.type and orionpatch.attempt.
  • Counters: orionpatch.outbox.enqueued, .dispatched, .failed, .deadlettered, .attempts.
  • Histogram: orionpatch.outbox.dispatch.duration (milliseconds).

Wire them up with the standard OpenTelemetry .NET helpers.

Benchmarks

See benchmarks.md for the scenarios we plan to measure and the current status of the BenchmarkDotNet harness. A formal bench/Moongazing.OrionPatch.Bench project is on the v0.2 roadmap; the dispatcher has only been profiled informally during development so far.

Roadmap

The current release is 0.3.0, which shipped the outbox dead-letter store (IDeadLetterStore) and outbox archival (IOutboxArchivalStore) described above. See the CHANGELOG for the full per-version history.

12-month forward plan in ROADMAP.md. The next milestones:

  • Push-based dispatch (LISTEN/NOTIFY, Service Broker).
  • Operator dashboard, schema-evolution helpers.
  • v1.0.0: API freeze, LTS window.

If something on the list matters to you, open an issue with the roadmap label.

More from the Orion family

OrionPatch is one of several standalone .NET libraries:

  • OrionGuard — input validation, guard clauses, DDD primitives.
  • OrionAudit — EF Core audit trail with JSON Patch diffs and time-travel reconstruction.
  • OrionKey — source-generated strongly-typed IDs.
  • OrionLock — distributed lock primitive with auto-renewing leases.

Each ships separately; none depends on another at runtime.

See it in a real app

Moongazing.OrionShowcase is a production-shaped banking sample integrating all six Orion packages end-to-end. OrionPatch outbox interceptor captures Account/Customer domain events into the same transaction as SaveChanges. DomainEventOutboxAdapter walks AggregateRoot.DomainEvents and enqueues via reflection on IOutbox.Enqueue<T>. Concrete usage:

Contributing

Issues and pull requests welcome. Please read CONTRIBUTING.md and the Code of Conduct before opening one.

License

MIT. See LICENSE.txt.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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
0.4.2 104 7/27/2026
0.4.1 119 6/30/2026
0.4.0 105 6/28/2026
0.3.3 120 6/27/2026
0.3.2 116 6/22/2026
0.3.1 117 6/20/2026
0.3.0 107 6/19/2026
0.2.32 127 6/17/2026
0.2.31 114 6/16/2026
0.2.30 108 6/16/2026
0.2.29 114 6/15/2026
0.2.25 114 6/12/2026
0.2.24 112 6/12/2026
0.2.23 109 6/11/2026
0.2.22 112 6/11/2026
0.2.21 114 6/11/2026
0.2.20 111 6/11/2026
0.2.19 113 6/11/2026
0.2.18 106 6/11/2026
0.2.17 110 6/11/2026
Loading failed