Unique event type constraint
Use builder.Unique<TEventType>() inside an IConstraint implementation to enforce that only one event of a specific type can be appended per event source identifier. Use this when the event itself is a unique fact — for example, initializing a project can only happen once per project.
Chronicle discovers all IConstraint implementations automatically — no registration is needed.
Defining a unique event type constraint
Section titled “Defining a unique event type constraint”using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsUniqueEventTypeProjectInitialized;
public class ConstraintsUniqueEventTypeProjectInitialization : IConstraint{ public void Define(IConstraintBuilder builder) => builder.Unique<ConstraintsUniqueEventTypeProjectInitialized>();}import io.cratis.chronicle.constraints.Constraintimport io.cratis.chronicle.constraints.IConstraintimport io.cratis.chronicle.constraints.IConstraintBuilderimport io.cratis.chronicle.events.EventType
@EventTypeclass ConstraintsUniqueEventTypeProjectInitialized
@Constraintclass ConstraintsUniqueEventTypeProjectInitialization : IConstraint { override fun define(builder: IConstraintBuilder) { builder.uniqueFor(ConstraintsUniqueEventTypeProjectInitialized::class) }}import io.cratis.chronicle.constraints.Constraint;import io.cratis.chronicle.constraints.IConstraint;import io.cratis.chronicle.constraints.IConstraintBuilder;import io.cratis.chronicle.events.EventType;
@EventTypeclass ConstraintsUniqueEventTypeProjectInitialized {}
@Constraintclass ConstraintsUniqueEventTypeProjectInitialization implements IConstraint { @Override public void define(IConstraintBuilder builder) { builder.uniqueFor(ConstraintsUniqueEventTypeProjectInitialized.class, ""); }}defmodule MyApp.Events.ConstraintsUniqueEventTypeProjectInitialized do use Chronicle.Events.EventType, id: "constraints-unique-event-type-project-initialized"
unique_event_type()
defstruct []endimport { constraint, eventType, IConstraint, IConstraintBuilder } from '@cratis/chronicle';
@eventType()class ConstraintsUniqueEventTypeProjectInitialized {}
@constraint()class ConstraintsUniqueEventTypeProjectInitialization implements IConstraint { define(builder: IConstraintBuilder): void { builder.uniqueFor(ConstraintsUniqueEventTypeProjectInitialized); }}Violation message
Section titled “Violation message”Provide a static message string:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsUniqueEventTypeMessageProjectInitialized;
public class ConstraintsUniqueEventTypeMessageProjectInitialization : IConstraint{ public void Define(IConstraintBuilder builder) => builder.Unique<ConstraintsUniqueEventTypeMessageProjectInitialized>( message: "A project can only be initialized once.");}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.constraints.Constraintimport io.cratis.chronicle.constraints.IConstraintimport io.cratis.chronicle.constraints.IConstraintBuilderimport io.cratis.chronicle.events.EventType
@EventTypeclass ConstraintsUniqueEventTypeMessageProjectInitialized
@Constraintclass ConstraintsUniqueEventTypeMessageProjectInitialization : IConstraint { override fun define(builder: IConstraintBuilder) { builder.uniqueFor( ConstraintsUniqueEventTypeMessageProjectInitialized::class, message = "A project can only be initialized once." ) }}// Requires io.cratis:chronicle 6.5.0 or later.import io.cratis.chronicle.constraints.Constraint;import io.cratis.chronicle.constraints.IConstraint;import io.cratis.chronicle.constraints.IConstraintBuilder;import io.cratis.chronicle.events.EventType;
@EventTypeclass ConstraintsUniqueEventTypeMessageProjectInitialized {}
@Constraintclass ConstraintsUniqueEventTypeMessageProjectInitialization implements IConstraint { @Override public void define(IConstraintBuilder builder) { builder.uniqueFor(ConstraintsUniqueEventTypeMessageProjectInitialized.class, "A project can only be initialized once."); }}defmodule MyApp.Events.ConstraintsUniqueEventTypeMessageProjectInitialized do use Chronicle.Events.EventType, id: "constraints-unique-event-type-message-project-initialized" defstruct []end
defmodule MyApp.Constraints.ConstraintsUniqueEventTypeMessageProjectInitialization do use Chronicle.Events.Constraint
unique_event_type MyApp.Events.ConstraintsUniqueEventTypeMessageProjectInitialized, name: "ConstraintsUniqueEventTypeMessageProjectInitialization", message: "A project can only be initialized once."endimport { constraint, eventType, IConstraint, IConstraintBuilder } from '@cratis/chronicle';
@eventType()class ConstraintsUniqueEventTypeMessageProjectInitialized {}
@constraint()class ConstraintsUniqueEventTypeMessageProjectInitialization implements IConstraint { define(builder: IConstraintBuilder): void { builder.uniqueFor(ConstraintsUniqueEventTypeMessageProjectInitialized, 'A project can only be initialized once.'); }}Or use a callback to compose the message dynamically from violation context:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsUniqueEventTypeCallbackProjectInitialized;
public class ConstraintsUniqueEventTypeCallbackProjectInitialization : IConstraint{ public void Define(IConstraintBuilder builder) => builder.Unique<ConstraintsUniqueEventTypeCallbackProjectInitialized>( messageCallback: violation => $"Constraint '{violation.ConstraintName}' was violated - the project has already been initialized.");}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.ConstraintsUniqueEventTypeCallbackProjectInitialized do use Chronicle.Events.EventType, id: "constraints-unique-event-type-callback-project-initialized" defstruct []end
defmodule MyApp.Constraints.ConstraintsUniqueEventTypeCallbackMessages do def project_already_initialized(violation) do "Constraint '#{violation."ConstraintName"}' was violated - the project has already been initialized." endend
defmodule MyApp.Constraints.ConstraintsUniqueEventTypeCallbackProjectInitialization do use Chronicle.Events.Constraint
unique_event_type MyApp.Events.ConstraintsUniqueEventTypeCallbackProjectInitialized, name: "ConstraintsUniqueEventTypeCallbackProjectInitialization", message: {MyApp.Constraints.ConstraintsUniqueEventTypeCallbackMessages, :project_already_initialized}endTypeScript does not support this workflow yet.Constraint name
Section titled “Constraint name”An optional name can be provided to identify the constraint. When not provided, Chronicle uses the event type name as the default constraint name:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsUniqueEventTypeNamedProjectInitialized;
public class ConstraintsUniqueEventTypeNamedProjectInitialization : IConstraint{ public void Define(IConstraintBuilder builder) => builder.Unique<ConstraintsUniqueEventTypeNamedProjectInitialized>( name: "UniqueProjectInitialization", message: "A project can only be initialized once.");}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.ConstraintsUniqueEventTypeNamedProjectInitialized do use Chronicle.Events.EventType, id: "constraints-unique-event-type-named-project-initialized" defstruct []end
defmodule MyApp.Constraints.ConstraintsUniqueEventTypeNamedProjectInitialization do use Chronicle.Events.Constraint
unique_event_type MyApp.Events.ConstraintsUniqueEventTypeNamedProjectInitialized, name: "UniqueProjectInitialization", message: "A project can only be initialized once."endimport { constraint, eventType, IConstraint, IConstraintBuilder } from '@cratis/chronicle';
@eventType()class ConstraintsUniqueEventTypeNamedProjectInitialized {}
@constraint()class ConstraintsUniqueEventTypeNamedProjectInitialization implements IConstraint { define(builder: IConstraintBuilder): void { builder.uniqueFor( ConstraintsUniqueEventTypeNamedProjectInitialized, 'A project can only be initialized once.', 'UniqueProjectInitialization'); }}Releasing a constraint
Section titled “Releasing a constraint”Left alone the constraint says “at most one, ever”. Most lifecycles repeat — a shift is worked and ended, a loan is taken and returned, a subscription is activated and cancelled — and for those, “ever” refuses the second legitimate cycle and there is nothing the caller can do about it.
Call .RemovedWith<T>() to name the event that ends a cycle. A covered event then violates the constraint only when it comes after the most recent removal event on that event source, so the next cycle is free to start:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsUniqueEventTypeShiftStarted(string Location);
[EventType]public record ConstraintsUniqueEventTypeShiftEnded;
public class ConstraintsUniqueEventTypeOneOpenShift : IConstraint{ // At most one open shift per employee. Ending the shift releases the constraint, // so the next shift is allowed - without it the constraint could only say // "at most one, ever", and the employee's second shift would be refused forever. public void Define(IConstraintBuilder builder) => builder .Unique<ConstraintsUniqueEventTypeShiftStarted>() .RemovedWith<ConstraintsUniqueEventTypeShiftEnded>();}import io.cratis.chronicle.constraints.RemoveConstraintimport io.cratis.chronicle.constraints.Uniqueimport io.cratis.chronicle.events.EventType
// Model-bound equivalent: only one open shift per employee/event source.@EventType@Unique(id = "ConstraintsUniqueEventTypeOneOpenShift")data class ConstraintsUniqueEventTypeShiftStarted(val location: String)
@EventType@RemoveConstraint("ConstraintsUniqueEventTypeOneOpenShift")class ConstraintsUniqueEventTypeShiftEndedimport io.cratis.chronicle.constraints.RemoveConstraint;import io.cratis.chronicle.constraints.Unique;import io.cratis.chronicle.events.EventType;
// Model-bound equivalent: only one open shift per employee/event source.@EventType@Unique(id = "ConstraintsUniqueEventTypeOneOpenShift")record ConstraintsUniqueEventTypeShiftStarted(String location) {}
@EventType@RemoveConstraint("ConstraintsUniqueEventTypeOneOpenShift")record ConstraintsUniqueEventTypeShiftEnded() {}defmodule MyApp.Events.ConstraintsUniqueEventTypeShiftStarted do use Chronicle.Events.EventType, id: "constraints-unique-event-type-shift-started"
unique_event_type(name: "OneOpenShift")
defstruct location: ""end
defmodule MyApp.Events.ConstraintsUniqueEventTypeShiftEnded do use Chronicle.Events.EventType, id: "constraints-unique-event-type-shift-ended"
remove_constraint "OneOpenShift"
defstruct []endThe TypeScript client does not support this workflow yet.uniqueFor() cannot configure a removal event. Using unique().on(EventType) without properties does not enforce event-type uniqueness.The removal applies to the constraint declared immediately before it, and it is per event source — one employee ending their shift opens their own next cycle and nobody else’s.
A cycle can end in more than one way, so chain .RemovedWith<T>() once for each event that ends it. Every one of them releases the constraint on its own, and the cycle ends at whichever was appended most recently:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsUniqueEventTypeSeveralLoanCheckedOut(string Title);
[EventType]public record ConstraintsUniqueEventTypeSeveralLoanReturned;
[EventType]public record ConstraintsUniqueEventTypeSeveralLoanWrittenOff;
public class ConstraintsUniqueEventTypeSeveralOneOpenLoan : IConstraint{ // A loan is open until it is returned or written off. Both end the cycle, so the // borrower can take the next loan whichever way the previous one finished. public void Define(IConstraintBuilder builder) => builder .Unique<ConstraintsUniqueEventTypeSeveralLoanCheckedOut>() .RemovedWith<ConstraintsUniqueEventTypeSeveralLoanReturned>() .RemovedWith<ConstraintsUniqueEventTypeSeveralLoanWrittenOff>();}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.ConstraintsUniqueEventTypeSeveralLoanCheckedOut do use Chronicle.Events.EventType, id: "constraints-unique-event-type-several-loan-checked-out" defstruct title: ""end
defmodule MyApp.Events.ConstraintsUniqueEventTypeSeveralLoanReturned do use Chronicle.Events.EventType, id: "constraints-unique-event-type-several-loan-returned" defstruct []end
defmodule MyApp.Events.ConstraintsUniqueEventTypeSeveralLoanWrittenOff do use Chronicle.Events.EventType, id: "constraints-unique-event-type-several-loan-written-off" defstruct []end
# A loan is open until it is returned or written off. Both end the cycle, so the borrower can# take the next loan whichever way the previous one finished.defmodule MyApp.Constraints.ConstraintsUniqueEventTypeSeveralOneOpenLoan do use Chronicle.Events.Constraint
unique_event_type MyApp.Events.ConstraintsUniqueEventTypeSeveralLoanCheckedOut, name: "ConstraintsUniqueEventTypeSeveralOneOpenLoan"
removed_with MyApp.Events.ConstraintsUniqueEventTypeSeveralLoanReturned removed_with MyApp.Events.ConstraintsUniqueEventTypeSeveralLoanWrittenOffendThe TypeScript client does not support this workflow yet.uniqueFor() cannot configure removal events. Repeated removedWith() calls are supported for unique property constraints, not unique event-type constraints.Declaring several event types under one name makes them a single constraint, and the removal events declared across those declarations all apply to it.
The model-bound form expresses the same thing with [RemoveConstraint] on the event that ends the cycle.
Scoping the constraint
Section titled “Scoping the constraint”By default the rule is “at most one per event source identifier”, wherever the event is appended. Some rules are narrower than that: one open loan per borrower per branch, one active session per user per device type. Call PerEventStreamId(), PerEventStreamType() or PerEventSourceType() before declaring the constraint and it holds one cycle per value of that dimension instead of one for the event source as a whole:
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsUniqueEventTypeScopedLoanCheckedOut(string Title);
[EventType]public record ConstraintsUniqueEventTypeScopedLoanReturned;
public class ConstraintsUniqueEventTypeOneOpenLoanPerBranch : IConstraint{ // One open loan per borrower per branch. The stream the event is appended to decides // which cycle it belongs to, so the same borrower can hold one open loan at every // branch, and returning at one branch opens the next cycle only there. public void Define(IConstraintBuilder builder) => builder .PerEventStreamId() .Unique<ConstraintsUniqueEventTypeScopedLoanCheckedOut>() .RemovedWith<ConstraintsUniqueEventTypeScopedLoanReturned>();}import io.cratis.chronicle.constraints.Constraintimport io.cratis.chronicle.constraints.IConstraintimport io.cratis.chronicle.constraints.IConstraintBuilderimport io.cratis.chronicle.events.EventType
@EventTypedata class ConstraintsUniqueEventTypeScopedLoanCheckedOut(val title: String)
@Constraintclass ConstraintsUniqueEventTypeOneLoanPerBranch : IConstraint { // One loan per borrower per branch. The stream the event is appended to decides which // branch it belongs to, so the same borrower can hold one loan at every branch. // The Kotlin fluent builder has no removedWith, so this scoped constraint is never // released by a later event - each borrower/branch pair gets exactly one checkout. override fun define(builder: IConstraintBuilder) { builder .perEventStreamId() .uniqueFor(ConstraintsUniqueEventTypeScopedLoanCheckedOut::class) }}import io.cratis.chronicle.constraints.Constraint;import io.cratis.chronicle.constraints.IConstraint;import io.cratis.chronicle.constraints.IConstraintBuilder;import io.cratis.chronicle.events.EventType;
@EventTyperecord ConstraintsUniqueEventTypeScopedLoanCheckedOut(String title) {}
@Constraintclass ConstraintsUniqueEventTypeOneLoanPerBranch implements IConstraint { // One loan per borrower per branch. The stream the event is appended to decides which // branch it belongs to, so the same borrower can hold one loan at every branch. // The Java fluent builder has no removedWith, so this scoped constraint is never // released by a later event - each borrower/branch pair gets exactly one checkout. @Override public void define(IConstraintBuilder builder) { builder .perEventStreamId() .uniqueFor(ConstraintsUniqueEventTypeScopedLoanCheckedOut.class); }}defmodule MyApp.Events.ConstraintsUniqueEventTypeScopedLoanCheckedOut do use Chronicle.Events.EventType, id: "constraints-unique-event-type-scoped-loan-checked-out" defstruct title: ""end
defmodule MyApp.Events.ConstraintsUniqueEventTypeScopedLoanReturned do use Chronicle.Events.EventType, id: "constraints-unique-event-type-scoped-loan-returned" defstruct []end
# One open loan per borrower per branch. The stream the event is appended to decides which# cycle it belongs to, so the same borrower can hold one open loan at every branch, and# returning at one branch opens the next cycle only there.defmodule MyApp.Constraints.ConstraintsUniqueEventTypeOneOpenLoanPerBranch do use Chronicle.Events.Constraint
unique_event_type MyApp.Events.ConstraintsUniqueEventTypeScopedLoanCheckedOut, name: "ConstraintsUniqueEventTypeOneOpenLoanPerBranch", scope: :per_event_stream_id
removed_with MyApp.Events.ConstraintsUniqueEventTypeScopedLoanReturnedendimport { constraint, eventType, IConstraint, IConstraintBuilder } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConstraintsUniqueEventTypeScopedLoanCheckedOut { @field(String) readonly title: string;
constructor(title: string) { this.title = title; }}
@constraint()class ConstraintsUniqueEventTypeOneLoanPerBranch implements IConstraint { // One checkout per borrower (event source) per branch (event stream). // The TypeScript uniqueFor API cannot configure a removal event yet, // so returning a loan does not release this constraint for another checkout. define(builder: IConstraintBuilder): void { builder .perEventStreamId() .uniqueFor(ConstraintsUniqueEventTypeScopedLoanCheckedOut); }}The dimensions of the event being appended decide which cycle it belongs to:
| Scope | One cycle per |
|---|---|
| (none) | event source identifier |
PerEventSourceType() | event source identifier + event source type |
PerEventStreamType() | event source identifier + event stream type |
PerEventStreamId() | event source identifier + event stream id |
Chain several of them and every declared dimension has to match for two events to share a cycle — one differing dimension is a different cycle.
Scoping narrows the rule; it never weakens it. Within one scope the constraint behaves exactly as an unscoped one: a second covered event is refused, and only a removal event in that same scope opens the next cycle. A removal appended elsewhere — another branch, another stream type — leaves this scope’s cycle exactly as it was.
Custom storage providers
Section titled “Custom storage providers”Scoping is enforced by the storage provider, so a provider has to be able to see which scope an append belongs to. The storage interface IUniqueEventTypesConstraintsStorage therefore has two members:
| Member | Narrows by | Who should implement it |
|---|---|---|
IsAllowedWithinScope(definition, eventSourceId, scope) | the appending event’s typed dimension values | every provider |
IsAllowed(definition, eventSourceId, scopeKey) | a single flattened string key | every provider, for compatibility — it stays abstract on the interface |
The engine only ever calls IsAllowedWithinScope. A provider that implements just IsAllowed still works: the interface’s default implementation flattens the scope into exactly the key that provider already received, so its behavior is carried over unchanged. It is not thereby corrected — a flattened key cannot tell two scopes apart when a dimension value contains the characters the key joins on, and that is the whole reason the typed member exists. Scoped isolation is guaranteed only for providers that implement IsAllowedWithinScope. If you maintain a custom provider and rely on scoping, implement it. Because IsAllowed stays abstract, a new provider still has to supply it too — a small legacy wrapper is enough: an empty or omitted key answers the unscoped lookup through IsAllowedWithinScope; a non-empty key has no typed dimensions to reconstruct, so it should fail closed with NotSupportedException rather than guess.
The providers shipped with Chronicle — MongoDB, SQL and in-memory — implement the typed member and narrow natively per dimension. Calling their IsAllowed with a non-empty key throws NotSupportedException rather than answering against a scope it cannot reconstruct; an empty or omitted key is an unscoped lookup and is answered as one.
Appending several events at once
Section titled “Appending several events at once”A constraint is checked against everything the append will produce, not only what is already stored. When you append several events in a single call, each event is checked in order against the events before it in that same call, so two covered events for the same event source (and scope) in one call are refused just as they would be across two separate calls — and a removal event earlier in the call opens the next cycle for a covered event later in it. Either the whole call is appended or none of it is.
Mutually exclusive event types
Section titled “Mutually exclusive event types”Declare several event types under the same constraint name and they become one constraint: at most one event drawn from that set is allowed per event source. Use it when an event source has more than one terminal outcome and the outcomes exclude each other — cancelled or completed, merged or erased, approved or permanently rejected.
using Cratis.Chronicle.Events;using Cratis.Chronicle.Events.Constraints;
[EventType]public record ConstraintsPersonAliasedTo(Guid Target);
[EventType]public record ConstraintsPersonErased;
public class ConstraintsPersonTerminalOutcome : IConstraint{ // Both declarations share one constraint name, so they become a single constraint: // at most one event drawn from { ConstraintsPersonAliasedTo, ConstraintsPersonErased } // per person. A person merged away can no longer be erased, and neither event can // occur twice. public void Define(IConstraintBuilder builder) { builder.Unique<ConstraintsPersonAliasedTo>( name: "PersonTerminal", message: "This person already has a terminal outcome.");
builder.Unique<ConstraintsPersonErased>( name: "PersonTerminal", message: "This person already has a terminal outcome."); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.defmodule MyApp.Events.ConstraintsPersonAliasedTo do use Chronicle.Events.EventType, id: "constraints-person-aliased-to" defstruct target: ""end
defmodule MyApp.Events.ConstraintsPersonErased do use Chronicle.Events.EventType, id: "constraints-person-erased" defstruct []end
# Both declarations share one constraint name, so they become a single constraint: at most# one event drawn from {ConstraintsPersonAliasedTo, ConstraintsPersonErased} per person. A# person merged away can no longer be erased, and neither event can occur twice.defmodule MyApp.Constraints.ConstraintsPersonTerminalOutcome do use Chronicle.Events.Constraint
unique_event_type MyApp.Events.ConstraintsPersonAliasedTo, name: "PersonTerminal", message: "This person already has a terminal outcome."
unique_event_type MyApp.Events.ConstraintsPersonErased, name: "PersonTerminal", message: "This person already has a terminal outcome."endimport { constraint, eventType, IConstraint, IConstraintBuilder, Guid } from '@cratis/chronicle';import { field } from '@cratis/fundamentals';
@eventType()class ConstraintsPersonAliasedTo { @field(Guid) readonly target: Guid;
constructor(target: Guid) { this.target = target; }}
@eventType()class ConstraintsPersonErased {}
@constraint()class ConstraintsPersonTerminalOutcome implements IConstraint { // Both declarations share one constraint name, so they become a single constraint: // at most one event drawn from { ConstraintsPersonAliasedTo, ConstraintsPersonErased } // per person. A person merged away can no longer be erased, and neither event can // occur twice. define(builder: IConstraintBuilder): void { builder.uniqueFor(ConstraintsPersonAliasedTo, 'This person already has a terminal outcome.', 'PersonTerminal'); builder.uniqueFor(ConstraintsPersonErased, 'This person already has a terminal outcome.', 'PersonTerminal'); }}Each event type still cannot occur twice — that is the single-type case of the same rule. What sharing the name adds is that the first of them to be appended blocks all of the others.
Because this is enforced at append time it holds under concurrency, which a validator reading prior state cannot guarantee: that check and the append are not atomic, so two competing commands can both observe “no terminal event yet” and both succeed.
How constraints are enforced
Section titled “How constraints are enforced”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.