| layout | default |
|---|---|
| title | v1.4.0 |
| description | Release notes for Neatoo RemoteFactory v1.4.0 |
| parent | Release Notes |
| nav_order | 2 |
Release Date: 2026-04-14
NuGet: Neatoo.RemoteFactory 1.4.0
Breaking changes: Yes — IFactoryEventRelay surface replaced, [FactoryEventHandler<T>] instance-method handlers no longer generated. See Migration Guide below.
Pre-stable API note. v1.4.0 is an intentional minor bump carrying breaking changes. The 1.x line is still stabilizing the factory-event and relay surfaces; breaking changes remain permitted under minor bumps until the surface settles. A major bump will be reserved for stability commitments.
Client-side factory event delivery is redesigned around a single integration hook: IFactoryEventRelay.Relay(IReadOnlyList<FactoryEventBase> events). The prior Register/Unregister weak-ref machinery, the FactoryEventRelayRegistry static, the internal FactoryEventRelayDispatcher, and the source-generated instance-method [FactoryEventHandler<T>] client-relay emission path are all removed. A NoOpFactoryEventRelay is registered automatically in NeatooFactory.Remote mode so consumers who do not need events can ignore the hook entirely.
This release also fixes a timing bug: in v1.3.0 and earlier, _ = _relay.DispatchRelayedEvents(...) ran the relay's synchronous prologue before return deserialized; executed, so handlers invoked during _entity = await factory.Create(...) could observe the caller's pre-assignment state. v1.4.0 guarantees Relay is invoked strictly after the factory method's return value reaches the caller's continuation.
IL trimming preservation for event records moves from per-handler generator codegen to a single [DynamicallyAccessedMembers] annotation on FactoryEventBase with Inherited = true. Every descendant is preserved by base-class inheritance — no per-event registration is required.
Consumers implement one method. RemoteFactory invokes it once per [Remote] factory call (even when the batch is empty), fire-and-forget, strictly after the caller's continuation resumes. Exceptions thrown from Relay are caught and logged, not propagated.
public interface IFactoryEventRelay
{
// Called once per [Remote] factory call, after the caller's continuation resumes.
// Exceptions are caught and logged; they do not propagate to the factory caller.
// Consumer owns threading / sync-context marshaling inside this method.
Task Relay(IReadOnlyList<FactoryEventBase> events);
}A minimal bridge to a consumer's event aggregator:
public sealed class MyEventAggregatorRelay : IFactoryEventRelay
{
private readonly IEventAggregator _aggregator;
public MyEventAggregatorRelay(IEventAggregator aggregator) => _aggregator = aggregator;
public Task Relay(IReadOnlyList<FactoryEventBase> events)
{
foreach (var evt in events)
{
_aggregator.Publish(evt); // dispatch per your aggregator's contract
}
return Task.CompletedTask;
}
}RemoteFactory does not ship a MediatR / event-aggregator bridge. The bridge is consumer-owned — a deliberate narrowing of the library's surface.
AddNeatooRemoteFactory(NeatooFactory.Remote) registers NoOpFactoryEventRelay via TryAdd. Consumers that do not care about relayed events need do nothing. Registering a custom IFactoryEventRelay — before or after AddNeatooRemoteFactory — replaces the no-op via standard DI override semantics.
IFactoryEventRelay is NOT registered in NeatooFactory.Server or NeatooFactory.Logical modes (server never relays to itself; Logical has no serialization boundary and dispatches via the handler registry directly).
IFactoryEventRelay.Relay is now guaranteed to be invoked strictly after the factory method's return value has reached the caller. The dispatch site uses Task.Run + await Task.Yield() to push relay work behind the caller's continuation, fixing this class of bug:
// Before v1.4.0: Relay could run (at least its sync prologue) before this assignment completed.
_entity = await factory.Create(...);
// After v1.4.0: Relay runs strictly after _entity has been assigned and the continuation has resumed.One [Remote] call = exactly one Relay invocation. Even when no events were raised, Relay is called with an empty IReadOnlyList<FactoryEventBase>. Consumers can use the empty-batch invocation as a signal that a factory call just returned — useful for batch-end bookkeeping or UI refresh triggers.
FactoryEventAttribute is applied once to FactoryEventBase. Via Inherited = true, every descendant is discoverable at runtime through GetCustomAttribute<FactoryEventAttribute>(inherit: true). Consumers inherit from FactoryEventBase as before — no opt-in per event type.
FactoryEventBase now carries [DynamicallyAccessedMembers(PublicConstructors | PublicProperties)] with Inherited = true. Every descendant's constructors and properties survive PublishTrimmed=true without generator involvement. This supersedes the v1.2.0 per-handler DtoConstructorRegistry.PreserveType<T>() emission path for event records — one annotation on the base, instead of one registration call per handler.
A PublishTrimmed smoke test in RemoteFactory.TrimmingTests verifies the round-trip on a trimmed build.
Internal, runtime-populated, lazily scans AppDomain.CurrentDomain.GetAssemblies() on first use for non-abstract FactoryEventBase descendants and caches TypeFullName → Type. No source generation, no consumer configuration. On a TypeFullName miss the scan re-runs once (to pick up dynamically-loaded assemblies) before the deserializer throws UnknownFactoryEventTypeException.
Thrown by FactoryEventDeserializer when a relayed event's TypeFullName is not loaded on the client. Caught by the relay dispatch isolation and logged; Relay is not invoked for that call, and the factory caller's await is unaffected.
| EventId | Name | Emitted when |
|---|---|---|
| 3008 | FactoryEventRelayFailed |
IFactoryEventRelay.Relay throws |
| 3009 | FactoryEventDeserializationFailed |
UnknownFactoryEventTypeException during batch deserialization |
| 3011 | NoOpFactoryEventRelayFirstEvent |
First event arrives at the no-op default (hint to the consumer that they likely meant to register a real relay) |
| 3012 | FactoryEventTypeRegistryCollision |
Two loaded assemblies expose a FactoryEventBase descendant with the same TypeFullName |
Instance-method [FactoryEventHandler<T>] handlers — the former client-relay shape — are no longer generated. The generator emits NF0503 as a Warning on the instance method, guiding the consumer to either make the method static (server handler, unchanged behavior) or implement IFactoryEventRelay (client receiver). This replaces the silent-skip behavior with a compile-time signal without breaking existing builds.
Both methods are gone. Replaced by Task Relay(IReadOnlyList<FactoryEventBase> events). Any consumer code calling _relay.Register(this) or _relay.Unregister(this) is now a compile error.
The public static FactoryEventRelayRegistry is deleted. Its runtime role is taken by an internal FactoryEventTypeRegistry; there is no public replacement because consumers no longer register event types — inheriting FactoryEventBase is sufficient.
Internal dispatcher type is deleted. Nothing to migrate — it was never part of the documented surface.
Classes decorated with [FactoryEventHandler<T>] whose handler is an instance method no longer produce any generated code. The generator emits NF0503 (Warning) pointing at the instance method. The handler is never invoked at runtime. This is the silent-failure footgun to watch for during migration: handlers that used to fire no longer fire, and only the NF0503 warning flags it.
Server-side static-method [FactoryEventHandler<T>] handlers are unchanged — dispatched via FactoryEventHandlerRegistry during server-side factory execution exactly as before.
Before (v1.3.0 and earlier):
[FactoryEventHandler<OrderCheckoutCompleted>]
[FactoryEventHandler<OrderShipped>]
public class OrderEventsClientHandler
{
private readonly IFactoryEventRelay _relay;
public OrderEventsClientHandler(IFactoryEventRelay relay)
{
_relay = relay;
_relay.Register(this); // removed in v1.4.0
}
public void Dispose() => _relay.Unregister(this); // removed in v1.4.0
internal Task Handle(OrderCheckoutCompleted evt) { /* ... */ return Task.CompletedTask; }
internal Task Handle(OrderShipped evt) { /* ... */ return Task.CompletedTask; }
}After (v1.4.0):
public sealed class OrderEventsClientRelay : IFactoryEventRelay
{
public Task Relay(IReadOnlyList<FactoryEventBase> events)
{
foreach (var evt in events)
{
switch (evt)
{
case OrderCheckoutCompleted completed: Handle(completed); break;
case OrderShipped shipped: Handle(shipped); break;
}
}
return Task.CompletedTask;
}
private void Handle(OrderCheckoutCompleted evt) { /* ... */ }
private void Handle(OrderShipped evt) { /* ... */ }
}Notes:
- No
Register/Unregistercalls. Lifetime is managed by the DI container. - Type switching replaces attribute-directed dispatch. If the number of event types is large, consider delegating to your event aggregator and letting it route.
- Server-side static-method
[FactoryEventHandler<T>]is unaffected. Only the client-receiver shape changes.
// Consumer wins regardless of order — AddNeatooRemoteFactory uses TryAdd for the no-op default.
services.AddSingleton<IFactoryEventRelay, OrderEventsClientRelay>();
services.AddNeatooRemoteFactory(NeatooFactory.Remote);
// Or after — standard DI override semantics apply:
services.AddNeatooRemoteFactory(NeatooFactory.Remote);
services.AddSingleton<IFactoryEventRelay, OrderEventsClientRelay>();If your app does not consume relayed events at all, register nothing — NoOpFactoryEventRelay is resolved automatically.
RemoteFactory does not ship a bridge. Wire one yourself inside your IFactoryEventRelay implementation:
public sealed class MediatrFactoryEventRelay : IFactoryEventRelay
{
private readonly IMediator _mediator;
public MediatrFactoryEventRelay(IMediator mediator) => _mediator = mediator;
public async Task Relay(IReadOnlyList<FactoryEventBase> events)
{
foreach (var evt in events)
{
await _mediator.Publish(evt);
}
}
}If any caller code had worked around the v1.3.0 timing bug by deferring event-dependent reads with Task.Yield() or Task.Delay(1), those workarounds can be removed — the ordering guarantee is now part of the framework.
// Before — workaround for pre-v1.4.0 timing bug
_entity = await factory.Create(...);
await Task.Yield(); // no longer needed
UseEntity(_entity);
// After
_entity = await factory.Create(...);
UseEntity(_entity); // Relay runs strictly after this pointRelay is called once per [Remote] call, even when no events were raised. Consumers that care only about non-empty batches can short-circuit:
public Task Relay(IReadOnlyList<FactoryEventBase> events)
{
if (events.Count == 0) return Task.CompletedTask;
// ... normal dispatch
}Consumers that want a "factory call just returned" signal can use the empty batch as the trigger.
Any docs or helper code that referenced IFactoryEventRelay.Register(handler), FactoryEventRelayRegistry, or instance-method [FactoryEventHandler<T>] client handlers is now stale. See the updated Factory Events, Events, Interfaces Reference, Attributes Reference, and IL Trimming pages.
Prior to v1.4.0, MakeRemoteDelegateRequest.ForDelegate invoked the relay via _ = _relay.DispatchRelayedEvents(result.RelayedEvents, ...) before executing return deserialized;. Because DispatchRelayedEvents was an async method, its synchronous prologue — and potentially the handlers themselves, up to the first real await — ran before the caller's continuation resumed. A caller writing _entity = await factory.Create(...) could therefore have a handler dispatch and read _entity while it was still null.
v1.4.0's dispatch site uses Task.Run + await Task.Yield() to ensure the relay queues behind the caller's continuation in any host (Blazor sync context, ASP.NET sync context, console/ThreadPool with no sync context). The new RelayTimingTests integration test fails against the old code and passes against the new code. See plan Factory Events Client Relay Redesign rules 6 and 7.
feat!Factory event relay redesign —IFactoryEventRelay.Relay(IReadOnlyList<FactoryEventBase>)replacesRegister/Unregisterfeat!Remove instance-method[FactoryEventHandler<T>]client-relay generator pathfeatNoOpFactoryEventRelaydefault inNeatooFactory.Remotemode (TryAdd semantics)featFactoryEventAttribute+[DynamicallyAccessedMembers]onFactoryEventBasewithInherited = truefeatRuntimeFactoryEventTypeRegistry(internal) — runtime assembly scan, no codegenfeatUnknownFactoryEventTypeException+FactoryEventDeserializerfeatNF0503 diagnostic (Warning) for ignored instance-method handlersfeatLog events 3008, 3009, 3011, 3012 for relay failure pathsfixPost-return ordering —Relayinvoked strictly after caller's continuation resumestestRelayTimingTests(integration) +FactoryEventTypeRegistryTests/FactoryEventDeserializerTests(unit) +PublishTrimmedsmoke test
See docs/todos/completed/factory-events-relay-redesign.md for full plan + verification details.