Skip to content

fix(generator): dispatch DI projections through partial-class override (marten#4787)#481

Merged
jeremydmiller merged 1 commit into
mainfrom
fix-4787-di-projection-evolver-injection
Jun 28, 2026
Merged

fix(generator): dispatch DI projections through partial-class override (marten#4787)#481
jeremydmiller merged 1 commit into
mainfrom
fix-4787-di-projection-evolver-injection

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes the runtime regression introduced by #470 / 2.14.1's GetUninitializedObject shadow-instance fallback. Reported in marten#4787.

Root cause

The 2.14.1 fallback in EvolverCodeEmitter.BuildProjectionInstanceExpression (EvolverCodeEmitter.cs:152-163) closed CS7036 for DI-only-ctor projections at compile time, but the file-scoped Evolver it produced built its private shadow projection via RuntimeHelpers.GetUninitializedObject — bypassing the constructor entirely. Every conventional Apply / Create / ShouldDelete then dispatched through that shadow, so any deref of a constructor-injected field NRE'd. The reporter's null-conditional masked it as silent zero events.

Fix shape

When the projection has at least one instance-on-projection convention method AND no public parameterless ctor AND is partial, emit a [GeneratedCode]-attributed override (Evolve / EvolveAsync / DetermineActionAsync — picked by HasShouldDelete / HasAnyAsync exactly like the existing file-scoped Evolver) directly into the user's partial class. Body uses bare convention-method calls so dispatch binds to this — the DI-built instance with all injected fields populated.

For this path the [assembly: GeneratedEvolver(...)] attribute and the file sealed class Evolver are NOT emitted — the runtime's isOverridden(...) precedence (JasperFxAggregationProjectionBase.cs:82-114) picks up the partial-class override before tryUseAssemblyRegisteredEvolver ever runs, so the file-scoped path is unreachable for this case.

Stateless / parameterless-ctor / aggregate-only / static-only projections keep the file-scoped Evolver verbatim — no perf or shape change for the cases that don't need DI.

Plumbing

  • New helpers: HasPublicParameterlessCtor, InstanceCallPrefix, three EmitXAsOverride entry points, EmitPartialProjectionWithDIOverride.
  • dispatchOnThis flag threaded through EmitCreateCallSync / EmitCreateCall / EmitApplyCallExpression / EmitApplyCallStatement / EmitShouldDeleteCall and the four Null/NonNull/{Sync,Async} case generators. When true, instance calls emit bare method names (binding to this); when false (default), they emit the existing _projection. prefix for the file-scoped Evolver path — so the existing emission is unchanged for everything except DI projections.

Test

  • di_activated_projection_without_parameterless_ctor_compiles → renamed to di_activated_projection_without_parameterless_ctor_dispatches_through_partial_override. Assertion flipped: source must NOT contain GetUninitializedObject / file sealed class / [assembly: GeneratedEvolver] / _projection.Create / _projection.Apply for a DI projection; MUST contain a partial class DiActivated with a [GeneratedCode] override Evolve(...) on it. Compile check (no CS7036) preserved.
  • 24/24 AggregateEvolverGeneratorTests green.
  • Verified locally against marten#4787 Bug_4787_di_projection_with_instance_apply (9 cases — Apply/Create/ShouldDelete × Singleton/Scoped/Transient): all pass; before this all 9 failed with NRE through the shadow instance.
  • Marten regression sweep on net10 against a locally-packed build of this branch: 71/71 ContainerScopedProjectionTests + 1411/1411 EventSourcingTests + 198/198 DaemonTests all green.

Marten consumption

Follow-up Marten PR will bump JasperFx.Events to the version this ships in, promote the repro to the regression suite, and close marten#4787.

…e (marten#4787)

The 2.14.1 GetUninitializedObject fallback in BuildProjectionInstanceExpression
fixed CS7036 for DI-only-ctor projections at compile time but introduced a
runtime regression: every conventional Apply/Create/ShouldDelete fired against
an uninitialized shadow instance, so any deref of a constructor-injected field
NRE'd. The reporter's null-conditional masked the NRE as silent zero events.

The runtime dispatch chain in JasperFxAggregationProjectionBase already prefers
an isOverridden(Evolve/EvolveAsync/DetermineAction*) check ahead of
tryUseAssemblyRegisteredEvolver, and the post-override path (evolveDefaultAsync)
calls EvolveAsync on `this` — which IS the DI-built instance with all injected
fields populated. So when the projection has at least one instance-on-projection
convention method AND no public parameterless ctor AND is partial, route to a
new emission path that emits a [GeneratedCode]-attributed override (Evolve /
EvolveAsync / DetermineActionAsync — picked by HasShouldDelete / HasAnyAsync
exactly like the existing file-scoped Evolver) directly into the user's partial
class. Body uses bare convention-method calls so dispatch binds to `this`. The
[assembly: GeneratedEvolver(...)] attribute and the file-scoped Evolver class
are NOT emitted for this case — the runtime selects the override before ever
walking the assembly-registered evolver list.

Plumbing changes (additive, defaulted false everywhere else):
- New helpers: HasPublicParameterlessCtor, InstanceCallPrefix, three
  EmitXAsOverride entry points, EmitPartialProjectionWithDIOverride.
- dispatchOnThis flag threaded through EmitCreateCallSync / EmitCreateCall /
  EmitApplyCallExpression / EmitApplyCallStatement / EmitShouldDeleteCall and
  the four Null/NonNull/{Sync,Async} case generators. When true, instance calls
  emit bare method names (binding to `this`); when false (default), they emit
  the existing `_projection.` prefix for the file-scoped Evolver path.
- Stateless / parameterless-ctor / aggregate-only / static-only projections
  keep the file-scoped Evolver verbatim — no perf or shape change for the
  cases that don't need DI.

Test surface:
- di_activated_projection_without_parameterless_ctor_compiles → renamed to
  di_activated_projection_without_parameterless_ctor_dispatches_through_partial_override.
  Assertion flipped: source must NOT contain GetUninitializedObject /
  `file sealed class` / [assembly: GeneratedEvolver] / `_projection.Create` /
  `_projection.Apply` for a DI projection; MUST contain a
  `partial class DiActivated` with a [GeneratedCode] `override Evolve(...)`
  on it. Compile check (no CS7036) preserved.
- Verified against marten#4787 Bug_4787_di_projection_with_instance_apply
  (9 cases — Apply/Create/ShouldDelete × Singleton/Scoped/Transient): all
  pass; before this fix all 9 failed with NRE through the shadow instance.
- ContainerScopedProjectionTests (71), EventSourcingTests (1411), DaemonTests
  (198) all green on net10 against a locally-packed
  2.17.0-bug4787local build of this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 235ed51 into main Jun 28, 2026
1 check passed
@jeremydmiller
jeremydmiller deleted the fix-4787-di-projection-evolver-injection branch June 28, 2026 21:26
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