OrionGuard.EntityFrameworkCore
7.0.0
dotnet add package OrionGuard.EntityFrameworkCore --version 7.0.0
NuGet\Install-Package OrionGuard.EntityFrameworkCore -Version 7.0.0
<PackageReference Include="OrionGuard.EntityFrameworkCore" Version="7.0.0" />
<PackageVersion Include="OrionGuard.EntityFrameworkCore" Version="7.0.0" />
<PackageReference Include="OrionGuard.EntityFrameworkCore" />
paket add OrionGuard.EntityFrameworkCore --version 7.0.0
#r "nuget: OrionGuard.EntityFrameworkCore, 7.0.0"
#:package OrionGuard.EntityFrameworkCore@7.0.0
#addin nuget:?package=OrionGuard.EntityFrameworkCore&version=7.0.0
#tool nuget:?package=OrionGuard.EntityFrameworkCore&version=7.0.0
OrionGuard.EntityFrameworkCore
Publishes the domain events your aggregates raise, at the moment the data they describe is committed — inline after the save, or through a transactional outbox that survives the process dying.
dotnet add package OrionGuard.EntityFrameworkCore
namespace Shop;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionGuard.DependencyInjection;
using Moongazing.OrionGuard.Domain.Events;
using Moongazing.OrionGuard.Domain.Primitives;
using Moongazing.OrionGuard.EntityFrameworkCore;
using Moongazing.OrionGuard.EntityFrameworkCore.Outbox;
using Moongazing.OrionGuard.EntityFrameworkCore.Outbox.Locking;
public sealed record OrderShipped(Guid OrderId) : DomainEventBase;
public sealed class Order(Guid id) : AggregateRoot<Guid>(id)
{
public void Ship() => RaiseEvent(new OrderShipped(Id));
}
public sealed class OrderShippedHandler : IDomainEventHandler<OrderShipped>
{
public Task HandleAsync(OrderShipped @event, CancellationToken cancellationToken) => Task.CompletedTask;
}
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>().Ignore(o => o.DomainEvents);
modelBuilder.ApplyConfiguration(new OutboxMessageEntityTypeConfiguration("OrionGuard_Outbox"));
modelBuilder.ApplyConfiguration(new OutboxLockEntityTypeConfiguration());
}
}
public static class OutboxSetup
{
public static void Add(IServiceCollection services, string connectionString)
{
// The (sp, options) overload lets the interceptor resolve its collaborators from the
// context's own scope.
services.AddDbContext<AppDbContext>((sp, options) => options
.UseSqlServer(connectionString)
.UseOrionGuardDomainEvents(sp));
services.AddOrionGuardDomainEvents();
services.AddOrionGuardDomainEventHandlers(typeof(OrderShippedHandler).Assembly);
services.AddOrionGuardEfCore<AppDbContext>(o => o.UseOutbox());
}
}
After an EF Core migration for the two tables, order.Ship() followed by SaveChangesAsync() writes one OrionGuard_Outbox row inside the same transaction as the order. A hosted worker picks it up on its next poll, deserializes the event, calls OrderShippedHandler, and stamps the row processed. If the process dies between the commit and the dispatch, the row is still there.
The core OrionGuard package comes along as a dependency; add the EF Core provider for your database yourself.
Inline or Outbox
UseInline() is the default; UseOutbox(...) switches strategy.
| Inline | Outbox | |
|---|---|---|
| When handlers run | Inside SaveChanges/SaveChangesAsync, after the save succeeds; inside your own BeginTransaction, when it commits |
In OutboxDispatcherHostedService, after the transaction commits |
| Where events live | Nowhere — dispatched from memory | One OutboxMessage row per event, written by the same SaveChanges as the aggregate |
| A handler throws | The exception leaves SaveChanges (or the commit), the data is already saved, and the remaining events of that save are dropped |
The row is retried on later polls, then dead-lettered |
| Process dies after the save | Events are lost | Rows stay and are delivered later |
| Delivery | At most once | At least once |
Saving inside your own Database.BeginTransaction() holds Inline events until that transaction commits, and drops them if it rolls back or is disposed without a commit; a handler exception then leaves the commit call. A synchronous SaveChanges() dispatches on a thread-pool thread and blocks until the handlers finish — the same contract as the async path, but prefer SaveChangesAsync() when handlers do I/O. A failed save leaves the events on the aggregates, so a retry picks them up.
AddOrionGuardDomainEvents(o => o.Mode = ...) decides how the handlers of one event are invoked: SequentialFailFast (default), SequentialContinueOnError (runs all, then throws AggregateException), or Parallel.
How outbox delivery works
Each poll the worker takes the distributed lock, reads up to BatchSize unprocessed rows ordered by OccurredOnUtc, then resolves, deserializes (System.Text.Json) and dispatches each one through IDomainEventDispatcher before stamping ProcessedOnUtc — one row at a time. When a full batch drains it polls again immediately, so a backlog is not rate-limited by the polling interval; after a partial, empty or partly-failed batch it waits for the next tick or a wake signal.
- Failure.
RetryCountis incremented andErrorholds the exception text. Retries come on later polls with no backoff beyond the polling interval. AtMaxRetriesthe row is stamped processed and becomes a dead letter,Errorintact. AnOperationCanceledExceptionfrom a handler (anHttpClienttimeout) is an ordinary failure; only host shutdown stops the worker. - Unresolvable rows are dead-lettered on the first attempt, with an
ErrorstartingTYPE_NOT_FOUND,TYPE_NOT_DOMAIN_EVENTorDESERIALIZE_FAILED. - Scope per row. Every row is dispatched in a DI scope of its own, so a handler writing through the scoped
DbContexthas those writes committed in one transaction with the row's processed stamp. A handler that throws has its writes discarded; writes that cannot be saved (a constraint violation) are discarded too and the row is recorded as a failed attempt —Errorstarts "The event was dispatched, but saving ..." — which counts towardsMaxRetries. - Concurrent changes. Every row update, the successful one included, is conditional on the state the row was read in, so a replay or discard made from the dashboard while the handler runs wins: the row stays queued for the replayed delivery and that attempt's handler writes are rolled back rather than applied twice. On a provider without conditional updates (EF Core InMemory) the check and the update are separate steps.
- Failed polls. A fault outside any row — database unreachable, missing table or registration — is logged at Error, counted by
orionguard.outbox.dispatcher.batch_faults, and retried after the polling interval. - Failure observer. Register
IOutboxRowFailureObserverto hear about every failed attempt, transient or terminal. Observer exceptions are logged and ignored.
Outbox options
Set through UseOutbox(o => ...). Setters throw on invalid values.
| Option | Default | Notes |
|---|---|---|
PollingInterval |
5 s | Must be > 0. Also the upper bound on wake latency. |
BatchSize |
100 | Rows read per poll. |
MaxRetries |
5 | Failed attempts before a row is dead-lettered. At least 1. |
LockKey |
orion_guard_outbox_dispatcher |
Distributed lock key. |
LockLeaseDuration |
30 s | Must exceed one batch's wall-clock cost. Not renewed. |
TableName |
OrionGuard_Outbox |
Not applied for you — pass the same name to OutboxMessageEntityTypeConfiguration. |
Schema and migrations
new OutboxMessageEntityTypeConfiguration(tableName, indexFilter = null) maps OutboxMessage and creates IX_OrionGuard_Outbox_Unprocessed: a composite index on (ProcessedOnUtc, OccurredOnUtc) by default, or a filtered index on OccurredOnUtc when you pass a provider-specific filter such as "[ProcessedOnUtc] IS NULL" (SQL Server) or "\"ProcessedOnUtc\" IS NULL" (PostgreSQL) — the filtered form stays small as processed rows pile up.
new OutboxLockEntityTypeConfiguration() maps OrionGuard_OutboxLocks, needed only with the default SkipLockedDistributedLock.
Generate the migration with dotnet ef migrations add as usual; reference DDL per provider is in the outbox locks migration guide.
Distributed lock
Outbox mode registers SkipLockedDistributedLock: one lease row per key, taken with a conditional UPDATE/INSERT in a transaction and confirmed by reading the holder back. A replica that loses skips that poll. Losing means the winner's row is there — any other INSERT failure (a key longer than the column, a constraint, a transient fault) is thrown and logged as a failed poll rather than mistaken for contention. Its SQL takes table, schema and column names from the OutboxLock mapping in your model, so a renamed table or a naming convention is honoured, and quotes them the way your provider does, which PostgreSQL needs for the mixed-case "OrionGuard_OutboxLocks"; values are always parameters. With OutboxLock unmapped or its table missing, the lock logs one warning naming the cause and every poll is skipped, so rows accumulate until the mapping and migration are in place.
UseDistributedLock<NullDistributedLock>()always acquires — for a single instance, with no lock table.UseDistributedLock<TLock>()plugs in anyIDistributedLock; OrionGuard.Locks.Redis is the Redis one.- The archival worker uses the same
IDistributedLockunder its own key.
Type mapping, and why the fallback is a trade-off
By default a row stores the event's assembly-qualified name and resolves it with Type.GetType, so renaming or moving an event type strands older rows. Map stable logical names instead:
namespace Shop;
using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionGuard.EntityFrameworkCore;
public static class TypeMapSetup
{
public static void Add(IServiceCollection services) =>
services.AddOrionGuardEfCore<AppDbContext>(o => o
.UseOutbox()
.UseOutboxTypeMap(
map => map.Map<OrderShipped>("orders.order-shipped.v1"),
typeMap => typeMap.AllowAssemblyQualifiedNameFallback = false));
}
OutboxTypeMapRegistry.Map<TEvent> throws when a name or type is already mapped to something else. AllowAssemblyQualifiedNameFallback defaults to true so rows written before the mapping stay readable — and that is the trade-off: with the fallback on, the event type comes from the row, so anyone who can write to the outbox table (a leaked connection string, another service sharing the database, an injection elsewhere) can have any loadable IDomainEvent type deserialized from a payload of their choosing and dispatched to its handlers. Types that do not implement IDomainEvent are rejected before deserialization, which rules out general deserialization gadgets but not forged events. The worker logs a warning the first time it resolves a row this way.
To turn it off: map every event type; wait until no unprocessed row still carries an assembly-qualified name (or rewrite those rows' EventType); then set AllowAssemblyQualifiedNameFallback = false. A row naming an unmapped type is then dead-lettered with TYPE_NOT_FOUND.
Waking the dispatcher
The worker waits on an IOutboxWakeSignal. The default NullOutboxWakeSignal only polls. In Outbox mode SaveChanges/SaveChangesAsync call SignalAsync after the save, so registering ChannelOutboxWakeSignal wakes a dispatcher in the same process at once (a synchronous save does not wait for a signal that completes asynchronously). Across processes, use OrionGuard.Outbox.PostgresNotify or OrionGuard.Outbox.SqlServerBroker. PollingInterval stays the upper bound either way.
Archival
Processed rows are kept forever unless you opt in:
namespace Shop;
using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionGuard.EntityFrameworkCore;
public static class ArchivalSetup
{
public static void Add(IServiceCollection services) =>
services.AddOrionGuardEfCore<AppDbContext>(o => o
.UseOutbox()
.UseOutboxArchival(a => a.RetentionPeriod = TimeSpan.FromDays(7)));
}
OutboxArchivalOptions |
Default | Notes |
|---|---|---|
RetentionPeriod |
30 days | Rows processed longer ago than this are archived. |
PollingInterval |
1 hour | The wait after a partial or empty batch; a full batch is followed immediately. |
BatchSize |
1000 | Rows per batch. |
PreserveDeadLetters |
true |
Rows with Error set are never archived. |
LockKey / LockLeaseDuration |
orion_guard_outbox_archival / 5 min |
OutboxArchivalHostedService deletes with DeleteOutboxArchiver unless you register an IOutboxArchiver. CopyToTableOutboxArchiver<TArchiveRow> copies into an archive entity mapped in your context and deletes in one transaction, run through the context's execution strategy so it works under EnableRetryOnFailure. BlobOutboxArchiver writes each batch as JSON Lines to an IOutboxArchiveSink and then deletes — if the delete fails the batch is written again next time. Sinks: LocalFileOutboxArchiveSink, RotatingFileOutboxArchiveSink, RetryingOutboxArchiveSink, CompositeOutboxArchiveSink.
The package's one health check watches archival:
namespace Shop;
using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionGuard.EntityFrameworkCore.Outbox.Archival;
public static class ArchivalHealth
{
public static void Add(IServiceCollection services) =>
services.AddHealthChecks().AddCheck<OutboxArchivalHealthCheck>("outbox-archival");
}
It reports how long ago this process last completed an archival batch: Degraded after two archival polling intervals, Unhealthy after three, never below 5 and 15 minutes. Set DegradedAfter or UnhealthyAfter on a registered OutboxArchivalHealthCheckOptions to pin a threshold; an explicit value always wins.
Observability
- Tracing. Each row stores the W3C
traceparent/tracestateofActivity.Currentat save time, and the worker starts anOutbox.Dispatchactivity (kindConsumer) on theMoongazing.OrionGuard.DomainEventssource with that parent, so handlers run inside the original trace. Subscribe withAddSource("Moongazing.OrionGuard.DomainEvents"). Trace data never fails a save: a non-W3C (hierarchical) activity id stores nothing, and an over-longtracestatekeeps the leading list members that fit. - Metrics. Meters
Moongazing.OrionGuard.Outbox.DispatcherandMoongazing.OrionGuard.Outbox.Archivalcover queue lag, batch size, idle polls, errors, failed polls, dead letters, lock contention, retries before success, dispatch duration, payload size, rows enqueued per save, and archival batch size, duration, failures and bytes written. - Time. The dispatcher, the archival worker and
SkipLockedDistributedLockread the clock from aTimeProviderin DI when one is registered, andTimeProvider.Systemotherwise — which is what makes their timing testable.
What this does not do
- Outbox delivery is at least once, never exactly once. A retry re-runs every handler of the event, including ones that already succeeded; a crash after a successful dispatch, or a batch outliving
LockLeaseDurationso another replica takes the lock, re-delivers too. Handlers must be idempotent. - Order is not guaranteed. A failing row does not block the rows behind it, so once retries start, events are handled out of the order they were raised.
- Inline mode cannot see a transaction EF Core does not own. Inside an ambient
TransactionScope, or a transaction handed in withDatabase.UseTransaction(), events are dispatched right after the save — before the commit — and one warning is logged per process. Use Outbox mode, orDatabase.BeginTransaction(), when handlers must not see uncommitted work. - It is not a message bus. Events go to in-process
IDomainEventHandler<T>implementations. Getting them onto a broker is a handler's job — see OrionGuard.MassTransit. - Nothing dispatches without a running worker. Outbox mode writes rows whether or not
OutboxDispatcherHostedServiceis running; on a host with no background services, or a replica that never wins the lock, rows simply accumulate. TableNameis only half the rename. It tells the worker where to look; the mapping still has to be given the same name.- Not NativeAOT- or trimming-safe.
ServiceProviderDomainEventDispatcherandOutboxDispatcherHostedServiceare marked[RequiresUnreferencedCode]and[RequiresDynamicCode]: handlers are resolved withMakeGenericType, and rows are read back withType.GetTypeandJsonSerializer.Deserialize(string, Type). The outbox uses the defaultJsonSerializerOptionswith no hook for aJsonSerializerContext, so it depends on reflection-based serialization, which trimmed and NativeAOT apps turn off. - Archival health is per process. A replica that never wins the archival lock reports Degraded — that is the check working, not a fault.
Targets
net8.0, net9.0, net10.0. EF Core 10 on net10.0 (EF Core 10 supports net10.0 only) and EF Core 9 on net8.0/net9.0; use a provider package from the same EF Core major.
With the rest of OrionGuard
OrionGuard · OrionGuard.Locks.Redis · OrionGuard.Outbox.Dashboard · OrionGuard.Outbox.PostgresNotify · OrionGuard.Outbox.SqlServerBroker · OrionGuard.Testing · OrionGuard.OpenTelemetry
Documentation
License
MIT. See LICENSE.txt.
| Product | Versions 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. |
-
net10.0
- Microsoft.EntityFrameworkCore (>= 10.0.12)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.12)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.12)
- OrionGuard (>= 7.0.0)
-
net8.0
- Microsoft.EntityFrameworkCore (>= 9.0.20)
- Microsoft.EntityFrameworkCore.Relational (>= 9.0.20)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.12)
- OrionGuard (>= 7.0.0)
-
net9.0
- Microsoft.EntityFrameworkCore (>= 9.0.20)
- Microsoft.EntityFrameworkCore.Relational (>= 9.0.20)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.12)
- OrionGuard (>= 7.0.0)
NuGet packages (4)
Showing the top 4 NuGet packages that depend on OrionGuard.EntityFrameworkCore:
| Package | Downloads |
|---|---|
|
OrionGuard.Outbox.PostgresNotify
Postgres LISTEN/NOTIFY backed IOutboxWakeSignal for OrionGuard.EntityFrameworkCore. Replaces the v6.5.1 polling fallback with an event-driven wake on every committed outbox row when the consumer's database is PostgreSQL. Falls back to polling cleanly when the LISTEN connection is unreachable. |
|
|
OrionGuard.Locks.Redis
Redis-backed IDistributedLock for OrionGuard.EntityFrameworkCore's outbox dispatcher. Bridges OrionGuard's IDistributedLock contract to the OrionLock.Redis backend so multi-instance outbox workers can share a Redis-coordinated lease instead of the default SkipLocked DB lock. |
|
|
OrionGuard.Outbox.SqlServerBroker
SQL Server Service Broker backed IOutboxWakeSignal for OrionGuard.EntityFrameworkCore. Sibling to OrionGuard.Outbox.PostgresNotify (v6.5.2) for the SQL Server provider. Replaces the v6.5.1 polling-only fallback with an event-driven wake on every committed outbox row via Service Broker's queueing primitives. |
|
|
OrionGuard.Outbox.Dashboard
Outbox operator dashboard for OrionGuard. Maps an authorized listing of failed / poisoned messages from the consumer's DbContext to an ASP.NET Core endpoint group, plus POST /{id}/replay and POST /{id}/discard mutation endpoints (v6.5.5) with an optional OnMutation audit hook. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 7.0.0 | 144 | 9/20/2026 |
| 6.7.0 | 259 | 7/20/2026 |
| 6.6.2 | 229 | 6/20/2026 |
| 6.6.1 | 203 | 6/20/2026 |
| 6.6.0 | 210 | 6/19/2026 |
| 6.5.30 | 217 | 6/17/2026 |
| 6.5.29 | 207 | 6/15/2026 |
| 6.5.28 | 207 | 6/15/2026 |
| 6.5.27 | 200 | 6/15/2026 |
| 6.5.26 | 208 | 6/13/2026 |
| 6.5.25 | 202 | 6/13/2026 |
| 6.5.24 | 188 | 6/12/2026 |
| 6.5.23 | 206 | 6/12/2026 |
| 6.5.22 | 201 | 6/11/2026 |
| 6.5.21 | 194 | 6/11/2026 |
| 6.5.20 | 194 | 6/11/2026 |
| 6.5.19 | 194 | 6/11/2026 |
| 6.5.18 | 192 | 6/11/2026 |
| 6.5.16 | 210 | 6/11/2026 |
| 6.5.15 | 206 | 6/11/2026 |