Skip to content

The search box knows all the secrets -- try it!

Fisher is part of the Critter Stack ecosystem.

JasperFx Logo JasperFx provides formal support for Fisher and other Critter Stack libraries. Please check our Support Plans for more details.

Projections Overview

A projection builds a read model from events. Fisher supports every shape the Critter Stack defines, across every lifecycle — and because the abstractions are JasperFx's, a projection written for Marten or Polecat runs here unaltered.

The shapes

ShapeBuilds
Single streamOne document per stream
Multi streamOne document from events across many streams
Event projectionArbitrary writes per event
Flat tableRows in a plain relational table
CompositeSeveral projections as ordered stages under one shard

The lifecycles

LifecycleWhenConsistency
LiveFolded on demandAlways current; nothing stored
InlineThe append's own transactionStrong
AsyncA background daemonEventual

Registering

cs
opts.Projections.Snapshot<Order>(SnapshotLifecycle.Inline);
opts.Projections.Add<OrdersByCustomer>(ProjectionLifecycle.Async);
opts.Projections.CompositeProjectionFor("reporting", c => { … });
opts.Projections.Subscribe(new NotifyOnShipment());

Add<T>(lifecycle) constructs the projection for you and is the spelling Marten and Polecat use, so a registration block reads identically against all three stores. Pass an instance instead when the projection needs constructor arguments:

cs
opts.Projections.Add(new OrdersByCustomer(connectionString), ProjectionLifecycle.Async);

Both forms take an optional AsyncOptions lambda for rebuild and batching behaviour:

cs
opts.Projections.Add<OrdersByCustomer>(ProjectionLifecycle.Async, o => o.BatchSize = 1000);

Conventional methods are source-generated

cs
public class Order
{
    public Guid Id { get; set; }
    public bool Shipped { get; set; }

    public static Order Create(OrderPlaced e) => new() { … };
    public void Apply(OrderShipped e) => Shipped = true;
    public bool ShouldDelete(OrderCancelled e) => true;
}

WARNING

The dispatcher is emitted by JasperFx.Events.SourceGenerator, and there is no runtime fallback.

  • The Fisher package carries the generator, so referencing Fisher is enough — no analyzer reference of your own. The generator runs in the assembly that defines the aggregate or projection, so that assembly is the one that has to reference Fisher.
  • A conventional-method projection class must be declared partial.
  • The aggregate needs an identity member, because the generator keys the dispatcher on (TDoc, TId).
  • TId is the aggregate's own id type — a strong-typed id is a wrapper struct, and the generated dispatcher is keyed on the wrapper.

Rebuilds

cs
var daemon = await store.BuildProjectionDaemonAsync();
await daemon.RebuildProjectionAsync("Order", CancellationToken.None);

A rebuild tears down the projection's existing state and replays from the beginning of the event store.

DANGER

Teardown is where projection bugs hide. A replay rewrites every row it can still produce, so a surviving row is invisible except where the replay cannot recreate it — a row whose backing events are gone, archived or compacted.

That means an ordinary rebuild test passes even when teardown is broken. Every place Fisher's teardown had to learn something — flat tables, composite members, EF Core-backed documents — is pinned with a row the replay cannot recreate, and any projection you write with unusual storage should be too.

Both halves of teardown — the progression rows and the documents — run in one transaction, because clearing progress without clearing documents replays a projection on top of rows it already wrote.

TIP

Teardown checks for the table in C#, not in SQL. SQLite resolves a table name when it prepares a statement, so a where exists (select 1 from sqlite_master …) guard on the delete fails before the guard could run. Names come back from sqlite_master first, and missing tables are skipped.

Errors

cs
opts.Projections.Errors.SkipApplyErrors = true;

A skipped poison event is quarantined into fi_dead_letters rather than stopping its shard.

Side effects

A projection can publish messages through an outbox seam — and the default outbox drops every message, which is the end state rather than a placeholder. Fisher ships no delivery mechanism.

Testing

cs
await store.Advanced.EventProjectionScenarioAsync(scenario =>
{
    scenario.Append(streamId, new OrderPlaced(…));
    scenario.DocumentShouldExist<Order>(streamId);
});

See Integration Testing.

Released under the MIT License.