Skip to content

FetchForWriting/FetchLatest accept a strong-typed identifier (#5144) - #5193

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/5144-strong-typed-id-fetch
Aug 5, 2026
Merged

FetchForWriting/FetchLatest accept a strong-typed identifier (#5144)#5193
jeremydmiller merged 1 commit into
masterfrom
fix/5144-strong-typed-id-fetch

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

FetchForWriting<T, TId>, FetchForExclusiveWriting<T, TId> and FetchLatest<T, TId> died with a bare NullReferenceException when handed a strong-typed id wrapping the stream identity — PaymentId(Guid), InvoiceId(string) and friends.

Root cause — a planning gap, wider than strong-typed ids

determineFetchPlan routes any TId that is neither Guid nor string down the natural-key branch, which passes null! as the identity strategy because natural-key planners do not use one:

if (planner.TryMatch<TDoc, TId>(null!, options, out var naturalKeyPlan))

But the three lifecycle planners match on the projection alone and never look at the identity:

// InlineFetchPlanner
if (options.Projections.TryFindAggregate(typeof(TDoc), out var projection))
    if (projection.Lifecycle == ProjectionLifecycle.Inline)
    {
        plan = new FetchInlinedPlan<TDoc, TId>(options.EventGraph, identity);  // identity is null
        return true;
    }

…so they returned a plan holding that null, and it blew up on first use. LiveFetchPlanner always matches, so nothing ever fell through to the existing "unable to determine a fetch plan" error. Any non-Guid/string TId that was not a natural key hit this — strong-typed ids are just the case someone finally tried.

The fix

1. The three lifecycle planners decline to match when the identity strategy is null. NaturalKeyFetchPlanner is ordered ahead of them (_builtInPlanners = NaturalKey, Inline, Async, Live) and gates on NaturalKeyDefinition.OuterType == typeof(TId), so genuine natural keys are unaffected. What changes is that a non-natural-key TId now falls through instead of producing a plan that cannot work.

2. Falling through, planning asks whether TId is a registered value type. A strong-typed id is the stream identity, just wrapped — so the plan for the underlying Guid/string is reused via a new UnwrappedIdentityFetchPlan, a pure forwarder that unwraps on the way in. IAggregateFetchPlan<TDoc, in TId> is contravariant and every member takes the identity as input, which is what makes forwarding sufficient rather than requiring a parallel plan.

A wrapper over anything other than Guid or string cannot address a stream, and now says so explicitly instead of failing obscurely.

How it was found

The cross-store StrongTypedIdentityCompliance suite (#636, marten#5144), which had to fall back to passing raw identities to get green. Worth noting why this survived so long: every strong-typed-id test in both Marten and Polecat passes the raw value, and Marten's own doc sample explicitly unwraps —

var stream = await session.Events.FetchForWriting<Payment>(id.Value);

— so the generic overload had simply never been called with a wrapper, despite being generic over the identity type and being the obvious thing to reach for.

Verification

Bug_5144_strong_typed_id_fetch_overloads — 6 tests covering Guid-backed and string-backed wrappers across Inline/Async lifecycles, exclusive writing, FetchLatest, and an unknown id yielding an empty handle. Verified to fail on master first (5 × NullReferenceException).

Full EventSourcingTests: 1694 passed / 0 failed / 7 skipped on net9.0.

Polecat parity for the same overload is being checked separately — this shape carries forward to the Sqlite consumer (#5156), which is the reason for fixing it rather than documenting the raw-value workaround.

FetchForWriting<T, TId>, FetchForExclusiveWriting<T, TId> and FetchLatest<T, TId>
died with a bare NullReferenceException when handed a strong-typed id wrapping
the stream identity -- PaymentId(Guid), InvoiceId(string) and friends.

Root cause is a planning gap, and it is wider than strong-typed ids.
determineFetchPlan routes any TId that is neither Guid nor string down the
natural-key branch, which passes `null!` as the identity strategy because
natural-key planners do not use one. But the three lifecycle planners (Inline,
Async, Live) match on the projection alone and never look at the identity, so
they happily returned a plan holding that null -- and LiveFetchPlanner always
matches, so nothing ever fell through to the "unable to determine a fetch plan"
error. Any non-Guid/string TId that was not a natural key hit this.

Two changes:

- The three lifecycle planners now decline to match when the identity strategy
  is null. NaturalKeyFetchPlanner is ordered ahead of them and gates on
  NaturalKeyDefinition.OuterType, so genuine natural keys are unaffected; what
  changes is that a non-natural-key TId now falls through instead of producing
  a plan that cannot work.

- Falling through, planning then asks whether TId is a registered value type. A
  strong-typed id IS the stream identity, just wrapped, so the plan for the
  underlying Guid/string is reused via UnwrappedIdentityFetchPlan, a pure
  forwarder that unwraps on the way in. IAggregateFetchPlan<TDoc, in TId> is
  contravariant and every member takes the identity as input, which is what
  makes forwarding sufficient. A wrapper over anything other than Guid or string
  cannot address a stream and now says so instead of failing obscurely.

Found by the cross-store StrongTypedIdentityCompliance suite (marten#5144),
which had to fall back to passing raw identities to get green. Both products'
existing tests pass the raw value and Marten's own doc sample explicitly
unwraps, so this overload had never been exercised with a wrapper.

Regression tests verified to fail on master first (5 x NullReferenceException).
EventSourcingTests 1694/0/7 on net9.0.
@jeremydmiller
jeremydmiller merged commit a4a4633 into master Aug 5, 2026
10 checks passed
@jeremydmiller
jeremydmiller deleted the fix/5144-strong-typed-id-fetch branch August 5, 2026 23:02
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.

1 participant