![]() |
VOOZH | about |
dotnet add package ZeroAlloc.Saga.Outbox --version 1.5.0
NuGet\Install-Package ZeroAlloc.Saga.Outbox -Version 1.5.0
<PackageReference Include="ZeroAlloc.Saga.Outbox" Version="1.5.0" />
<PackageVersion Include="ZeroAlloc.Saga.Outbox" Version="1.5.0" />Directory.Packages.props
<PackageReference Include="ZeroAlloc.Saga.Outbox" />Project file
paket add ZeroAlloc.Saga.Outbox --version 1.5.0
#r "nuget: ZeroAlloc.Saga.Outbox, 1.5.0"
#:package ZeroAlloc.Saga.Outbox@1.5.0
#addin nuget:?package=ZeroAlloc.Saga.Outbox&version=1.5.0Install as a Cake Addin
#tool nuget:?package=ZeroAlloc.Saga.Outbox&version=1.5.0Install as a Cake Tool
Source-generated long-running process orchestration for the ZeroAlloc ecosystem.
Status: AOT compatible. The generator-emitted saga handler runs each OCC retry attempt in a fresh
IServiceScope, and theZeroAlloc.Saga.Outboxbridge commits every step command's dispatch row atomically with the saga state save — together they eliminate Saga 1.1's "OCC retry can dispatch twice" caveat for both cross-process races and same-process retries. Durable persistence viaZeroAlloc.Saga.EfCore(single sharedSagaInstancetable, row-version OCC, retry-on-conflict) is unchanged. InMemory remains the default backend; switch to EfCore with one fluent call, and opt into the outbox bridge with.WithOutbox(). See and .
👁 NuGet
👁 Build
👁 AOT
👁 GitHub Sponsors
ZeroAlloc.Saga lets you express multi-step business workflows declaratively
as a partial class. The source generator emits state-machine code,
notification handlers, and dispatch wiring. Compensation runs in reverse on
failure. No reflection, no open-generic resolution at runtime — the whole
runtime is Native AOT-compatible and exercised by an aot-smoke CI job
that publishes a sample with PublishAot=true and asserts end-to-end execution.
[Saga]
public partial class OrderFulfillmentSaga
{
public OrderId OrderId { get; private set; }
public decimal Total { get; private set; }
[CorrelationKey] public OrderId Correlation(OrderPlaced e) => e.OrderId;
[CorrelationKey] public OrderId Correlation(StockReserved e) => e.OrderId;
[CorrelationKey] public OrderId Correlation(PaymentCharged e) => e.OrderId;
[CorrelationKey] public OrderId Correlation(PaymentDeclined e) => e.OrderId;
[Step(Order = 1, Compensate = nameof(CancelReservation))]
public ReserveStockCommand ReserveStock(OrderPlaced e)
{
OrderId = e.OrderId; Total = e.Total;
return new ReserveStockCommand(e.OrderId, e.Total);
}
[Step(Order = 2, Compensate = nameof(RefundPayment), CompensateOn = typeof(PaymentDeclined))]
public ChargeCustomerCommand ChargeCustomer(StockReserved e) => new(OrderId, Total);
[Step(Order = 3)]
public ShipOrderCommand ShipOrder(PaymentCharged e) => new(OrderId);
public CancelReservationCommand CancelReservation() => new(OrderId);
public RefundPaymentCommand RefundPayment() => new(OrderId);
}
Wiring (one line per saga):
// AddSaga() implicitly calls AddMediator() in v1.1 — separate AddMediator()
// call is no longer needed, though it remains harmless (idempotent TryAdd*).
services.AddSaga()
.WithOrderFulfillmentSaga(); // generator-emitted extension
That's it. Publish OrderPlaced via IMediator.Publish and the saga drives
itself: each [Step] runs in correlation-key order, returned commands flow
through IMediator.Send, downstream events advance the FSM, and a terminal
Completed (or Compensated) state removes the saga from the store
automatically.
ZeroAlloc.Saga.Outbox.Redis (new package — closes Phase 3a-2)Redis-native atomic dispatch. Saga state save and outbox-row write commit
together in a single Redis MULTI/EXEC, so a failed save discards both —
matching the Saga.EfCore + Saga.Outbox.EfCore story for Redis-backed
sagas.
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect("..."));
services.AddSaga()
.WithRedisStore()
.WithOutbox()
.WithRedisOutbox() // <-- enlists outbox writes into the saga store's MULTI/EXEC
.WithOrderFulfillmentSaga();
See for the full atomicity
contract, the IRedisSagaTransactionContributor extension point, and the
poller integration.
ZeroAlloc.Saga.Redis (new package)Second durable backend, mirroring Saga.EfCore. One Redis Hash per saga
instance with state (bytes) + version (Guid) fields; OCC via
WATCH/MULTI/EXEC; conflicts surface as RedisSagaConcurrencyException
which the generator-emitted handler's retry loop catches alongside EfCore's
exceptions.
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect("..."));
services.AddSaga()
.WithRedisStore(opts => opts.KeyPrefix = "myapp:saga")
.WithOrderFulfillmentSaga();
Mutually exclusive with WithEfCoreStore<TContext>(). Composition with
WithOutbox() works for the dispatch path but full atomic-commit
guarantees await Stage 3 (ZeroAlloc.Saga.Outbox.Redis). Requires
StackExchange.Redis 2.8+. See .
ISagaUnitOfWork abstraction (Phase 3a-2 stage 1)The dispatch-side outbox enlistment now goes through ISagaUnitOfWork
instead of IOutboxStore directly, opening the door for backend-specific
unit-of-work implementations (Redis MULTI/EXEC, etc.). No behavior change
for existing EfCore + Outbox consumers — the default
OutboxStoreSagaUnitOfWork is a passthrough.
Add{Saga}Saga() → With{Saga}Saga()The generator-emitted per-saga registration method is renamed from
Add{Saga}Saga() to With{Saga}Saga() so it aligns with the rest of
the builder API (WithEfCoreStore, WithOutbox, WithResilience).
The legacy Add-prefixed name still compiles, but emits diagnostic
ZASAGA018 pointing at the new name. The shim will be removed in v2.
// Before:
services.AddSaga()
.WithEfCoreStore<AppDbContext>()
.AddOrderFulfillmentSaga();
// After:
services.AddSaga()
.WithEfCoreStore<AppDbContext>()
.WithOrderFulfillmentSaga();
ZeroAlloc.Saga.Resilience (new package)Optional bridge that wraps every saga step command's dispatch in a
ZeroAlloc.Resilience policy stack — retry, timeout, circuit-breaker,
rate-limit. One fluent call configures the pipeline:
services.AddSaga()
.WithEfCoreStore<AppDbContext>(opts => opts.MaxRetryAttempts = 3)
.WithOrderFulfillmentSaga()
.WithResilience(r =>
{
r.Retry = new RetryPolicy(maxAttempts: 5, backoffMs: 200, jitter: true, perAttemptTimeoutMs: 5_000);
r.CircuitBreaker = new CircuitBreakerPolicy(maxFailures: 10, resetMs: 30_000, halfOpenProbes: 1);
});
Composition order is outermost-first: circuit-breaker → rate-limit → timeout → retry → inner.DispatchAsync. Caller cancellation propagates
as OperationCanceledException; policy denials surface as
ResilienceException(Policy: ...) so consumers can disambiguate.
Requires ZeroAlloc.Resilience 1.0+. See .
ZeroAlloc.Saga.Outbox (new package)Optional bridge that routes every saga step command through
ZeroAlloc.Outbox so dispatch is committed in the same database
transaction as the saga state save. Eliminates the cross-process race
where a state update can succeed without the corresponding command being
delivered.
services.AddSaga()
.WithEfCoreStore<AppDbContext>(opts => opts.MaxRetryAttempts = 3)
.WithOutbox() // <-- one fluent call
.WithOrderFulfillmentSaga();
Requires ZeroAlloc.Outbox 2.4.0+ (introduces
IOutboxStore.EnqueueDeferredAsync) and ZeroAlloc.Serialisation 2.1.0+.
See for the full setup, marker
diagnostics (ZASAGA016/ZASAGA017), and poller knobs.
ZeroAlloc.Saga runtimeISagaCommandDispatcher indirection: step handlers no longer
depend on IMediator directly. Default impl (generator-emitted)
forwards to IMediator.Send; Saga.Outbox's WithOutbox() swaps
it in for transactional dispatch.SagaCommandRegistry generator-emitted in consumer assemblies —
central deserialise+dispatch lookup keyed by typeof(T).FullName,
resolves ISerializer<T> from DI.ZASAGA016 / ZASAGA017 new diagnostics (with code-fix for the
former) nudge step command types toward the partial /
same-assembly shape the auto-[ZeroAllocSerializable] extension
needs.[ZeroAllocSerializable] — when ZeroAlloc.Serialisation
is referenced, the generator applies the attribute to step command
types via a partial extension so consumers don't have to remember.ZeroAlloc.Saga 1.2.0ISagaPersistableState + zero-allocation SagaStateWriter /
SagaStateReader ref structs. Every [Saga] class implements the
interface via a generator-emitted partial; backends use it to round-trip
saga state across process boundaries. Supported state shapes: primitives,
enums, string, DateTime / DateTimeOffset / TimeSpan / Guid,
[TypedId]-attributed types, the common record struct Foo(TPrim Bar)
shape, byte[], and Nullable<T> wrappers thereof.[NotSagaState] escape-hatch attribute — exclude transient or
computed members from generator-emitted Snapshot/Restore.SagaRetryOptions + ISagaStoreRegistrar — backend-agnostic
retry knobs and a typed registrar indirection so durable backends
swap themselves in without MakeGenericType.WithEfCoreStore in the same compilation, the emitted notification
handlers wrap the load → step → save loop in a configurable retry
catching DbUpdateConcurrencyException.ZASAGA014 (Error) — saga state field has an unsupported type.ZASAGA015 (Info, suppressible) — saga commands should be idempotent
under durable backends; fires when WithEfCoreStore / WithRedisStore
is detected in the same compilation.AddMediator() — AddSaga() no longer requires a separate
services.AddMediator() call.ZeroAlloc.Saga.EfCore 1.0.0 (new package)First durable backend for ZeroAlloc.Saga. Single shared SagaInstance
table keyed by (SagaType, CorrelationKey); provider-agnostic row-version
optimistic concurrency; automatic retry-on-conflict driven by
EfCoreSagaStoreOptions. See
for the full
guide.
services.AddDbContext<AppDbContext>(opts => opts.UseSqlServer(connStr));
services.AddSaga()
.WithEfCoreStore<AppDbContext>()
.WithOrderFulfillmentSaga();
Plus, in your DbContext:
protected override void OnModelCreating(ModelBuilder mb) => mb.AddSagas();
[CorrelationKey] rules, multi-saga subscription, composite keysCompensate / CompensateOn, reverse cascade, ISagaManager.CompensateAsyncZeroAlloc.Saga.EfCore: setup, migrations, OCC, idempotency, AOT storyZASAGA0XX diagnostic with examples and fixesdotnet run --project samples/OrderFulfillment/ (InMemory) or dotnet run --project samples/OrderFulfillment/ -- --efcore (EfCore + SQLite).PublishAot=true. Run by the aot-smoke CI job on every push.dotnet add package ZeroAlloc.Saga # runtime + generator (single package)
# Optional — for durable persistence over an EF Core DbContext:
dotnet add package ZeroAlloc.Saga.EfCore
The base ZeroAlloc.Saga package contains both the runtime and the source
generator (bundled as an analyzer asset). No separate .Generator package to
install.
Hard dependencies pulled in transitively:
ZeroAlloc.Mediator — for INotification, IRequest, IMediator.SendMicrosoft.Extensions.DependencyInjection — for AddSaga(), WithXxxSaga()Microsoft.Extensions.Logging.Abstractions — for the saga-handler loggers13 source-generator diagnostics catch authoring mistakes at compile time:
| ID | What | Severity |
|---|---|---|
| ZASAGA001 | [Saga] class not partial |
error (code-fix: Make partial) |
| ZASAGA002 | Saga is static, abstract, generic, or nested | error |
| ZASAGA003 | Saga lacks parameterless ctor | error |
| ZASAGA004 | Step input event missing [CorrelationKey] |
error |
| ZASAGA005 | [CorrelationKey] methods return inconsistent types |
error |
| ZASAGA006 | [CorrelationKey] method has wrong signature |
error |
| ZASAGA007 | [Step(Order = ...)] values have gaps or duplicates |
error (code-fix: Renumber steps) |
| ZASAGA008 | [Step] method has wrong signature |
error |
| ZASAGA009 | [Step.Compensate] target missing or mis-shaped |
error (code-fix: Add compensation method) |
| ZASAGA010 | [Step.CompensateOn] event missing [CorrelationKey] |
error |
| ZASAGA011 | [CorrelationKey] method appears to mutate state |
warning |
| ZASAGA012 | Compensate without CompensateOn — dead code |
warning |
| ZASAGA013 | Two sagas correlate on same event with different key types | warning |
| ZASAGA018 | Add{Saga}Saga() is renamed to With{Saga}Saga() — legacy shim deprecated |
warning (suppressible) |
Every diagnostic links to with a worked example.
ZeroAlloc.Saga.EfCore for durability.ZeroAlloc.Saga.EfCore Native AOT publish — the runtime library
itself is AOT-clean, but a fully PublishAot=true binary is blocked
upstream by EF Core 9.0's experimental AOT story (precompiled queries
don't yet cover the store's tracked Set<>().AsTracking()... shape).
Use the EfCore backend on JITted hosts; stay on InMemory for AOT-published
hosts. See .SagaLockManager grows monotonically — one SemaphoreSlim per unique
correlation key seen, never evicted. Bounded by process lifetime; ~80 bytes
each. Eviction lands in v1.x for high-cardinality workloads.[Step(TimeoutMs = ...)] via Scheduling integration.ZeroAlloc.Saga.Telemetry bridge.| Phase | Package | Adds |
|---|---|---|
| v1.0 | ZeroAlloc.Saga 1.0 |
runtime + generator + InMemory + diagnostics + AOT |
| v1.1 | ZeroAlloc.Saga 1.1, ZeroAlloc.Saga.EfCore 1.0 |
durable persistence (EfCore), retry-on-OCC-conflict, snapshot/rehydrate via ISagaPersistableState |
| this release | ZeroAlloc.Saga, ZeroAlloc.Saga.Outbox (new) |
atomic command dispatch via transactional outbox (.WithOutbox()), ISagaCommandDispatcher indirection |
| this release | ZeroAlloc.Saga.Resilience (new) |
retry / timeout / circuit-breaker / rate-limit policies wrapping step dispatch (.WithResilience()) |
| this release | ZeroAlloc.Saga.Redis (new) |
second durable backend (Redis Hash + WATCH/MULTI/EXEC OCC) |
| v1.4 | (Scheduling integration) | per-step timeouts, deadlines |
| v1.5 | ZeroAlloc.Saga.Telemetry, ZeroAlloc.Saga.Dashboard |
OTel spans/metrics, ops dashboard |
| v1.6 stretch | ZeroAlloc.Saga.EventSourcing |
ES-backed store, choreography mode |
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 net8.0 is compatible. net8.0-android net8.0-android was computed. net8.0-browser net8.0-browser was computed. net8.0-ios net8.0-ios was computed. net8.0-maccatalyst net8.0-maccatalyst was computed. net8.0-macos net8.0-macos was computed. net8.0-tvos net8.0-tvos was computed. net8.0-windows net8.0-windows was computed. net9.0 net9.0 was computed. net9.0-android net9.0-android was computed. net9.0-browser net9.0-browser was computed. net9.0-ios net9.0-ios was computed. net9.0-maccatalyst net9.0-maccatalyst was computed. net9.0-macos net9.0-macos was computed. net9.0-tvos net9.0-tvos was computed. net9.0-windows net9.0-windows was computed. net10.0 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
Showing the top 1 NuGet packages that depend on ZeroAlloc.Saga.Outbox:
| Package | Downloads |
|---|---|
|
ZeroAlloc.Saga.Outbox.Redis
Redis-native outbox storage + transactional unit-of-work for ZeroAlloc.Saga.Outbox + ZeroAlloc.Saga.Redis. Closes the cross-backend atomic-dispatch story: saga state save and outbox row commit together (or roll back together) inside a single Redis MULTI/EXEC. Native AOT compatible. |
This package is not used by any popular GitHub repositories.