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.

Document Identity ​

Fisher finds a document's identity member by convention — a public member named Id or id — or by [Identity]. Four types are supported directly, plus a wrapper around any of them.

Id typeColumnAssigned by
GuidTEXT, lowercase canonicalFisher, a version-7 Guid
stringTEXTYou
intINTEGERFisher, from a Hi-Lo sequence
longINTEGERFisher, from a Hi-Lo sequence
cs
public class User
{
    public Guid Id { get; set; }         // assigned on Store if empty
}

public class Country
{
    public string Id { get; set; } = ""; // you assign it
}

public class Invoice
{
    public int Id { get; set; }          // assigned from fi_hilo
}

Guids are lowercase canonical text ​

SQLite has no Guid type, so Fisher stores the lowercase canonical form — d3f1…, not D3F1…. SQLite's default collation is case-sensitive, so this matters more than it looks: a Guid bound as a raw parameter is written UPPERCASE by Microsoft.Data.Sqlite, which would write rows that can never be read back. Every load returns null and every id match fails, silently, and only for Guid-identified types.

Fisher converts on every write path it owns. The one place it can reach you is raw SQL, where Fisher converts your parameter for you.

Hi-Lo sequences ​

A numeric identity is assigned from a Hi-Lo sequence held in fi_hilo — one row per sequence.

cs
// Per document type. Schema.For<T>() returns an expression; the mapping hangs off it.
opts.Schema.For<Invoice>().Mapping.HiloSettings =
    new Weasel.Core.Sequences.HiloSettings { MaxLo = 100 };

// Or store-wide, for every type with no settings of its own
opts.HiloSequenceDefaults.MaxLo = 100;

snippet source | anchor

There is a method form too, so a block of configuration reads the same as Marten's:

cs
opts.Schema.For<Invoice>()
    .HiloSettings(new Weasel.Core.Sequences.HiloSettings { MaxLo = 100, SequenceName = "shared" });

Or declaratively:

cs
[HiloSequence(MaxLo = 100, SequenceName = "shared")]
public class Invoice { public int Id { get; set; } }

Advancing the "hi" is one atomic statement — insert … on conflict … do update set hi_value = hi_value + 1 returning hi_value — where Marten calls a stored function and Polecat does a guarded read-then-update with a retry loop. SQLite's upsert does the whole thing with no window to lose.

TIP

Sequences are cached by sequence name, not by document type, so two types sharing a configured SequenceName share one allocation instead of each holding a private lo range over the same row.

Reset the floor when you need to:

cs
await store.Advanced.ResetHiloSequenceFloorAsync<Invoice>(10_000);

WARNING

fi_hilo is created by the sequence itself when needed, not only by the migration. An id is assigned inside session.Store(document), which returns before any commit, so waiting for the commit-time table creation would be far too late. AutoCreate.None is honoured in both places.

Strong-typed identities ​

A wrapper struct or class standing in for one of the four types works as both an aggregate's identity and a document's:

cs
public readonly record struct CatchId(Guid Value);

public class TaggedCatch
{
    public CatchId Id { get; set; }
    public string Species { get; set; } = "";
}

snippet source | anchor

The shape is JasperFx's, described by ValueTypeInfo: one public gettable property, plus a matching constructor or a static builder.

cs
// Loading by a wrapper needs both type parameters, which is what keeps it
// unambiguous against the four single-parameter overloads.
var order = await session.LoadAsync<Order, OrderId>(id);

// Or the identity-agnostic overload, which is the one the store-agnostic
// document contract declares. It resolves a wrapper, a raw value the wrapper
// is over, and the four canonical types alike.
var same = await session.LoadAsync<Order>((object)id);

TIP

The four canonical overloads are more specific than LoadAsync<T>(object), so a Guid argument still binds to LoadAsync<T>(Guid). Prefer a typed overload where the type is known — they resolve storage without a reflection step, and the compiler checks the identity against the document.

TIP

Fisher discovers wrappers rather than requiring registration, which is Polecat's model rather than Marten's. Nothing above needs a registration call.

RegisterValueType<T>() exists anyway, so a configuration block reads identically whichever store it is pointed at:

cs
opts.ConfigureSerialization(EnumStorage.AsString, Casing.CamelCase);
opts.Events.StreamIdentity = StreamIdentity.AsString;
opts.RegisterValueType<CatchId>();

It does two things discovery does not. First, a type that cannot be a wrapper is reported at configuration time with its name, rather than much later as has no identity member. Second, and more important:

A registered wrapper is serialized as the primitive it wraps. Without registration, System.Text.Json writes CatchId as {"value":"…"}. That loads fine, but LINQ reads the JSON rather than the object, so a wrapper used anywhere other than the identity could not be queried. Once registered, every member of that type is a plain value in the JSON, and LINQ treats it as one:

