Symptom
For a self-aggregating type that uses an implicit constructor as its "Create" handler (rather than a named static Create(...) method) and also declares ShouldDelete, the generated DetermineAction dispatcher silently omits the Create event from its switch. No diagnostic, no compile error — the code compiles fine, but the constructor is never invoked. Once any subsequent Apply-only event arrives, the aggregate is built via RuntimeHelpers.GetUninitializedObject(typeof(T)) instead, so every field the constructor would have set (including things like collection-initialized properties) stays at its CLR default. In our repro this manifested as a NullReferenceException inside a subsequent Apply method that assumed a list property was initialized, and in general silently produces a wrong/blank aggregate.
Root cause
src/JasperFx.Events.SourceGenerator/EvolverCodeEmitter.cs:
-
EmitSelfAggregatingEvolve / EmitSelfAggregatingEvolveAsync (used when the aggregate has no ShouldDelete) build allEventTypes via:
info.Methods.Select(m => m.EventType)
.Concat(info.EventConstructors.Select(c => c.EventType))
.Distinct<ITypeSymbol>(SymbolEqualityComparer.Default)
— correctly folding in constructor-based Create handlers from info.EventConstructors.
-
EmitSelfAggregatingDetermineAction (used when the aggregate does declare ShouldDelete) builds its switch purely from info.Methods (i.e., named Create/Apply/ShouldDelete methods) and never references info.EventConstructors at all. Any event type whose only handler is an implicit constructor is entirely missing from the emitted switch's case arms.
Repro
public static class TempGuidEvents
{
public sealed record Created(Guid Id, string Name);
public sealed record TagAdded(Guid Id, string Tag);
public sealed record Deleted(Guid Id);
}
public sealed class TempGuidAggregate
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public List<string> Tags { get; set; } = [];
// implicit constructor-based Create
public TempGuidAggregate(TempGuidEvents.Created @event)
{
Id = @event.Id;
Name = @event.Name;
}
public void Apply(TempGuidEvents.TagAdded @event)
{
Tags.Add(@event.Tag); // NRE at runtime: Tags is never initialized
}
public bool ShouldDelete(TempGuidEvents.Deleted _) => true;
}
Registered with options.Snapshot<TempGuidAggregate>(SnapshotLifecycle.Inline) (Marten 9.30.0 / JasperFx.Events 2.56.0). Starting a stream with Created then appending TagAdded throws:
JasperFx.Events.Daemon.ApplyEventException: Failure to apply event #0 Id(...)
---> System.NullReferenceException: Object reference not set to an instance of an object.
at TempGuidAggregate.Apply(TagAdded @event)
at <generated>.TempGuidAggregateEvolver.DetermineAction(...)
The emitted evolver's EventTypes / switch only lists TagAdded and Deleted — Created is entirely absent, confirmed by inspecting the generated .g.cs (via EmitCompilerGeneratedFiles):
public global::System.Type[] EventTypes => [typeof(TagAdded), typeof(Deleted)];
public (TempGuidAggregate?, ActionType) DetermineAction(TempGuidAggregate? snapshot, Guid id, IReadOnlyList<IEvent> events)
{
var exists = snapshot != null;
foreach (var e in events)
{
switch (e.Data)
{
case Deleted data:
if (snapshot != null && snapshot.ShouldDelete(data)) snapshot = null;
break;
case TagAdded data:
snapshot ??= (TempGuidAggregate)RuntimeHelpers.GetUninitializedObject(typeof(TempGuidAggregate));
snapshot.Apply(data);
break;
// no case for Created at all
}
}
...
}
Confirmed independent of the aggregate's Id type (reproduces identically for Guid and for a custom value-type Id struct) — the trigger is purely "constructor-based Create" + "ShouldDelete present", nothing to do with identity typing.
Suggested fix
EmitSelfAggregatingDetermineAction should fold info.EventConstructors into its dispatch the same way the Evolve emitters already do, e.g. building the Create-eligible event set from info.Methods.Where(m => m.MethodName is "Create" or "Apply").Select(...).Concat(info.EventConstructors.Select(c => c.EventType)), and emitting a case arm per constructor that calls new T(data) guarded by snapshot == null (mirroring the existing Methods-based Create arm shape).
Workaround
Convert the implicit constructor to an explicit static T Create(TEvent @event) method — this routes through info.Methods discovery, which EmitSelfAggregatingDetermineAction does consult, and produces correct output.
Versions
- JasperFx.Events 2.56.0
- JasperFx.Events.SourceGenerator (bundled with Marten 9.30.0)
- .NET 10 preview SDK
Symptom
For a self-aggregating type that uses an implicit constructor as its "Create" handler (rather than a named
static Create(...)method) and also declaresShouldDelete, the generatedDetermineActiondispatcher silently omits the Create event from its switch. No diagnostic, no compile error — the code compiles fine, but the constructor is never invoked. Once any subsequentApply-only event arrives, the aggregate is built viaRuntimeHelpers.GetUninitializedObject(typeof(T))instead, so every field the constructor would have set (including things like collection-initialized properties) stays at its CLR default. In our repro this manifested as aNullReferenceExceptioninside a subsequentApplymethod that assumed a list property was initialized, and in general silently produces a wrong/blank aggregate.Root cause
src/JasperFx.Events.SourceGenerator/EvolverCodeEmitter.cs:EmitSelfAggregatingEvolve/EmitSelfAggregatingEvolveAsync(used when the aggregate has noShouldDelete) buildallEventTypesvia:— correctly folding in constructor-based Create handlers from
info.EventConstructors.EmitSelfAggregatingDetermineAction(used when the aggregate does declareShouldDelete) builds its switch purely frominfo.Methods(i.e., namedCreate/Apply/ShouldDeletemethods) and never referencesinfo.EventConstructorsat all. Any event type whose only handler is an implicit constructor is entirely missing from the emitted switch'scasearms.Repro
Registered with
options.Snapshot<TempGuidAggregate>(SnapshotLifecycle.Inline)(Marten 9.30.0 / JasperFx.Events 2.56.0). Starting a stream withCreatedthen appendingTagAddedthrows:The emitted evolver's
EventTypes/ switch only listsTagAddedandDeleted—Createdis entirely absent, confirmed by inspecting the generated.g.cs(viaEmitCompilerGeneratedFiles):Confirmed independent of the aggregate's Id type (reproduces identically for
Guidand for a custom value-type Id struct) — the trigger is purely "constructor-based Create" + "ShouldDelete present", nothing to do with identity typing.Suggested fix
EmitSelfAggregatingDetermineActionshould foldinfo.EventConstructorsinto its dispatch the same way theEvolveemitters already do, e.g. building the Create-eligible event set frominfo.Methods.Where(m => m.MethodName is "Create" or "Apply").Select(...).Concat(info.EventConstructors.Select(c => c.EventType)), and emitting acasearm per constructor that callsnew T(data)guarded bysnapshot == null(mirroring the existingMethods-based Create arm shape).Workaround
Convert the implicit constructor to an explicit
static T Create(TEvent @event)method — this routes throughinfo.Methodsdiscovery, whichEmitSelfAggregatingDetermineActiondoes consult, and produces correct output.Versions