Skip to content

Nested Projection Objects — Design

This page is the cross-cutting design reference for nested projection objects — the ability to populate, update, and clear a single nullable child object on a read model from events. It is an explanation-style page that ties together every layer affected by the feature: the Projection Declaration Language (PDL) syntax, the declarative .NET client, the model-bound .NET client, the projection definition object model, the projection engine, the PDL compiler and code generator, and the Monaco language definition for the Workbench projection editor.

The user-facing references for each surface live in their respective folders. This page exists to explain what nested means as a concept, why it is a first-class projection primitive, how the moving parts fit together, and what work was required to fully close the feature.

Tracking issue: Cratis/Chronicle#3142.

A read model frequently has properties that represent a single, optional, scalar child object — for example, the active contract on an employee, the current promotion on a product, or the registered command for a slice. These properties have three distinct behaviors:

  1. They start out as null.
  2. A specific event populates them with a fresh nested object.
  3. A specific event clears them back to null.

Until nested objects existed as a first-class concept, you had to either:

  • Model the property as a collection of one item using children, which forces a key, breaks the scalar nature of the property, and complicates queries; or
  • Hand-roll mutations into mapped properties on the parent type, which leaks nested concerns into the root read model and loses any independent lifecycle.

nested solves both problems with a small, dedicated primitive that mirrors the shape of children without the collection semantics — one scalar, nullable property, with explicit set and clear lifecycle events.

A nested object is:

  • A nullable, scalar property on a read model — it holds at most one child object at a time.
  • Set by one or more events declared with from <EventType>. Each from event auto-maps (or explicitly maps) properties onto the nested object, creating it on first touch and merging into the existing instance on subsequent touches.
  • Cleared by one or more events declared with clear with <EventType>. When the clear event fires the property is set back to null.
  • Recursive — a nested object can itself contain children, joins, counters, every-blocks, and further nested objects. Every projection primitive that works at the root works inside a nested block.

read model created

from

from

clear with

Empty

Populated

The contract is intentionally narrow: nested is not a soft-deletion marker for the parent, and it is not a workaround for one-of-many relationships — use children for those.

The four developer-facing surfaces of Chronicle all express the same underlying concept and compile down to the same projection definition.

Projection engine

Projection definition object model

Developer-facing surfaces

Compiler + codegen

INestedBuilder

Reflection

PDL:

nested Command

from CommandSetForSlice

clear with CommandClearedForSlice

.NET Declarative:

.Nested(_ => _.Command, n => n

.From<CommandSetForSlice>()

.ClearWith<CommandClearedForSlice>())

.NET Model-Bound:

[Nested] CommandItem? Command

+ [FromEvent<...>] / [ClearWith<...>]

ProjectionDefinition.Nested

: Dictionary<PropertyPath, ChildrenDefinition>

ProjectionFactory.SetupNestedSubscriptions

ProjectNested + ClearNested

A nested block is declared with a property name and contains the same building blocks as a top-level projection or a children block:

projection Slice => SliceReadModel
from SliceCreated
Name = name
nested command
from CommandSetForSlice
Name = commandName
Schema = schema
clear with CommandClearedForSlice

Two new keywords are introduced: nested (block opener) and clear with (lifecycle directive). Both can appear inside children and inside other nested blocks. See PDL Nested Objects for the full surface.

The fluent builder exposes Nested(...) symmetrical to Children(...), taking a property expression and a configuration callback. The callback receives an INestedBuilder<TParent, TNested> that inherits every method of the root projection builder and adds ClearWith<TEvent>():

public class SliceProjection : IProjectionFor<Slice>
{
public void Define(IProjectionBuilderFor<Slice> builder) => builder
.From<SliceCreated>()
.Nested(_ => _.Command, nested => nested
.From<CommandSetForSlice>()
.ClearWith<CommandClearedForSlice>());
}

See Declarative Nested Objects for the full surface.

Two attributes drive the model-bound surface:

  • [Nested] — placed on a nullable property or record parameter, marks it as a nested object on the parent.
  • [ClearWith<TEvent>] — placed on the nested type (or its properties), declares the event that nulls the parent’s property.
public record Slice(
[Key] SliceId Id,
string Name,
[Nested]
CommandItem? Command);
[FromEvent<CommandSetForSlice>]
[ClearWith<CommandClearedForSlice>]
public record CommandItem(CommandItemId Id, string Name, string Schema);

The nested type is scanned for [FromEvent<T>], [ClearWith<T>], [SetFrom<T>], [AddFrom<T>], [Nested], [ChildrenFrom<T>], and the other standard projection annotations. See Model-Bound Nested Objects.

A nested block can appear inside another nested block, inside a children block, and inside a child of a child — there is no recursion depth limit imposed by the model. Each layer is rendered into the projection definition as a ChildrenDefinition with IdentifiedBy = PropertyPath.NotSet, which the engine treats as “scalar” rather than “collection”.

projection Slice => SliceReadModel
from SliceCreated
Name = name
nested command
from CommandSetForSlice
Name = commandName
nested validation
from ValidationConfigured
Rules = rules
clear with ValidationRemoved
clear with CommandClearedForSlice
public record Slice(
[Nested] CommandItem? Command);
[FromEvent<CommandSetForSlice>]
[ClearWith<CommandClearedForSlice>]
public record CommandItem(
string Name,
[Nested] ValidationConfig? Validation);
[FromEvent<ValidationConfigured>]
[ClearWith<ValidationRemoved>]
public record ValidationConfig(string Rules);

