Skip to content

Self-aggregating DetermineAction emitter drops implicit event-constructor Create when ShouldDelete is present #733

Description

@bugs-wkettlitz

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions