Skip to content

Unique property constraint

Adorn a property with [Unique] to enforce that the value of that property is unique across every event of this type in the event store.

Chronicle discovers [Unique]-adorned properties automatically and registers the constraints with the Kernel when the client starts.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsModelBoundUniqueProjectCreated([property: Unique] string Name, string Description);

View C# snippet source on GitHub

A unique property value belongs to an event source, not to a single append. The same event source may append its current value again without violating the constraint. If it claims a different value, the new value replaces its previous claim, freeing the old value for another event source.

Do not constrain a property containing the event source’s own id to prevent a second event on that source: it will re-claim its own value and pass. To allow an event type only once per event source, use a unique event type constraint instead.

If one project accumulates many version ids, retaining only its latest id lets another project reuse an earlier one. Set Mode = UniqueConstraintMode.PerValue to reserve every value until you explicitly release it. The default, PerEventSource, keeps the replacement behavior described above. Per-value mode is available through the .NET client for now.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ScheduleVersionRegistered(
[property: Unique("ScheduleVersionId", Mode = UniqueConstraintMode.PerValue)] string VersionId);
[EventType]
[RemoveConstraint("ScheduleVersionId", Properties = [nameof(VersionId)])]
public record ScheduleVersionRemoved(string VersionId);
[EventType]
[RemoveConstraint("ScheduleVersionId")]
public record ScheduleVersionsRemoved;

View C# snippet source on GitHub

Appending two different version ids for the same project now keeps both reserved. Another project cannot claim either; the original project may append either again without a violation. ScheduleVersionRemoved releases only the id carried by that event, and only if its event source owns that value. ScheduleVersionsRemoved, which declares no removal properties, releases all values held by that event source in the constraint’s scope.

For a composite value, list removal properties in the same order as the constrained properties. A value-carrying event with no constrained properties present claims nothing and does not release earlier values. Properties sharing a constraint name must declare the same mode.

[Unique] works on ConceptAs<T> properties — the idiomatic way to model a domain value in Cratis. Chronicle treats the concept as the underlying primitive it wraps (string, Guid, and so on), so a uniqueness rule on a strongly-typed EmailAddress or OrganizationNumber behaves exactly like one on the raw primitive, and registers and enforces identically through the declarative form too:

using Cratis.Concepts;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
public record ConstraintsModelBoundUniqueEmailAddress(string Value) : ConceptAs<string>(Value);
[EventType]
public record ConstraintsModelBoundUniqueAuthorRegistered([property: Unique(name: "UniqueAuthorEmail")] ConstraintsModelBoundUniqueEmailAddress Email);

View C# snippet source on GitHub

When multiple event types share the same constraint name, Chronicle groups them into a single constraint. A value introduced by any of the participating events is checked against all others:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsModelBoundUniqueUserRegistered([property: Unique(name: "UniqueEmail")] string Email, string DisplayName);
[EventType]
public record ConstraintsModelBoundUniqueUserEmailChanged([property: Unique(name: "UniqueEmail")] string NewEmail);

View C# snippet source on GitHub

Both events now participate in the same UniqueEmail constraint, so neither UserRegistered nor UserEmailChanged can introduce an email address that already exists.

By default a constraint applies to every event sequence its event type is appended to, and each sequence keeps its own index. When the event is a domain fact that is also forwarded to the outbox, the forwarded copy claims the value in the outbox’s index, and only a removal event appended to the outbox can release it. Set EventSequences on [Unique] to apply the constraint only to the sequences you name. The kernel neither validates nor indexes it for any other sequence:

using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
[RemoveConstraint("ConstraintsModelBoundUniqueEventLogInvitationEmail")]
public record ConstraintsModelBoundUniqueEventLogInvitationRevoked;
[EventType]
public record ConstraintsModelBoundUniqueEventLogInvitationSent(
[property: Unique("ConstraintsModelBoundUniqueEventLogInvitationEmail", EventSequences = [EventSequenceId.LogId])] string Email);

View C# snippet source on GitHub

When several properties share a constraint name, the event sequences declared on each are combined. A property that sets no EventSequences means every event sequence, and that wins: if any property sharing the name leaves it unset, the constraint applies to every event sequence.

Changing the sequences takes effect when the client next registers its constraints. The kernel then rebuilds, in every namespace, the index of each sequence the constraint now applies to but did not before, from the events already in that sequence. A sequence the constraint no longer applies to stops validating it, and its index is no longer read until the constraint applies there again, when it is rebuilt.

The message parameter is optional. Chronicle produces a default violation message when one is not supplied. C# supports a custom violation message, and so do Kotlin and Java from io.cratis:chronicle 6.5.0: the client that registered the constraint puts its message on the violations its appends return, with {detailKey} placeholders filled in from the violation details. TypeScript uses Chronicle’s default message. Elixir accepts :message as a string or a {Module, :function} callback:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsModelBoundUniqueMessageProjectCreated([property: Unique(message: "A project with this name already exists.")] string Name, string Description);

View C# snippet source on GitHub

Apply [RemoveConstraint] to the event type that signals a domain object has been removed. When this event is appended, Chronicle releases the named constraint and its previously held values can be claimed again:

using Cratis.Concepts;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
public record ConstraintsModelBoundUniqueUserId(Guid Value) : ConceptAs<Guid>(Value);
[EventType]
[RemoveConstraint("UniqueEmail")]
public record ConstraintsModelBoundUniqueUserRemoved(ConstraintsModelBoundUniqueUserId UserId);

View C# snippet source on GitHub

The constraint name must exactly match the name used in the [Unique] attribute that established it.

An event type can release more than one constraint by stacking multiple attributes:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
[RemoveConstraint("UniqueEmail")]
[RemoveConstraint("UniqueUsername")]
public record ConstraintsModelBoundUniqueMultiRemoveUserRemoved(ConstraintsModelBoundUniqueUserId UserId);

View C# snippet source on GitHub

The reverse also holds: more than one event type can release the same constraint. Name it from each event that ends the lifecycle and every one of them frees the held values:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
[RemoveConstraint("UniqueInvitedEmail")]
public record ConstraintsModelBoundUniqueSeveralInvitationAccepted;
[EventType]
[RemoveConstraint("UniqueInvitedEmail")]
public record ConstraintsModelBoundUniqueSeveralInvitationRevoked;
[EventType]
[RemoveConstraint("UniqueInvitedEmail")]
public record ConstraintsModelBoundUniqueSeveralInvitationExpired;

View C# snippet source on GitHub

The Chronicle Kernel reads the [Unique] and [RemoveConstraint] attributes when the client connects and creates the indexes it needs. Every subsequent append is checked against those indexes server-side, so no constraint logic runs in client code.