Document Hierarchies
A base type and its sub-classes can share one table and one identity space.
public abstract class Vehicle
{
public Guid Id { get; set; }
public string Registration { get; set; } = "";
}
public class Car : Vehicle
{
public int Doors { get; set; }
}
public class Truck : Vehicle
{
public decimal PayloadTonnes { get; set; }
}opts.Schema.For<Vehicle>()
.AddSubClass<Car>()
.AddSubClass<Truck>("lorry"); // an explicit aliasOr sweep an assembly:
opts.Schema.For<Vehicle>().AddSubClassHierarchy();session.Store(new Car { … }); // lands in fi_doc_vehicle
var vehicle = await session.LoadAsync<Vehicle>(id); // comes back as Car
var all = await session.Query<Vehicle>().ToListAsync(); // every sub-class, as itself
var cars = await session.Query<Car>().ToListAsync(); // narrowed in SQLThe discriminator is doc_type
A short alias in a column of its own — not dotnet_type.
That is worth stating because dotnet_type is already on every row and looks like the obvious candidate. It is not: it holds an assembly-qualified name, which is long, not worth indexing, and brittle across an assembly rename. Both siblings keep the columns separate too.
A sub-class's default alias follows the same convention the base type's does — the base's discriminator alias is the alias its table is named from — so a sub-class spelled differently would put two conventions in one column.
TIP
Name the alias explicitly if the type may be renamed. The alias is what is stored.
A sub-class never gets a mapping of its own
That is the whole point, and it is enforced before the mapping cache rather than after. Without the check, Store(derived) would create a mapping and write to fi_doc_car: the sub-class is registered, carries an alias, and still lands in the wrong table.
The two narrowing paths are different on purpose
| Read | Narrowed |
|---|---|
Query<TDerived>() | in SQL, with a doc_type predicate |
LoadAsync<TDerived>(id) | in memory, by testing what came back |
A load names one row and the id is unique across the hierarchy, so a discriminator predicate would only turn "that id is a different sub-class" into the same answer as "no such id".
The query filter is one statement-level pass
Not composed into each caller predicate. Two ways to get this wrong, and both were hit during development:
- Composing it per predicate repeats it and omits it entirely from a query with none.
- Hanging it off the soft-delete branch omits it for a type that is not soft-deleted, and for the
IsDeleted/MaybeDeletedscopes of one that is.
It is an in over the aliases at or below the queried type rather than an equality, because a sub-class may have sub-classes. Polecat emits a bare equality, which is correct only two levels deep.
An unknown alias throws
WARNING
A row whose doc_type this deployment does not recognise throws rather than falling back to the base. A row written by a deployment that knew a sub-class this one does not is a real configuration gap; deserializing it as the base hands back an object quietly missing whatever the sub-class added.
This is deliberately the opposite of the event reads' policy, which skip an unresolvable dotnet_type — an event store must stay readable by a deployment that does not know every event, where a document load has one right answer.
AddSubClassHierarchy
opts.Schema.For<Vehicle>().AddSubClassHierarchy();An overload takes the assembly to sweep, where the no-argument form uses the calling one.
TIP
It orders by full name, not by reflection order. Two sub-classes whose default aliases collide have to fail the same way on every run, and Assembly.GetTypes() promises no ordering — a collision that appeared on one machine and not another would be the worst version of that error.
Abstract and interface types are skipped, because a discriminator names something a row can be read back as.
An abstract or interface base
An abstract or interface base is a hierarchy whether or not anything is registered, so its table carries the doc_type column from the first migration. Adding it later would leave the rows already written with no discriminator to read.
Hierarchies elsewhere
- A joined hierarchy comes back as its real sub-classes, because the inner document is materialized by its own storage's selector. See Joins.
- Raw SQL does too, for the same reason. See Raw SQL.
- Ejecting a hierarchy works without knowing it is one: the map is keyed by the base, so
EjectAllOfTypescans entries whose key is not exactly the type and removes matching values individually.

JasperFx provides formal support for Fisher and other Critter Stack libraries. Please check our