Skip to content

Empty Child Collections

A read model with a [ChildrenFrom<T>] collection — or a declarative .Children(...) projection — has no children until a child event arrives. That is not an edge case. It is the state every instance is in the moment its root event creates it, so it is the first read of every new instance. This page covers what Chronicle stores then, what you read back, and how to say which answer you want.

A child collection with no children is not stored

Section titled “A child collection with no children is not stored”

Chronicle never writes a children collection on behalf of the root event. A children path is owned exclusively by the child add and remove operations for the whole lifetime of the read model, and the root projection’s initial state is applied with every children path removed.

The reason is replay. Chronicle replays event source partitions independently and in parallel, so a LineItemAdded for one partition can be processed before the OrderPlaced for the order it belongs to. If the root event were allowed to write Lines = [], that empty array would erase the line item the sibling partition had already added.

OrderPlaced partitionRead modelLineItemAdded partitionOrderPlaced partitionRead modelLineItemAdded partitionlines is held back from this write,so the child already added survivesadd a child to linesset customer and status

So absence is the encoding: a child collection with no children is a field that is not there, not a field holding []. The exclusion is specific to children collections; the rest of the initial state is applied as written.

When Chronicle materializes a read model for you, a collection property the stored data carries no value for is filled in according to how you declared it:

Declared asNo value storedA value stored
IEnumerable<LineItem> LinesAn empty collectionExactly that value, including []
IEnumerable<LineItem>? LinesnullExactly that value, including []

A non-nullable declaration is a promise, and Chronicle keeps it — you can enumerate the property without a guard, on an instance that has never had a child. A nullable declaration says you want to tell “no children yet” apart from “an empty list”, so Chronicle leaves it alone.

Two things stay out of scope. A string is left alone even though it is an IEnumerable, and so is a dictionary — whether a missing dictionary means an empty one is a different question with a different answer. A collection type Chronicle cannot construct, such as a custom collection with no parameterless constructor, is also left as it was rather than guessed at.

The declaration is the decision point. There is no option to set and nothing to register:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record EmptyChildrenOrderPlaced(string Customer);
[EventType]
public record EmptyChildrenLineItemAdded(Guid ItemId, string ProductName, int Quantity);
public record EmptyChildrenLineItem([Key] Guid Id, string ProductName, int Quantity);
// Non-nullable: an order with no line items reads back as an empty collection,
// so enumerating Lines never needs a guard.
[FromEvent<EmptyChildrenOrderPlaced>]
public record EmptyChildrenOrder(
[Key]
Guid OrderId,
string Customer,
[ChildrenFrom<EmptyChildrenLineItemAdded>(key: nameof(EmptyChildrenLineItemAdded.ItemId))]
IEnumerable<EmptyChildrenLineItem> Lines);
// Nullable: "no line items yet" stays distinguishable from "an empty list".
[FromEvent<EmptyChildrenOrderPlaced>]
public record EmptyChildrenDraftOrder(
[Key]
Guid DraftOrderId,
string Customer,
[ChildrenFrom<EmptyChildrenLineItemAdded>(key: nameof(EmptyChildrenLineItemAdded.ItemId))]
IEnumerable<EmptyChildrenLineItem>? Lines);

The rule is applied when the instance is materialized, not when it is stored, so changing a declaration takes effect for instances that were written long before you changed it. Nothing needs replaying.

Initial values do not reach a child collection

Section titled “Initial values do not reach a child collection”

Initial values look like the place to say “this collection starts empty”, and for a children collection they do nothing. The initial state is applied with every children path removed — the same exclusion, for the same replay reason — so an initial Lines = [] is dropped before it reaches the sink.

Use the declaration instead. Initial values remain the right tool for business defaults, sentinel values, and properties that are not children collections.

These rules belong to Chronicle’s reader, not to the stored document. They apply wherever Chronicle materializes the read model for you — reading a single instance, reading a collection, materialized instances and paging, snapshots, watching a read model, and read model reactors — and to anything layered on those APIs. The read model testing scenarios materialize through the same code, so a spec and a running system answer this question identically.

They do not apply when something else deserializes the same stored document. If you query the read model’s MongoDB collection directly with IMongoCollection<T>, as Get started shows for reporting and ad-hoc reads, the MongoDB driver builds the instance — and the driver has never heard of this page. Left alone, it hands you null for a field the document does not carry.

If you are on Cratis Arc, you already have the answer: Arc registers a convention pack that materializes a non-nullable collection on a [ReadModel] type as empty, for both an absent field and a stored null. It makes the driver agree with this page rather than differ from it. Without Arc, configure the equivalent on the driver yourself, or read through Chronicle.