Skip to content

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.

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>();
}

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.");
}

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.");
}

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.");
}

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>();
}

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. Declaring several event types under one name makes them a single constraint, and a single constraint has one removal event.

The model-bound form expresses the same thing with [RemoveConstraint] on the event that ends the cycle.

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.");
}
}

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.

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.