cs
opts.RegisterValueType<CrewId>();

// A member that is not the identity: compared, ordered, projected and matched as its Guid.
session.Query<Vessel>().Where(x => x.Captain == captain);
session.Query<Vessel>().Where(x => x.Captain.Value == guid);
session.Query<Vessel>().Where(x => x.Crew.Contains(sailor));
session.Query<Vessel>().Select(x => x.Captain);
  • Rows written before registration still load. Reading accepts the old {"value":…} shape too, so registering a type on a live store needs no migration. LINQ sees the new shape only in rows written after registration. Register before data is written, or re-store the documents you query by it.
  • Only registered types change shape. Plenty of ordinary single-property types match a wrapper's shape, and Fisher will not rewrite their JSON because they happen to fit.
  • A wrapper with its own [JsonConverter] is left alone. Vogen's and StronglyTypedId's generated converters already write the primitive, and a registration must not override them.
  • Only Fisher's System.Text.Json serializer takes it. A custom ISerializer writes its own shape.

Two things fall out of the design and are worth knowing:

  • The column holds the inner value. The wrapper exists only in .NET, so the table shape, the write SQL and everything downstream are untouched. An int-backed wrapper gets an INTEGER column, not a TEXT one.
  • A Guid-backed wrapper goes through the same lowercase-canonical conversion as a raw one. That conversion lives in the identity strategy precisely so a wrapper cannot lose it.

Generation mirrors the raw strategies: a version-7 Guid, or the document type's Hi-Lo sequence. A string-backed wrapper generates nothing, because a raw string key is externally assigned too.

Overriding the identity member ​

cs
public class Report
{
    [Identity]
    public Guid Key { get; set; }
}

Or in configuration, for a type you would rather not annotate — one from another assembly, or one shared with code that should not know about Fisher:

cs
opts.Schema.For<Report>().Identity(x => x.Key);

TIP

This has to run during configuration, which is the same rule every other member of the DSL follows. What makes it worth saying is that identity is the one thing resolved when the mapping is created — so a type with no usable identity member is refused when the store is built rather than when Schema.For<T>() is called, precisely so that naming one can rescue it.

Supplying the identity strategy ​

cs
opts.Schema.For<Boat>()
    .Identity(x => x.Registration)
    .IdStrategy(new PrefixedKeys());

Fisher otherwise picks by the id's type: a version-7 Guid, an externally-assigned string, a Hi-Lo int or long, or the unwrapping strategy for a strong-typed wrapper. This is the seam for anything else — a ULID in a string key, a snowflake long, a tenant-prefixed key.

A strategy is a Weasel.Core.Identity.IIdentification<TDoc, TId>, which is two members:

cs
public class PrefixedKeys : IIdentification<Boat, string>
{
    public string Identity(Boat document) => document.Registration;

    public string AssignIfMissing(Boat document, ISequenceSource sequences)
    {
        if (string.IsNullOrEmpty(document.Registration))
        {
            document.Registration = "boat-" + Guid.NewGuid().ToString("N")[..8];
        }

        return document.Registration;
    }
}

WARNING

Marten's IdStrategy(IIdGeneration) has no direct counterpart, and this is the honest translation. That type is a code-generation contract; Fisher's strategies are ordinary objects from the shared Weasel identity runtime, so the seam is that runtime interface — an object to write rather than a generator.

DANGER

A Guid strategy is wrapped, never taken raw. The lowercase-canonical conversion above lives in the identity strategy, so replacing the strategy is exactly where it could be lost — and losing it writes rows that can never be read back, silently and only for Guid-identified types. Fisher puts the wrapper back rather than leaving the trap open.

Aggregate identity ​

An aggregate's identity is resolved the same way, and there is one rule worth knowing because it produces a confusing error otherwise: conventional Apply / Create / ShouldDelete dispatch is compile-time only. JasperFx's source generator emits the dispatcher and keys it on (TDoc, TId), resolving TId from the aggregate's identity member — so an aggregate with no Id gets no dispatcher at all.

Fisher therefore requires an identity member and says so, rather than defaulting to the stream identity primitive and failing later with a message about a missing generated dispatcher.

WARNING

The generator runs in the assembly that defines the aggregate, so that assembly is the one that has to reference Fisher — the package carries JasperFx.Events.SourceGenerator inside it, so there is no analyzer reference to add yourself. A conventional-method projection class must be declared partial.

TId is the aggregate's own id type, not the stream identity primitive. They coincide for a plain Guid Id, but a strong-typed id is a wrapper struct and the generated dispatcher is keyed on the wrapper.

Released under the MIT License.