Skip to content

fix(#569): bind IEvent<T> natural key sources, stop fabricating aggregates, fail loudly - #571

Merged
jeremydmiller merged 1 commit into
mainfrom
fix/569-natural-key-source-discovery
Jul 26, 2026
Merged

fix(#569): bind IEvent<T> natural key sources, stop fabricating aggregates, fail loudly#571
jeremydmiller merged 1 commit into
mainfrom
fix/569-natural-key-source-discovery

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Jul 26, 2026

Copy link
Copy Markdown
Member

Closes #569. Fixes the upstream half of JasperFx/marten#5041.

Important

Breaking for both downstream event stores. NaturalKeyEventMapping.Extractor is now Func<IEvent, object?> instead of Func<object, object?>. #569 was written believing Marten was the only consumer; Polecat maintains its own pc_natural_key_X lookup table off the same NaturalKeyDefinition. Every call site already holds the real IEvent, so it is a one-line change in each:

  • MartenNaturalKeyProjection.ApplyAsync and NaturalKeyProjection.QueueUpsertsForEvents: mapping.Extractor(@event.Data)mapping.Extractor(@event). Tracked in JasperFx/marten#5052.
  • PolecatNaturalKeyProjection.QueueOperationForEvent (shared by the inline append and rebuild paths): mapping.Extractor(e.Data)mapping.Extractor(e). Tracked in JasperFx/polecat#369.

Bug 1 — IEvent<T> parameters produced no mapping, silently

discoverNaturalKeySourceMethods correctly unwrapped IEvent<T> to determine the event type, but buildExtractor could not build anything for it: the extractor only received the event data, so there was no IEvent to hand the method, and the static branch explicitly set canCall = false for an IEvent parameter. Control fell to the property-matching fallback, which cannot match a strong-typed key (Code) carried on the event as a string, so it returned null — and a null extractor was skipped with no error, no log, and no configuration-time validation.

The fix is the issue's suggested direction 1: widen the contract from the event data to the event. That makes IEvent<T> — a first class signature everywhere else in aggregation discovery — directly bindable rather than fixed by synthesizing a fake wrapper, and it makes a key derived from event metadata (stream key, timestamp, headers) expressible at all.

Bug 2 — extractors invoked user Apply methods against a fabricated blank aggregate

The extraction strategies are now ranked, most trustworthy first:

  1. A key extraction method — a static [NaturalKeySource] whose return type is the natural key type. A pure function of the event, so nothing is fabricated and no user aggregation code runs:
    [NaturalKeySource] public static Code KeyFor(CodeChanged e) => new Code(e.NewCode);
    [NaturalKeySource] public static Code KeyFor(IEvent<CodeChanged> e) => new Code(e.Data.NewCode);
  2. A property of the key's type carried on the event body — and only when it is unambiguous. "The first declared property of the right type" answers with the old key on an event carrying both, so several candidates are only usable when one is named after the natural key property; otherwise it defers to the method that knows which is which.
  3. Invoking the user's method against a fabricated aggregate — the legacy path, still last, now gated on the aggregate being safely constructible (public parameterless constructor, and no required members the constructor cannot satisfy). Expression.New bypasses required-member enforcement, which is how the user's method got an aggregate C# itself would never have let them create, and how a plain event append ended in ArgumentNullException out of SaveChangesAsync. This path also now reads the key off what an evolve method returned rather than off the blank aggregate.

The hard constraint stays and is now documented on the contract: the key is derived from the event alone. Marten maintains the lookup table inline at append time, where no prior aggregate exists under an Async snapshot lifecycle, so a key that depends on prior aggregate state is not expressible here.

Bug 3 — no supported escape hatch

NaturalKeyBuilder<TDoc>.SetBy<TEvent> was exactly the explicit registration a user needs when discovery cannot bind their method — and its constructor was internal, with nothing in JasperFx.Events or Marten ever constructing it. Unreachable dead code.

It is public now, with a SetByEvent<TEvent>(Func<IEvent<TEvent>, object?>) overload for metadata-derived keys, reachable from NaturalKeyFor() on the aggregation projection:

NaturalKeyFor(x => x
    .SetBy<ProductRegistered>(e => new ProductCode(e.Code))
    .SetByEvent<ProductCodeChanged>(e => new ProductCode(e.Data.NewCode)));

An explicit registration replaces whatever discovery found for the same event type, and clears the configuration-time error an unbindable method would otherwise raise.

Bug 4 — failures were swallowed

discoverNaturalKeySourceMethods wrapped extractor construction in catch { /* Silently skip */ } and dropped null extractors with no diagnostic. A user annotated their methods, got no warning of any kind, and discovered at runtime that natural key lookups returned null.

Unbindable methods are now recorded as NaturalKeyDefinition.DiscoveryProblems, and AssembleAndAssertValidity() throws an InvalidProjectionException naming the method, the reason, and the two supported fixes:

Unable to derive the natural key 'Thing.Code' from every [NaturalKeySource] method on ThingProjection:
  * Thing.Apply() for event ChangedInstance: deriving the key means calling this method against a blank Thing, but it declares required members that a parameterless constructor cannot satisfy
Either give the method a signature that derives the key from the event alone — a static method returning Code that takes the event or IEvent<T> — or register the mapping explicitly with NaturalKeyFor(x => x.SetBy<TEvent>(...)).

The check runs ahead of the source-generated short circuit, since natural key discovery is independent of how the aggregation itself is dispatched.

Tests

NaturalKeySourceDiscoveryTests grows from 4 to 12. The four existing cases (instance method, marten#4277 self-aggregating factory, separate projection class, marten#4966 evolve-that-changes-the-key) still pass, updated for the IEvent contract. New coverage, using the repro shapes from the issue:

  • ievent_handlers_produce_a_mapping — Bug 1
  • a_key_extraction_method_needs_no_aggregate_at_all — the dedicated signature binds even for an aggregate that cannot be fabricated, including the IEvent<T> form reading StreamKey
  • will_not_invoke_a_handler_against_an_aggregate_it_cannot_safely_build — Bug 2, the required-member repro
  • an_unbindable_source_method_fails_at_configuration_time — Bug 4
  • an_explicit_registration_wins_and_clears_the_discovery_problem / ..._replaces_a_discovered_mapping_for_the_same_event — Bug 3
  • an_event_carrying_two_candidate_keys_is_not_resolved_by_declaration_order
  • a_source_method_with_no_event_parameter_is_reported

Full solution builds. EventTests 629 passed, EventStoreTests 72 passed, 0 failed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QYfvCxHCUHo9MriHQocuoD

…gates, fail loudly

[NaturalKeySource] discovery silently dropped IEvent<T> handlers and invoked
user Apply methods against a fabricated blank aggregate, which threw at append
time. Reported downstream as JasperFx/marten#5041.

Widen the extraction contract from event data to the event. NaturalKeyEventMapping
.Extractor is now Func<IEvent, object?> rather than Func<object, object?>. That is
what makes an IEvent<T> handler bindable at all — buildExtractor used to set
canCall = false for an IEvent parameter because it had no IEvent to pass, then
fall through to a property-matching fallback that cannot match a strong-typed key
carried on the event as a string. The result was a mapping that was never
registered, so the natural key lookup table was simply never written for that
event type: FetchLatest returning null after a rename, live and on rebuild alike.
It also makes a key derived from event metadata expressible.

BREAKING for the one consumer: Marten's NaturalKeyProjection calls
mapping.Extractor(@event.Data) at both call sites and must pass @event instead.
Both already hold the real IEvent.

Rank the extraction strategies, most trustworthy first:

  1. a static [NaturalKeySource] returning the natural key type — a pure function
     of the event, so nothing is fabricated and no user aggregation code runs;
  2. a property of the key's type carried on the event body — and only when it is
     unambiguous, since "the first property of the right type" answers with the
     OLD key on an event that carries both;
  3. invoking the user's method against a fabricated aggregate.

(3) stays last and is now gated on the aggregate being safely constructible.
Expression.New bypasses required-member enforcement, so it handed the user's
method an aggregate C# itself would never have let them create — the marten#5041
ArgumentNullException, thrown out of the extractor, out of the inline natural key
maintenance, and out of the caller's SaveChangesAsync. It also reads the key off
what an evolve method returned rather than off the blank aggregate.

Make NaturalKeyBuilder reachable. Its constructor was internal and nothing in
JasperFx.Events or Marten ever constructed it, so SetBy — exactly the escape
hatch a user needs when discovery cannot bind their method — was unreachable
dead code. It is public now, with a SetByEvent overload for metadata-derived
keys, and NaturalKeyFor() on the aggregation projection to reach it. An explicit
registration replaces a discovered mapping for the same event type.

Fail loudly at configuration time. Discovery used to `catch { }` and drop a null
extractor with no diagnostic, so a user annotated their methods, got no warning
of any kind, and found out at runtime. Unbindable methods are recorded as
NaturalKeyDefinition.DiscoveryProblems and AssembleAndAssertValidity throws an
InvalidProjectionException naming the method, the reason, and the two supported
ways to fix it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYfvCxHCUHo9MriHQocuoD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[NaturalKeySource] discovery silently drops IEvent<T> handlers and throws when invoking Apply methods against a fabricated aggregate

1 participant