Skip to content

Clearing Values

Some facts take a value away without putting anything in its place. A note is deleted, a due date is dropped, a shift is released. ClearWith declares which event does that, and the projection writes the member back to null every time the event is observed — including when the read model is rebuilt from the beginning of the stream.

Put ClearWith on the member the event empties. The member has to be nullable.

Clear a scalar member
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbClearingProjectNoted(string Note);
[EventType]
public record MbClearingProjectNoteCleared;
[FromEvent<MbClearingProjectNoted>]
public record MbClearingProjectNotes(
[Key]
Guid Id,
[ClearWith<MbClearingProjectNoteCleared>]
string? Note);

When MbClearingProjectNoted occurs, Note is populated. When MbClearingProjectNoteCleared occurs, Note goes back to null. A later MbClearingProjectNoted sets it again — a clear is a value written at a point in the stream, not a terminal state for the member.

SetValue with null means exactly the same thing and builds exactly the same mapping. Reach for whichever reads better where you are: ClearWith states the intent, and SetValue(null) sits naturally alongside the other SetValue declarations on a member.

The same clear, written as a null SetValue
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbClearingInvoiceIssued(string Reference);
[EventType]
public record MbClearingInvoiceVoided;
[FromEvent<MbClearingInvoiceIssued>]
public record MbClearingInvoice(
[Key]
Guid Id,
[SetValue<MbClearingInvoiceVoided>(null)]
string? Reference);

Clearing means returning a member to no value, so the member must have that state. A non-nullable member does not, and the declaration is refused: CHR0048 reports it as a build warning — scheduled to become an error in the next major — and building the projection throws CannotClearNonNullableMember, so an unaddressed warning fails at startup.

A member has to be able to hold no value
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbClearingShiftPlanned(string Assignee, int Hours);
[EventType]
public record MbClearingShiftReleased;
[FromEvent<MbClearingShiftPlanned>]
public record MbClearingShift(
[Key]
Guid Id,
// Nullable, so "nobody is assigned" is a state the member can actually hold.
[ClearWith<MbClearingShiftReleased>]
string? Assignee,
// Nullable value type, for the same reason: 0 hours is a number of hours, not the absence of one.
[ClearWith<MbClearingShiftReleased>]
int? Hours);

This is deliberate rather than a limitation to work around. The only value a projection could write to a non-nullable member is its type default — "", 0, DateTimeOffset.MinValue — and that is a different fact. A reader seeing an empty string cannot tell whether the value was cleared or was genuinely empty, which is exactly the ambiguity a type-specific “not set” sentinel introduces.

So decide which you mean:

You meanDeclare
No value at allA nullable member plus [ClearWith<TEvent>]
A specific value that happens to be the type default[SetValue<TEvent>("")], [SetValue<TEvent>(0)], …

A member of a child collection item clears the same way. The clearing event has to reach the child, so it needs its own ChildrenFrom with the key that identifies which item to clear.

Clear a member of a child item
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbClearingTaskListStarted(string Name);
[EventType]
public record MbClearingTaskAdded(Guid ListId, Guid TaskId, string Title, string Due);
[EventType]
public record MbClearingTaskDeferred(Guid ListId, Guid TaskId);
public record MbClearingTask(
[Key] Guid Id,
[SetFrom<MbClearingTaskAdded>(nameof(MbClearingTaskAdded.Title))] string Title,
[SetFrom<MbClearingTaskAdded>(nameof(MbClearingTaskAdded.Due))]
[ClearWith<MbClearingTaskDeferred>]
string? Due);
[FromEvent<MbClearingTaskListStarted>]
public record MbClearingTaskList(
[Key] Guid Id,
[ChildrenFrom<MbClearingTaskAdded>(key: nameof(MbClearingTaskAdded.TaskId), parentKey: nameof(MbClearingTaskAdded.ListId), identifiedBy: nameof(MbClearingTask.Id))]
[ChildrenFrom<MbClearingTaskDeferred>(key: nameof(MbClearingTaskDeferred.TaskId), parentKey: nameof(MbClearingTaskDeferred.ListId), identifiedBy: nameof(MbClearingTask.Id))]
IReadOnlyList<MbClearingTask> Tasks);

ClearWith on a member of a nested type clears that member and leaves the object standing. ClearWith on the Nested member itself — or on the nested type, as described in Nested Objects — clears the whole object back to null.

