| layout | default |
|---|---|
| title | v1.1.0 |
| description | Release notes for Neatoo RemoteFactory v1.1.0 |
| parent | Release Notes |
| nav_order | 4 |
Release Date: 2026-04-10
NuGet: Neatoo.RemoteFactory 1.1.0
Breaking changes: Yes — [FactoryEventHandler<T>] execution model changed.
[FactoryEventHandler<T>] + IFactoryEvents.Raise is now a transactional mediator for domain events. Handlers share the caller's DI scope, run sequentially, and let exceptions propagate — so a handler can participate in the factory's DbContext/transaction and a handler failure rolls the whole operation back.
This is a deliberate 180 from the v1.0.0 execution model, where handlers ran detached in isolated scopes under Task.Run. The v1.0.0 behavior had the wrong defaults for a DDD-biased library: factory events are for domain events that commit alongside the aggregate, not fire-and-forget work. If you need fire-and-forget semantics (notifications, emails, webhooks, queue publishes), use the [Event] delegate method attribute — that path is unchanged and still purpose-built for detached work.
v1.0.0 shipped ~24 hours before this release. Adoption window was short; I'd rather fix the defaults now than live with them.
Every IFactoryEvents.Raise<T>() call now obeys three invariants:
- Shared scope. Every handler resolves
[Service]dependencies from the caller'sIServiceProvider. ADbContextinjected into the factory method and aDbContextinjected into the handler are the same instance. - Sequential. Handlers run one after another in unspecified order. A
DbContextis not thread-safe, so parallel dispatch is not possible. Callers must not rely on a specific ordering. - Awaited.
Raise<T>()returns only after every handler has completed. A handler exception aborts the remaining handlers and propagates to the caller so the transaction can roll back. Across the client/server boundary the HTTP call stays open until all server-side handlers finish.
[Remote, Create]
internal async Task Create(
int id,
decimal total,
[Service] AppDbContext db,
[Service] IFactoryEvents events,
CancellationToken ct)
{
Id = id; Total = total;
db.Orders.Add(new OrderEntity(id, total));
await db.SaveChangesAsync(ct);
// RecordInLedger below runs here, in THIS scope, sharing `db`.
// A throwing handler aborts this method and rolls back everything.
await events.Raise(new OrderCheckoutCompleted(id, total), RaiseOptions.None, ct);
}
[FactoryEventHandler<OrderCheckoutCompleted>]
public static partial class OrderCheckoutJournal
{
internal static async Task RecordInLedger(
OrderCheckoutCompleted evt,
[Service] AppDbContext db, // SAME DbContext the factory uses
CancellationToken ct) // caller's CancellationToken
{
db.LedgerEntries.Add(new LedgerEntry(evt.OrderId, evt.Total));
await db.SaveChangesAsync(ct);
}
}Task Raise<T>(
T factoryEvent,
RaiseOptions options = RaiseOptions.None,
CancellationToken cancellationToken = default)
where T : FactoryEventBase;Pass the factory method's CancellationToken through so handlers that declare a CancellationToken parameter observe the caller's cancellation. Across the wire, the client's CT flows into the HTTP call and into the server's handler dispatch.
For client-to-server raises (NeatooFactory.Remote), the HTTP call now stays open until every server-side handler has completed. A server handler exception is serialized back and rethrows on the client. This replaces the v1.0.0 behavior where the client only awaited HTTP acknowledgment.
Repeated BuildServiceProvider() calls in the same process (as happens in integration test suites) no longer accumulate duplicate handler registrations. The registry dedupes by (event type, handler class type).
Awaiting server-side handlers over the wire is now the default and only behavior. Remove the flag from any call sites:
// v1.0.0
await events.Raise(evt, RaiseOptions.AwaitRemote);
// v1.1.0
await events.Raise(evt);Transactional handlers must fail fast so the transaction can roll back. There is no supported way to "continue after a handler throws" for [FactoryEventHandler<T>]. If you need fire-and-forget dispatch with failure tolerance, use [Event] delegate methods — they run in isolated scopes and their exceptions are swallowed.
// v1.0.0
await events.Raise(evt, RaiseOptions.ServerOnly | RaiseOptions.ContinueOnFail);
// v1.1.0 — drop ContinueOnFail; if this work must not block the factory, move
// it into an [Event] delegate method instead
await events.Raise(evt, RaiseOptions.ServerOnly);[FactoryEventHandler<T>] static-method handlers no longer get a new IServiceScope per invocation. They resolve [Service] dependencies from the caller's IServiceProvider. If you have a handler that assumed a fresh scope per call (e.g., it resolved a scoped service expecting a clean slate), adjust the handler or move the work to an [Event] delegate method.
Concretely:
- Handler exceptions now propagate to the caller (previously swallowed/logged).
- Handlers see the caller's
DbContext, correlation context, tenant context — automatically, noIEventScopeInitializerneeded for this path. IEventTrackeris no longer involved in[FactoryEventHandler<T>]dispatch. It remains wired up for[Event]delegate methods, which are still the fire-and-forget path.
// v1.0.0
Task ForDelegateEvent(Type delegateType, object?[]? parameters);
// v1.1.0
Task ForDelegateEvent(Type delegateType, object?[]? parameters, CancellationToken cancellationToken);A backward-compatible default-interface overload is provided, so most call sites (inside RemoteFactory itself) keep working. Custom IMakeRemoteDelegateRequest implementations (test stand-ins) must add the CT parameter.
// v1.0.0
public delegate Task RaiseFactoryEventRemote(FactoryEventBase factoryEvent, int options);
// v1.1.0
public delegate Task RaiseFactoryEventRemote(FactoryEventBase factoryEvent, int options, CancellationToken cancellationToken);This is the internal delegate the server uses to receive remote Raise requests. It is picked up by generated code; user code does not normally reference it.
// v1.0.0
public static void RegisterHandler<TEvent>(
Func<IServiceProvider, object, RaiseOptions, Task> handlerFactory);
// v1.1.0
public static void RegisterHandler<TEvent>(
Type handlerClassType,
Func<IServiceProvider, object, RaiseOptions, CancellationToken, Task> handlerFactory);Called by generated code only. Custom call sites must supply the handler class type (for deduplication) and a CT-accepting delegate.
Most codebases will need only minimal edits:
- Remove
AwaitRemoteandContinueOnFailfrom anyRaisecall sites. - Audit handlers for assumptions about isolated scope. If a handler expected a fresh scoped service per invocation, think about whether sharing the caller's scope changes the behavior. Common cases:
DbContext— good news, handler writes now participate in the factory's transaction.ICorrelationContext— shared automatically, no more manual propagation.- Per-invocation temporary state — uncommon; if present, store it on a local instead of a scoped service.
- If a handler was doing fire-and-forget work (email, webhook, queue, external audit sink), move it to an
[Event]delegate method. That path is unchanged and is the right execution model for detached work. - Thread the caller's
CancellationTokenthroughRaise. The overload withCancellationToken = defaultkeeps existing call sites compiling, but you probably want to pass the real CT so handlers observe cancellation.
// Before
await events.Raise(new OrderCheckoutCompleted(id, total));
// After — pass CT explicitly
await events.Raise(new OrderCheckoutCompleted(id, total), RaiseOptions.None, ct);- Update custom
IMakeRemoteDelegateRequestimplementations (usually only in integration tests) to addCancellationTokentoForDelegateEvent.
FactoryEventHandlerRegistryduplicate handlers acrossBuildServiceProvidercalls. A latent bug in v1.0.0: callingBuildServiceProvidermultiple times (common in integration test runs) accumulated duplicate handler registrations in the static registry. Now deduplicated by(event type, handler class type).
Why flip the defaults? RemoteFactory is a DDD-biased library. FactoryEvent is named with Factory on the front specifically because it's about data: events raised inside factory methods to model domain events that commit alongside the aggregate. For that use case, detached fire-and-forget is the wrong default — it means handlers can't touch the same DbContext and can't enforce cross-aggregate invariants.
Why not support both models under a flag? Two execution models sharing one API would be confusing. Naming them separately makes the choice loud at the call site: if you see IFactoryEvents.Raise you know you're in a transaction; if you see an [Event] delegate invocation you know you're fire-and-forget.
Why now, less than a day after v1.0.0? v1.0.0 shipped the wrong defaults for the library's intent. Adoption window is small enough that flipping the semantics in a fast-follow is cheaper than living with it.
feat!: [FactoryEventHandler<T>] runs in caller's scope, sequentially, awaited
fix: deduplicate FactoryEventHandlerRegistry across BuildServiceProvider calls
- Factory Events — full pattern documentation
- Events —
[Event]delegate methods (the fire-and-forget path) - Interfaces Reference — updated
IFactoryEventssignature