Skip to content

Unique property constraint

Use builder.Unique(...) inside an IConstraint implementation to enforce that a property value is unique across one or more event types. The constraint fires if any tracked event would introduce a duplicate value.

Chronicle discovers all IConstraint implementations automatically — no registration is needed.

Implement IConstraint and call the builder in Define:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueProjectCreated(string Name);
[EventType]
public record ConstraintsUniqueProjectRemoved;
public class ConstraintsUniqueProjectName : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.On<ConstraintsUniqueProjectCreated>(e => e.Name)
.RemovedWith<ConstraintsUniqueProjectRemoved>());
}

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.

When one event source accumulates many identifiers, call .WithMode(UniqueConstraintMode.PerValue) to keep all of them reserved. The default, PerEventSource, keeps only the source’s current value. Per-value mode is available through the .NET client for now.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record MappingVersionRegistered(string VersionId);
[EventType]
public record MappingVersionRemoved(string VersionId);
[EventType]
public record MappingVersionsRemoved;
public class UniqueMappingVersionId : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique => unique
.WithMode(UniqueConstraintMode.PerValue)
.On<MappingVersionRegistered>(e => e.VersionId)
.RemovedWith<MappingVersionRemoved>(e => e.VersionId)
.RemovedWith<MappingVersionsRemoved>());
}

View C# snippet source on GitHub

Each mapping version id remains unavailable to other event sources even after the project registers another version. The owning source may reclaim any of its values without a violation. .RemovedWith<MappingVersionRemoved>(e => e.VersionId) releases only the value carried by the removal event, provided that source owns it. .RemovedWith<MappingVersionsRemoved>() releases all values that source holds in the constraint’s scope.

For a composite value, pass removal-property expressions in the same order as the constrained properties. An event with no constrained properties present claims nothing and leaves earlier values reserved. .IgnoreCasing() applies to both claiming and releasing values.

Use multiple .On calls when several event types each contribute to the same logical uniqueness rule. The constraint fires if any of the tracked events would introduce a duplicate value:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueUserRegistered(string Email);
[EventType]
public record ConstraintsUniqueUserEmailChanged(string NewEmail);
[EventType]
public record ConstraintsUniqueUserRemoved;
public class ConstraintsUniqueEmailAcrossEvents : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.WithName("UniqueEmail")
.On<ConstraintsUniqueUserRegistered>(e => e.Email)
.On<ConstraintsUniqueUserEmailChanged>(e => e.NewEmail)
.RemovedWith<ConstraintsUniqueUserRemoved>());
}

View C# snippet source on GitHub

Use .WithName(...) to give the constraint an explicit name. When not provided, Chronicle uses the class name of the IConstraint implementation:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueNamedUserRegistered(string Email);
public class ConstraintsUniqueNamedEmail : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.WithName("UniqueEmail")
.On<ConstraintsUniqueNamedUserRegistered>(e => e.Email));
}

View C# snippet source on GitHub

Call .RemovedWith<T>() to register the event type that releases the constraint. When that event is appended, the previously held value is freed and can be claimed again:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueOrderPlaced(string Reference);
[EventType]
public record ConstraintsUniqueOrderCancelled;
public class ConstraintsUniqueOrderReference : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.On<ConstraintsUniqueOrderPlaced>(e => e.Reference)
.RemovedWith<ConstraintsUniqueOrderCancelled>());
}

View C# snippet source on GitHub

A lifecycle often ends in more than one way — an invitation is over when it is accepted, revoked, or expires. Call .RemovedWith<T>() once for each of those events and every one of them releases the value on its own; declaring a second does not replace the first:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueSeveralInvitationSent(string EmailAddress);
[EventType]
public record ConstraintsUniqueSeveralInvitationAccepted;
[EventType]
public record ConstraintsUniqueSeveralInvitationRevoked;
[EventType]
public record ConstraintsUniqueSeveralInvitationExpired;
public class ConstraintsUniqueSeveralInvitedAddress : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.On<ConstraintsUniqueSeveralInvitationSent>(e => e.EmailAddress)
.RemovedWith<ConstraintsUniqueSeveralInvitationAccepted>()
.RemovedWith<ConstraintsUniqueSeveralInvitationRevoked>()
.RemovedWith<ConstraintsUniqueSeveralInvitationExpired>());
}

View C# snippet source on GitHub

By default a constraint applies to every event sequence its event types are appended to, and each sequence keeps its own index. When an event type 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. Call .ForEventLog() to apply the constraint to the event log alone, or .ForEventSequences(...) to name other sequences. The kernel neither validates nor indexes the constraint for a sequence it does not apply to:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueEventLogInvitationSent(string Email);
[EventType]
public record ConstraintsUniqueEventLogInvitationRevoked;
public class ConstraintsUniqueEventLogInvitationEmail : IConstraint
{
// InvitationSent is also forwarded to the outbox. Scope the constraint to the event log
// so the forwarded copy does not claim the email in the outbox's own index.
public void Define(IConstraintBuilder builder) =>
builder
.ForEventLog()
.Unique(unique =>
unique
.On<ConstraintsUniqueEventLogInvitationSent>(e => e.Email)
.RemovedWith<ConstraintsUniqueEventLogInvitationRevoked>());
}

View C# snippet source on GitHub

The declaration covers every constraint defined on that builder, wherever in the chain it is called, and calling .ForEventSequences(...) again adds to the sequences already named. A builder without either call applies its constraints 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.

Call .IgnoreCasing() to make the uniqueness check case-insensitive:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueCasingUserRegistered(string Email);
public class ConstraintsUniqueCasingEmail : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.On<ConstraintsUniqueCasingUserRegistered>(e => e.Email)
.IgnoreCasing());
}

View C# snippet source on GitHub

Call .WithMessage(...) to provide a custom message when the constraint is violated:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueMessageProjectCreated(string Name);
public class ConstraintsUniqueMessageProjectName : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.On<ConstraintsUniqueMessageProjectCreated>(e => e.Name)
.WithMessage("A project with this name already exists."));
}

View C# snippet source on GitHub

Use a callback to compose the message dynamically from violation context:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Events.Constraints;
[EventType]
public record ConstraintsUniqueMessageCallbackProjectCreated(string Name);
public class ConstraintsUniqueMessageCallbackProjectName : IConstraint
{
public void Define(IConstraintBuilder builder) =>
builder.Unique(unique =>
unique
.On<ConstraintsUniqueMessageCallbackProjectCreated>(e => e.Name)
.WithMessage(violation => $"A project named '{violation.Details[WellKnownConstraintDetailKeys.PropertyValue]}' already exists."));
}

View C# snippet source on GitHub

When a constraint is registered, the Chronicle Kernel creates the indexes required to enforce it. Constraints are evaluated server-side during append, ensuring data integrity regardless of the client.

When a constraint changes, Chronicle rebuilds its index from the event sequence in log order. It clears all claims in each scope before replaying that scope’s first event, so stale claims do not block historical values.

If the store rejects a duplicate claim during the rebuild, Chronicle logs a warning, skips that claim, and continues. The first historical claimant keeps the value until an event releases or replaces it. A live append during the rebuild can claim a value before its historical claimant is replayed; the log-order rule applies to history, not to concurrent live appends.