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.
Defining a unique property constraint
Section titled “Defining a unique property constraint”using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsModelBoundUniqueProjectCreated([property: Unique] string Name, string Description);import io.cratis.chronicle.constraints.Uniqueimport io.cratis.chronicle.events.EventType
@EventTypedata class ConstraintsModelBoundUniqueProjectCreated(@Unique val name: String, val description: String)import io.cratis.chronicle.constraints.Unique;import io.cratis.chronicle.events.EventType;
@EventTyperecord ConstraintsModelBoundUniqueProjectCreated(@Unique String name, String description) {}defmodule MyApp.Events.ConstraintsModelBoundUniqueProjectCreated do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-project-created"
defstruct [:name, :description]
unique(:name)endimport { eventType, unique } from '@cratis/chronicle';
@eventType('constraints-model-bound-project-created')class CmbProjectCreated { @unique() name = ''; description = '';}Re-claiming a value
Section titled “Re-claiming a value”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.
Keeping every value
Section titled “Keeping every value”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;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.
Strongly-typed properties
Section titled “Strongly-typed properties”[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);import io.cratis.chronicle.concepts.ConceptAsimport io.cratis.chronicle.constraints.Uniqueimport io.cratis.chronicle.events.EventType
data class ConstraintsModelBoundUniqueEmailAddress(override val value: String) : ConceptAs<String>
@EventTypedata class ConstraintsModelBoundUniqueAuthorRegistered( @Unique(id = "UniqueAuthorEmail") val email: ConstraintsModelBoundUniqueEmailAddress)import io.cratis.chronicle.concepts.ConceptAs;import io.cratis.chronicle.constraints.Unique;import io.cratis.chronicle.events.EventType;
record ConstraintsModelBoundUniqueEmailAddress(String value) implements ConceptAs<String> { @Override public String getValue() { return value; }}
@EventTyperecord ConstraintsModelBoundUniqueAuthorRegistered( @Unique(id = "UniqueAuthorEmail") ConstraintsModelBoundUniqueEmailAddress email) {}defmodule MyApp.Concepts.ConstraintsModelBoundUniqueEmailAddress do use Chronicle.Concept, type: :stringend
defmodule MyApp.Events.ConstraintsModelBoundUniqueAuthorRegistered do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-author-registered"
unique :email, name: "UniqueAuthorEmail"
defstruct email: %MyApp.Concepts.ConstraintsModelBoundUniqueEmailAddress{}endimport { ConceptAs } from '@cratis/fundamentals';import { eventType, unique } from '@cratis/chronicle';
class CmbEmailAddress extends ConceptAs<string> { static readonly valueType = String;}
@eventType('constraints-model-bound-author-registered')class CmbAuthorRegistered { @unique('UniqueAuthorEmail') email = new CmbEmailAddress('');}Grouping constraints across event types
Section titled “Grouping constraints across event types”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);import io.cratis.chronicle.constraints.Uniqueimport io.cratis.chronicle.events.EventType
@EventTypedata class ConstraintsModelBoundUniqueUserRegistered( @Unique(id = "UniqueEmail") val email: String, val displayName: String)
@EventTypedata class ConstraintsModelBoundUniqueUserEmailChanged(@Unique(id = "UniqueEmail") val newEmail: String)import io.cratis.chronicle.constraints.Unique;import io.cratis.chronicle.events.EventType;
@EventTyperecord ConstraintsModelBoundUniqueUserRegistered(@Unique(id = "UniqueEmail") String email, String displayName) {}
@EventTyperecord ConstraintsModelBoundUniqueUserEmailChanged(@Unique(id = "UniqueEmail") String newEmail) {}defmodule MyApp.Events.ConstraintsModelBoundUniqueUserRegistered do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-user-registered"
defstruct [:email, :display_name]
unique(:email, name: "UniqueEmail")end
defmodule MyApp.Events.ConstraintsModelBoundUniqueUserEmailChanged do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-user-email-changed"
defstruct [:new_email]
unique(:new_email, name: "UniqueEmail")endimport { eventType, unique } from '@cratis/chronicle';
@eventType('constraints-model-bound-user-registered')class CmbUserRegistered { @unique('UniqueEmail') email = ''; displayName = '';}
@eventType('constraints-model-bound-user-email-changed')class CmbUserEmailChanged { @unique('UniqueEmail') newEmail = '';}Both events now participate in the same UniqueEmail constraint, so neither UserRegistered nor UserEmailChanged can introduce an email address that already exists.
Scoping to the event log
Section titled “Scoping to the event log”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);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.
Violation message
Section titled “Violation message”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);// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.constraints.Uniqueimport io.cratis.chronicle.events.EventType
@EventTypedata class ConstraintsModelBoundUniqueMessageProjectCreated( @Unique(message = "A project with this name already exists.") val name: String, val description: String)// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.constraints.Unique;import io.cratis.chronicle.events.EventType;
@EventTyperecord ConstraintsModelBoundUniqueMessageProjectCreated( @Unique(message = "A project with this name already exists.") String name, String description) {}defmodule MyApp.Events.ConstraintsModelBoundUniqueMessageProjectCreated do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-message-project-created"
defstruct [:name, :description]
unique(:name, message: "A project with this name already exists.")endimport { eventType, unique } from '@cratis/chronicle';
@eventType('constraints-model-bound-message-project-created')class CmbMessageProjectCreated { @unique(undefined, 'A project with this name already exists.') name = ''; description = '';}Releasing a constraint
Section titled “Releasing a constraint”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);import io.cratis.chronicle.constraints.RemoveConstraintimport io.cratis.chronicle.events.EventType
@EventType@RemoveConstraint("UniqueEmail")data class ConstraintsModelBoundUniqueUserRemoved(val userId: String)import io.cratis.chronicle.constraints.RemoveConstraint;import io.cratis.chronicle.events.EventType;
@EventType@RemoveConstraint("UniqueEmail")record ConstraintsModelBoundUniqueUserRemoved(String userId) {}defmodule MyApp.Events.ConstraintsModelBoundUniqueUserRemoved do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-user-removed"
defstruct [:user_id]
remove_constraint("UniqueEmail")endimport { eventType, removeConstraint } from '@cratis/chronicle';
@eventType('constraints-model-bound-user-removed')@removeConstraint('UniqueEmail')class CmbUserRemoved { userId = '';}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);import io.cratis.chronicle.constraints.RemoveConstraintimport io.cratis.chronicle.events.EventType
@EventType@RemoveConstraint("UniqueEmail")@RemoveConstraint("UniqueUsername")data class ConstraintsModelBoundUniqueMultiRemoveUserRemoved(val userId: String)import io.cratis.chronicle.constraints.RemoveConstraint;import io.cratis.chronicle.events.EventType;
@EventType@RemoveConstraint("UniqueEmail")@RemoveConstraint("UniqueUsername")record ConstraintsModelBoundUniqueMultiRemoveUserRemoved(String userId) {}defmodule MyApp.Events.ConstraintsModelBoundUniqueMultiRemoveUserRemoved do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-multi-remove-user-removed"
defstruct [:user_id]
remove_constraint("UniqueEmail") remove_constraint("UniqueUsername")endimport { eventType, removeConstraint } from '@cratis/chronicle';
@eventType('constraints-model-bound-user-multi-removed')@removeConstraint('UniqueEmail')@removeConstraint('UniqueUsername')class CmbUserMultiRemoved { userId = '';}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;Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.ConstraintsModelBoundUniqueSeveralInvitationAccepted do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-several-invitation-accepted"
defstruct []
remove_constraint("UniqueInvitedEmail")end
defmodule MyApp.Events.ConstraintsModelBoundUniqueSeveralInvitationRevoked do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-several-invitation-revoked"
defstruct []
remove_constraint("UniqueInvitedEmail")end
defmodule MyApp.Events.ConstraintsModelBoundUniqueSeveralInvitationExpired do use Chronicle.Events.EventType, id: "constraints-model-bound-unique-several-invitation-expired"
defstruct []
remove_constraint("UniqueInvitedEmail")endimport { eventType, removeConstraint } from '@cratis/chronicle';
@eventType('constraints-model-bound-invitation-accepted')@removeConstraint('UniqueInvitedEmail')class CmbInvitationAccepted {}
@eventType('constraints-model-bound-invitation-revoked')@removeConstraint('UniqueInvitedEmail')class CmbInvitationRevoked {}
@eventType('constraints-model-bound-invitation-expired')@removeConstraint('UniqueInvitedEmail')class CmbInvitationExpired {}How constraints are enforced
Section titled “How constraints are enforced”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.