A nested block may also live inside a children collection, attaching a single nullable child object to every item in the collection. The engine resolves the property path with the child’s array indexers so each item maintains its own nested state:

projection Project => ProjectReadModel
from ProjectCreated
Name = name
children tasks identified by taskId
from TaskAdded key taskId
parent projectId
Title = title
nested assignee
from TaskAssigned
Name = assigneeName
Email = assigneeEmail
clear with TaskUnassigned

Nested objects are represented in the contract layer by reusing ChildrenDefinition — the same shape that drives collections — with a sentinel IdentifiedBy value:

FieldChildrenNested
IdentifiedByAn event property path that keys the collectionPropertyPath.NotSet
FromEvents that add or update itemsEvents that set or update the scalar
RemovedWithEvents that remove a single itemEvents that clear the scalar to null
ChildrenNested collections within itemsNested collections within the scalar
NestedNested scalars within itemsNested scalars within the scalar

ProjectionDefinition.Nested and ChildrenDefinition.Nested are dictionaries keyed by the property path of the nested object on the parent. The engine walks this structure recursively to build subscriptions.

ProjectionFactory.SetupNestedSubscriptions walks the Nested dictionaries of a projection definition, prefixing every property path with the accumulated path to the current nested or child position. For each entry it:

  1. Resolves the merged property mappers using the schema of the parent and the events.
  2. Subscribes each from event to the engine’s ProjectNested operator, which writes the mapped properties onto the nested object (creating it if it doesn’t yet exist).
  3. Subscribes each clear with event to ClearNested, which sets the property back to null while preserving the array indexers of any surrounding children.
  4. Recurses into the nested definition’s own Nested dictionary so deeper levels are wired up in the same pass.

The Changeset infrastructure already carries the necessary NestedCleared change type so sinks can persist the null transition correctly across the MongoDB and SQL storage providers.

Integration coverage for nested objects lives under Integration/DotNET.InProcess/Projections/Scenarios/:

  • when_projecting_with_nested_object/ — declarative first-level nested
    • setting_the_nested_object — populates the nested object from a from event
    • updating_the_nested_object — multiple from events merge into the same nested instance
    • clearing_the_nested_object — the clear with event nulls the property
  • when_projecting_with_nested_in_children/ — declarative nested object inside a children collection.
  • ModelBound/when_projecting_with_nested_object/ — model-bound first-level nested
    • The same three scenarios driven by [Nested] and [ClearWith<T>].
  • ModelBound/when_projecting_with_nested_in_children/ — nested object inside a children collection.

Additional recursive (2-level) scenarios are included in the current integration coverage.

Client API contract coverage for nested objects lives under Source/Clients/DotNET.Specs/Projections/:

  • for_ProjectionBuilderFor/when_building/with_nested_object.cs — declarative single nested object.
  • for_ProjectionBuilderFor/when_building/with_nested_object_in_nested_object.cs — declarative recursive nested-in-nested.
  • for_ProjectionBuilderFor/when_building/with_nested_object_in_children_collection.cs — declarative nested inside children.
  • ModelBound/for_ModelBoundProjectionBuilder/when_building_model/with_nested/* — model-bound nested attributes and recursive behavior.
  • ModelBound/for_ModelBoundProjectionBuilder/when_building_model/with_children_having/* — model-bound nested behavior inside children.
AreaStatus
nested concept on the projection definition object modelImplemented
Projection engine — ProjectNested, ClearNested, SetupNestedSubscriptionsImplemented
Declarative .NET client — INestedBuilder<TParent, TNested>, .Nested(...), .ClearWith<TEvent>()Implemented
Model-bound .NET client — [Nested], [ClearWith<TEvent>]Implemented
Storage sinks — NestedCleared change handling for MongoDB and SQLImplemented
Integration specs — first-level nested (declarative + model-bound)Implemented
Integration specs — nested inside children (declarative + model-bound)Implemented
Documentation — declarative, model-bound, PDL reference pagesImplemented
Integration specs — recursive (2-level) nested-in-nestedImplemented
PDL compiler — nested and clear with parsing rulesImplemented
PDL grammar reference (EBNF) — nested and clear with productionsImplemented
PDL code generator — emit Nested entries onto ProjectionDefinitionImplemented
Monaco language definition — nested, clear, with keywordsImplemented

The core nested-object feature is now implemented across engine, .NET surfaces, PDL compiler/codegen, Monaco grammar, and end-to-end/integration verification. Remaining follow-ups are design clarifications captured below.

  • Empty nested blocks. Should the PDL compiler accept a nested block with only clear with and no from, or reject it as ambiguous? The declarative and model-bound clients let you forget the clear; symmetry would suggest the same flexibility for the set side, but the resulting nested object would have no events to populate it.
  • Mapping inheritance. Should every blocks at the parent level apply to nested objects by default, or require an explicit opt-in? The engine currently does not propagate every into nested scopes; making this explicit in PDL would prevent surprise behavior.
  • Removal cascade ordering. When the outer object is cleared, does the inner nested object emit its own clear with event semantics on the sink (separate NestedCleared per level), or does the outer clear discard the entire subtree as one change? The current implementation cascades structurally — open question whether downstream sinks need a more granular signal.
  • Schema discovery. The model-bound discovery walks [Nested] properties using reflection. There is an open question about how to surface diagnostics when a [ClearWith<T>] attribute references an event type that no [FromEvent<T>] on the nested type produces — should the framework warn at startup or be silent?