Clear one member of a nested object, or the whole object
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[EventType]
public record MbClearingContractSigned(string Title, string NoticeGiven);
[EventType]
public record MbClearingNoticeWithdrawn;
[EventType]
public record MbClearingContractEnded;
[FromEvent<MbClearingContractSigned>]
public record MbClearingContract(
string Title,
// Clears this member of the nested object; the object itself stays.
[ClearWith<MbClearingNoticeWithdrawn>]
string? NoticeGiven);
public record MbClearingEmployee(
[Key] Guid Id,
// Clears the whole nested object back to null.
[Nested]
[ClearWith<MbClearingContractEnded>]
MbClearingContract? Contract);

Declaring it on the holding member rather than on the nested type is worth preferring when the nested type is shared: the owner names the event that ends its relationship, and the nested type does not have to know about it.

  • Emptying a collection. ClearWith on a ChildrenFrom collection is not a supported way to remove every item; use RemovedWith on the child, described in Removal.
  • Deleting the whole read model. That is a root-level RemovedWith, not a clear.
  • A member that should fall back to a default rather than to nothing. Say so with SetValue; the declaration then records the value on purpose instead of leaving a reader to guess.

clear is a statement of its own, alongside increment, decrement and count. It names the member the event empties:

projection Notes => MbClearingProjectNotes
from MbClearingProjectNoted
note = note
from MbClearingProjectNoteCleared
clear note

A clear reaches a member of a nested object through its path — the same clear the MbClearingContract example above declares with an attribute:

projection Employees => MbClearingEmployee
from MbClearingNoticeWithdrawn
clear contract.noticeGiven

note = null compiles to exactly the same mapping and keeps working, so existing declarations need no change. Prefer clear in new ones: assigning a value and taking one away are different acts, and spelling both with = hides that. It is also what you get back — when Chronicle generates a declaration from a projection definition it writes clear, whichever spelling the declaration was authored in. See the Projection Declaration Language for the rest of the syntax.

Clear is an operation in its own right, alongside Set, Increment and Count. It is available everywhere Set is — at the root, inside Children, and inside Nested.

Clear at the root, on a child and inside a nested object
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections;
[EventType]
public record MbClearingFluentNoted(string Note);
[EventType]
public record MbClearingFluentNoteCleared;
[EventType]
public record MbClearingFluentSummarised(string Headline, string Note);
[EventType]
public record MbClearingFluentSummaryNoteCleared;
[EventType]
public record MbClearingFluentTaskAdded(Guid TaskId, string Title, string Note);
[EventType]
public record MbClearingFluentTaskNoteCleared(Guid TaskId);
public record MbClearingFluentSummary(string Headline, string? Note);
public record MbClearingFluentTask([Key] Guid Id, string Title, string? Note);
public record MbClearingFluentProject(
[Key] Guid Id,
string? Note,
MbClearingFluentSummary? Summary,
IReadOnlyList<MbClearingFluentTask> Tasks);
public class MbClearingFluentProjectProjection : IProjectionFor<MbClearingFluentProject>
{
public void Define(IProjectionBuilderFor<MbClearingFluentProject> builder) => builder
.From<MbClearingFluentNoted>(_ => _
.Set(m => m.Note).To(e => e.Note))
.From<MbClearingFluentNoteCleared>(_ => _
.Clear(m => m.Note))
.Nested(m => m.Summary, summary => summary
.From<MbClearingFluentSummarised>(_ => _
.Set(m => m.Headline).To(e => e.Headline)
.Set(m => m.Note).To(e => e.Note))
.From<MbClearingFluentSummaryNoteCleared>(_ => _
.Clear(m => m.Note)))
.Children(m => m.Tasks, tasks => tasks
.IdentifiedBy(_ => _.Id)
.From<MbClearingFluentTaskAdded>(_ => _
.UsingKey(e => e.TaskId)
.Set(m => m.Title).To(e => e.Title)
.Set(m => m.Note).To(e => e.Note))
.From<MbClearingFluentTaskNoteCleared>(_ => _
.UsingKey(e => e.TaskId)
.Clear(m => m.Note)));
}

Set(...).ToValue(null) means the same thing and keeps working, so existing projections need no change. Prefer Clear in new code: it names the operation instead of spelling it as a set of a value that happens to be absent, and it reads the same as the ClearWith attribute.

Clear carries a default implementation that throws ClearNotSupported, so adding it to the builder interface does not break an implementation written outside Chronicle: such a builder keeps compiling and only fails if it is actually asked to clear something, which it could not have been before the member existed.

The nullable rule is enforced here too. C# cannot express “a nullable-annotated reference type” as a generic constraint — a non-nullable argument converts to a nullable parameter without complaint — so Clear cannot refuse a non-nullable member at its signature. Building the projection refuses it instead, throwing CannotClearNonNullableMember, and CHR0048 reports both Clear and ToValue(null) at build time.