Skip to content

Latest commit

 

History

History
221 lines (153 loc) · 10.5 KB

File metadata and controls

221 lines (153 loc) · 10.5 KB
layout default
title v1.1.0
description Release notes for Neatoo RemoteFactory v1.1.0
parent Release Notes
nav_order 4

v1.1.0 — Transactional Factory Events

Release Date: 2026-04-10 NuGet: Neatoo.RemoteFactory 1.1.0 Breaking changes: Yes — [FactoryEventHandler<T>] execution model changed.


Overview

[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.


What's New

Transactional [FactoryEventHandler<T>] dispatch

Every IFactoryEvents.Raise<T>() call now obeys three invariants:

  1. Shared scope. Every handler resolves [Service] dependencies from the caller's IServiceProvider. A DbContext injected into the factory method and a DbContext injected into the handler are the same instance.
  2. Sequential. Handlers run one after another in unspecified order. A DbContext is not thread-safe, so parallel dispatch is not possible. Callers must not rely on a specific ordering.
  3. 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);
    }
}

IFactoryEvents.Raise<T> now takes a CancellationToken

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.

Remote raise no longer fire-and-forget

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.

FactoryEventHandlerRegistry deduplicates registrations

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).


Breaking Changes

RaiseOptions.AwaitRemote removed

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);

RaiseOptions.ContinueOnFail removed

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);

Handler scope is now the caller's scope

[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, no IEventScopeInitializer needed for this path.
  • IEventTracker is no longer involved in [FactoryEventHandler<T>] dispatch. It remains wired up for [Event] delegate methods, which are still the fire-and-forget path.

IMakeRemoteDelegateRequest.ForDelegateEvent signature

// 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.

RaiseFactoryEventRemote delegate signature

// 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.

FactoryEventHandlerRegistry.RegisterHandler signature

// 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.


Migration Guide

Most codebases will need only minimal edits:

  1. Remove AwaitRemote and ContinueOnFail from any Raise call sites.
  2. 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:
    • DbContextgood 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.
  3. 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.
  4. Thread the caller's CancellationToken through Raise. The overload with CancellationToken = default keeps 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);
  1. Update custom IMakeRemoteDelegateRequest implementations (usually only in integration tests) to add CancellationToken to ForDelegateEvent.

Bug Fixes

  • FactoryEventHandlerRegistry duplicate handlers across BuildServiceProvider calls. A latent bug in v1.0.0: calling BuildServiceProvider multiple times (common in integration test runs) accumulated duplicate handler registrations in the static registry. Now deduplicated by (event type, handler class type).

Design Decision Rationale

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.


Commits

feat!: [FactoryEventHandler<T>] runs in caller's scope, sequentially, awaited
fix: deduplicate FactoryEventHandlerRegistry across BuildServiceProvider calls

Links