State Change — register an author
A librarian types an author’s first and last name into a dialog and presses Register. Arc validates the names, Chronicle records that the author was registered, and the dialog closes with the new author’s ID. A second registration of exactly the same name is rejected. Registering an author is the Library’s first State Change, shown with a C#, Kotlin, Java or TypeScript (source preview) backend.
This series uses Arc with its optional Chronicle integration; Arc also supports commands and queries without event sourcing.
Start with an application configured for Arc with its Chronicle integration, proxy generation, and Components. For C#, see Arc and Chronicle and proxy generation; the other backends are covered under Arc backends. The examples below add the Library behavior to that application.
What is a State Change?
Section titled “What is a State Change?”In Event Modeling terms, a State Change slice is the answer to the question: “What can a user do that will permanently change the system?”
The shape is always the same:
- A user provides input
- That input is turned into a Command — an explicit statement of intent
- The command is validated (is this allowed right now?)
- If valid, one or more Events are appended to the event log
- The event log is the truth — the state has changed
Registration does not update an author table directly. It records that AuthorRegistered happened for an identity. The next slice builds a queryable view from those facts, so readers do not have to scan the event log.
Folder structure
Section titled “Folder structure”Following the vertical slice convention, everything for this slice lives together:
Source/└── Authors/ ├── AuthorId.cs ← Concept: strongly-typed author identity ├── AuthorName.cs ← Concept: strongly-typed author name └── Registration/ ├── Registration.cs ← Command + event + constraint (C# layout) └── AddAuthor.tsx ← React component for the add-author formOne folder holds the slice’s backend artifacts and one .tsx file holds the UI. The shared concepts sit in the parent folder so later author slices use the same vocabulary. Kotlin keeps the same layout with .kt files and TypeScript with .ts files. Java puts each public type in its own file, so Registration/ holds AuthorRegistered.java, RegisterAuthor.java and the validator files instead of one Registration file.
Step 1 — Concepts
Section titled “Step 1 — Concepts”Before writing the command, name the domain values: a strongly-typed author name and a strongly-typed author identity. Both keep unrelated values from being swapped accidentally, and the name carries a concept validator so the nonempty-name rule travels with it wherever Arc validates it.
using Cratis.Arc.Validation;using Cratis.Chronicle.Events;using Cratis.Concepts;using FluentValidation;
namespace Library.Authors;
// Authors/AuthorId.cspublic record AuthorId(Guid Value) : EventSourceId<Guid>(Value){ public static readonly AuthorId NotSet = new(Guid.Empty); public static AuthorId New() => new(Guid.NewGuid()); public static implicit operator AuthorId(Guid value) => new(value);}
// Authors/AuthorName.cspublic record AuthorName(string Value) : ConceptAs<string>(Value){ public static readonly AuthorName NotSet = new(string.Empty); public static implicit operator AuthorName(string value) => new(value);}
public class AuthorNameValidator : ConceptValidator<AuthorName>{ public AuthorNameValidator() => RuleFor(name => name.Value).NotEmpty();}import io.cratis.arc.concepts.ConceptAs;import io.cratis.arc.results.ValidationResult;import io.cratis.arc.validation.ConceptValidator;import java.util.List;import java.util.UUID;import org.springframework.stereotype.Component;
// Authors/AuthorId.javapublic record AuthorId(UUID value) implements ConceptAs<UUID>, io.cratis.chronicle.concepts.ConceptAs<UUID> { public static final AuthorId NOT_SET = new AuthorId(new UUID(0, 0));
public static AuthorId newId() { return new AuthorId(UUID.randomUUID()); }
@Override public UUID getValue() { return value; }}
// Authors/AuthorName.javapublic record AuthorName(String value) implements ConceptAs<String>, io.cratis.chronicle.concepts.ConceptAs<String> { @Override public String getValue() { return value; }}
// Authors/AuthorNameValidator.java@Componentpublic final class AuthorNameValidator implements ConceptValidator<AuthorName> { @Override public Class<AuthorName> getConceptType() { return AuthorName.class; }
@Override public List<ValidationResult> validate(AuthorName concept) { return concept.value().isBlank() ? List.of(ValidationResult.error("A name is required.")) : List.of(); }}import io.cratis.arc.concepts.ConceptAs as ArcConceptAsimport io.cratis.arc.results.ValidationResultimport io.cratis.arc.validation.ConceptValidatorimport io.cratis.chronicle.concepts.ConceptAs as ChronicleConceptAsimport java.util.UUIDimport org.springframework.stereotype.Component
// Authors/AuthorId.ktdata class AuthorId(private val id: UUID) : ArcConceptAs<UUID>, ChronicleConceptAs<UUID> { override fun value(): UUID = id override val value: UUID get() = id
companion object { val NOT_SET = AuthorId(UUID(0, 0)) fun new(): AuthorId = AuthorId(UUID.randomUUID()) }}
// Authors/AuthorName.ktdata class AuthorName(private val name: String) : ArcConceptAs<String>, ChronicleConceptAs<String> { override fun value(): String = name override val value: String get() = name}
@Componentclass AuthorNameValidator : ConceptValidator<AuthorName> { override val conceptType: Class<AuthorName> = AuthorName::class.java
override fun validate(concept: AuthorName): List<ValidationResult> = if (concept.value().isBlank()) listOf(ValidationResult.error("A name is required.")) else emptyList()}import { ConceptAs, Guid } from '@cratis/fundamentals';import { ConceptValidator, validator } from '@cratis/arc.core';
export class AuthorId extends ConceptAs<Guid> { static readonly valueType = Guid;
static create(): AuthorId { return new AuthorId(Guid.create()); }}
// Authors/AuthorName.tsexport class AuthorName extends ConceptAs<string> { static readonly valueType = String;}
@validator(AuthorName)export class AuthorNameValidator extends ConceptValidator<AuthorName> { constructor() { super(); this.ruleFor(name => name.value).notEmpty(); }}The identity is what Chronicle appends the registration under, and each backend expresses that differently:
- C#:
AuthorIdderives fromEventSourceId<Guid>, so the Arc–Chronicle integration can use it as the event source. The declared conversion operators let you construct domain values from their primitives. - TypeScript:
AuthorIdis aConceptAs<Guid>; the command tells the integration which event source to use (Step 2). - Kotlin and Java: each concept implements both Arc’s and Chronicle’s
ConceptAs, so Arc’s JSON and Chronicle’s event serialization both treat it as its underlying value. The command routes its events with@CommandKeyrather than a typed event-source ID (Step 2).
JSON serialization retains a simple wire value, not an object with an authorId property. For C#, Kotlin and Java, the generated proxy types the AuthorId response as a Guid from @cratis/fundamentals. The TypeScript command returns the identity as a string (Step 2), so its proxy is Command<IRegisterAuthor, string>.
Step 2 — The backend slice
Section titled “Step 2 — The backend slice”The event, the command validation and the command live together in the slice’s registration file.
using Cratis.Arc.Commands;using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;using FluentValidation;
namespace Library.Authors.Registration;
/// <summary>Records an author's registration with their first and last names.</summary>[EventType]public record AuthorRegistered(AuthorName FirstName, AuthorName LastName);
public class RegisterAuthorValidator : CommandValidator<RegisterAuthor>{ public RegisterAuthorValidator() { RuleFor(c => c.FirstName) .NotEmpty().WithMessage("First name is required");
RuleFor(c => c.LastName) .NotEmpty().WithMessage("Last name is required"); }}
[Command]public record RegisterAuthor(AuthorName FirstName, AuthorName LastName){ public AuthorId Provide() => AuthorId.New();
public (AuthorId, AuthorRegistered) Handle(AuthorId authorId) => (authorId, new AuthorRegistered(FirstName, LastName));}import io.cratis.arc.artifacts.Command;import io.cratis.arc.artifacts.CommandKey;import io.cratis.arc.authorization.AllowAnonymous;import io.cratis.arc.commands.CommandContext;import io.cratis.arc.commands.CommandValidator;import io.cratis.arc.java.BlockingCommandValidator;import io.cratis.arc.java.BlockingCommandValidatorAdapter;import io.cratis.arc.results.ValidationResult;import io.cratis.chronicle.events.EventType;import java.util.ArrayList;import java.util.List;import kotlin.Pair;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
// Authors/Registration/AuthorRegistered.java/** Records an author's registration with their first and last names. */@EventTypepublic record AuthorRegistered(AuthorName firstName, AuthorName lastName) { }
// Authors/Registration/RegisterAuthorValidator.javapublic final class RegisterAuthorValidator implements BlockingCommandValidator<RegisterAuthor> { @Override public Class<RegisterAuthor> getCommandType() { return RegisterAuthor.class; }
@Override public List<ValidationResult> validate(RegisterAuthor command, CommandContext context) { var results = new ArrayList<ValidationResult>(); if (command.firstName().value().isBlank()) { results.add(ValidationResult.error("First name is required", List.of("firstName"))); } if (command.lastName().value().isBlank()) { results.add(ValidationResult.error("Last name is required", List.of("lastName"))); } return results; }}
// Authors/Registration/RegistrationValidation.java@Configurationpublic class RegistrationValidation { @Bean public CommandValidator<RegisterAuthor> registerAuthorValidator() { return new BlockingCommandValidatorAdapter<>(new RegisterAuthorValidator()); }}
// Authors/Registration/RegisterAuthor.java@Command@AllowAnonymouspublic record RegisterAuthor(@CommandKey AuthorId id, AuthorName firstName, AuthorName lastName) { public Pair<AuthorId, AuthorRegistered> handle() { return new Pair<>(id, new AuthorRegistered(firstName, lastName)); }}import io.cratis.arc.artifacts.Commandimport io.cratis.arc.artifacts.CommandKeyimport io.cratis.arc.authorization.AllowAnonymousimport io.cratis.arc.commands.CommandContextimport io.cratis.arc.commands.CommandValidatorimport io.cratis.arc.results.ValidationResultimport io.cratis.chronicle.events.EventTypeimport org.springframework.stereotype.Component
// Authors/Registration/Registration.kt
/** Records an author's registration with their first and last names. */@EventTypedata class AuthorRegistered(val firstName: AuthorName, val lastName: AuthorName)
@Componentclass RegisterAuthorValidator : CommandValidator<RegisterAuthor> { override val commandType: Class<RegisterAuthor> = RegisterAuthor::class.java
override suspend fun validate(command: RegisterAuthor, context: CommandContext): List<ValidationResult> = buildList { if (command.firstName.value().isBlank()) { add(ValidationResult.error("First name is required", listOf("firstName"))) } if (command.lastName.value().isBlank()) { add(ValidationResult.error("Last name is required", listOf("lastName"))) } }}
@Command@AllowAnonymousdata class RegisterAuthor( @CommandKey val id: AuthorId, val firstName: AuthorName, val lastName: AuthorName) { fun handle(): Pair<AuthorId, AuthorRegistered> = id to AuthorRegistered(firstName, lastName)}import { field } from '@cratis/fundamentals';import { command, CommandValidator, tuple, validator } from '@cratis/arc.core';import { eventSourceIdResponse } from '@cratis/arc.chronicle';import { eventType } from '@cratis/chronicle/events';import { AuthorId } from '../AuthorId.js';import { AuthorName } from '../AuthorName.js';
// Authors/Registration/Registration.ts
/** Records an author's registration with their first and last names. */@eventType()export class AuthorRegistered { @field(AuthorName) firstName: AuthorName; @field(AuthorName) lastName: AuthorName;
constructor(firstName = new AuthorName(''), lastName = new AuthorName('')) { this.firstName = firstName; this.lastName = lastName; }}
@command()export class RegisterAuthor { @field(AuthorName) firstName!: AuthorName; @field(AuthorName) lastName!: AuthorName;
provide(): AuthorId { return AuthorId.create(); }
handle(authorId: AuthorId) { return tuple( eventSourceIdResponse(authorId.value.toString()), new AuthorRegistered(this.firstName, this.lastName)); }}
@validator(RegisterAuthor)export class RegisterAuthorValidator extends CommandValidator<RegisterAuthor> { constructor() { super(); this.ruleFor(command => command.firstName).notEmpty().withMessage('First name is required'); this.ruleFor(command => command.lastName).notEmpty().withMessage('Last name is required'); }}The uniqueness rule belongs to the same file:
// Authors/Registration/Registration.cs (continued)using Cratis.Chronicle.Events.Constraints;
namespace Library.Authors.Registration;
public class UniqueAuthorName : IConstraint{ public void Define(IConstraintBuilder builder) => builder .Unique(_ => _ .On<AuthorRegistered>(e => e.FirstName, e => e.LastName) .WithMessage("An author with that name is already registered"));}import io.cratis.chronicle.constraints.Constraint;import io.cratis.chronicle.constraints.IConstraint;import io.cratis.chronicle.constraints.IConstraintBuilder;import io.cratis.chronicle.java.UniqueConstraintBuilderJavaBridge;
// Authors/Registration/UniqueAuthorName.java@Constraintpublic class UniqueAuthorName implements IConstraint { @Override public void define(IConstraintBuilder builder) { builder.unique(unique -> { UniqueConstraintBuilderJavaBridge .on(unique, AuthorRegistered.class, "firstName", "lastName") .withMessage("An author with that name is already registered"); }); }}import io.cratis.chronicle.constraints.Constraintimport io.cratis.chronicle.constraints.IConstraintimport io.cratis.chronicle.constraints.IConstraintBuilder
// Authors/Registration/Registration.kt (continued)@Constraintclass UniqueAuthorName : IConstraint { override fun define(builder: IConstraintBuilder) { builder.unique { unique -> unique .on(AuthorRegistered::class, AuthorRegistered::firstName, AuthorRegistered::lastName) .withMessage("An author with that name is already registered") } }}import { constraint, type IConstraint, type IConstraintBuilder } from '@cratis/chronicle/events';
// Authors/Registration/Registration.ts (continued)@constraint()export class UniqueAuthorName implements IConstraint { define(builder: IConstraintBuilder): void { builder.unique(unique => unique .on(AuthorRegistered, event => event.firstName, event => event.lastName) .withMessage('An author with that name is already registered')); }}What is happening here?
Section titled “What is happening here?”The event declaration marks AuthorRegistered as a Chronicle event. The framework uses the type name as the event identifier, so the declaration takes neither a GUID nor a string argument. Each property records part of the fact, and none of them is nullable: the validators enforce the required names before this command constructs the event.
The command validator runs automatically before the handler is ever called. If any rule fails, the command pipeline short-circuits and returns validation errors to the caller; the validator reports a failed rule without throwing an exception. C# builds it on FluentValidation (a CommandValidator<T>); TypeScript uses Arc’s CommandValidator with ruleFor; Kotlin and Java implement Arc’s command-validator interface and return validation results.
The constraint guards the combination of FirstName and LastName across authors in the event store namespace. On selects the two properties themselves, and the constraint computes no concatenated string from them. This tutorial keeps the Library’s rule that an exact name combination may be registered only once; it does not treat names as a universal way to identify people. Kotlin and Java name the same two properties and need Chronicle.Kotlin 6.9.0 or later; earlier versions cannot express a constraint over more than one property.
Chronicle checks uniqueness when the returned event is committed, after the handler has run. A duplicate produces a constraint violation in the command result and the attempted registration is not appended. That is different from input validation, which short-circuits before the handler.
The model-bound command returns two things: the new author’s identity, which becomes the response value the frontend receives, and the AuthorRegistered event to append. The backends differ in where that identity comes from:
- C#:
Provide()creates theAuthorIdbeforeHandle(), which returns the tuple(AuthorId, AuthorRegistered). BecauseAuthorIdderives fromEventSourceId<Guid>, the integration uses it for the append as well as the response; aConceptAs<Guid>alone would only be an ordinary response. See model-bound commands. - TypeScript:
provide()creates theAuthorId, andhandle()returnstuple(eventSourceIdResponse(authorId.value.toString()), event): the event is appended to that ID, and the ID’s string is the response. - Kotlin and Java: the caller supplies the identity in the
@CommandKeyproperty. Arc appends the returned event under the command key and returns theAuthorIdhalf of thePairas the response. The command is marked@AllowAnonymous(see the backend table).
Creating the identity before the handler keeps the event-construction decision deterministic for a supplied ID.
Build before writing frontend code. The build generates the RegisterAuthor proxy the React component imports; without it, the component has nothing to import:
- C#:
dotnet build -c DebuggeneratesRegisterAuthor.ts(proxy generation). - Kotlin and Java:
./gradlew buildgeneratesRegisterAuthor.tsunderbuild/generated/arc-proxies/(TypeScript proxies). - TypeScript: the application’s proxy generation writes
RegisterAuthor.proxy.tsbeside the slice; the Library sample runs it as part ofyarn workspace @cratis/arc.sample.library build(generated code).
Step 3 — The React component
Section titled “Step 3 — The React component”The form is one component around the generated proxy. The code below is written against the C# proxy.
import { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs';import { CommandDialog } from '@cratis/components/CommandDialog';import { InputTextField } from '@cratis/components/CommandForm';import { Guid } from '@cratis/fundamentals';import { RegisterAuthor } from './RegisterAuthor';
export const AddAuthor = () => { const { closeDialog } = useDialogContext<object, Guid>();
return ( <CommandDialog<RegisterAuthor, Guid> command={RegisterAuthor} title="Register Author" okLabel="Register" onSuccess={authorId => closeDialog(DialogResult.Ok, authorId)} onCancel={() => closeDialog(DialogResult.Cancelled)} > <InputTextField<RegisterAuthor> value={instance => instance.firstName} title="First name" /> <InputTextField<RegisterAuthor> value={instance => instance.lastName} title="Last name" /> </CommandDialog> );};The dialog component uses useDialogContext from @cratis/arc.react/dialogs to get the closeDialog function. It does not receive props for visibility — all dialog lifecycle is managed by the framework. A parent component uses the useDialog hook to show and await this dialog.
CommandDialog from @cratis/components does the heavy lifting:
- It creates and executes the
RegisterAuthorproxy InputTextFieldrenders typed form fields bound to command properties- It runs the frontend-side validation defined in the proxy
- It calls the Arc command pipeline when the user confirms
onSuccessreceives the response payload only after command execution succeeds- It surfaces any backend validation errors directly in the form
- It gives the user a success or error response without you writing any
fetchcalls
A parent can await the returned identity and display a confirmation:
import { useState } from 'react';import { DialogResult, useDialog } from '@cratis/arc.react/dialogs';import { Guid } from '@cratis/fundamentals';import { AddAuthor } from './AddAuthor';
export const RegisterAuthorButton = () => { const [AddAuthorDialog, showAddAuthor] = useDialog<Guid>(AddAuthor); const [registeredId, setRegisteredId] = useState<Guid>();
const handleAdd = async () => { const [dialogResult, authorId] = await showAddAuthor(); if (dialogResult === DialogResult.Ok && authorId) { setRegisteredId(authorId); } };
return ( <> <button type="button" onClick={handleAdd}>Register author</button> {registeredId && <p role="status">Registered author {registeredId.toString()}</p>} <AddAuthorDialog /> </> );};useDialog<Guid> describes the value supplied to closeDialog. In the dialog, that value is the second generic parameter of useDialogContext<object, Guid>; its first parameter describes the request. The command result envelope stays inside CommandDialog, so this parent receives the author ID itself.
Step 4 — Command specs
Section titled “Step 4 — Command specs”Use an in-process command scenario to prove that the response identity and appended event agree. It runs the command through the real Arc pipeline — validation, the handler and the Chronicle append — without an HTTP host.
Library.Specs/Authors/Registration/when_registering/├── and_author_does_not_exist.cs└── and_author_name_already_exists.csEach backend keeps specs in its own layout: Kotlin and Java under src/test, and TypeScript beside the slice in a for_RegisterAuthor/when_registering/ folder.
using Cratis.Arc.Chronicle.Testing.Commands;using Cratis.Arc.Commands;using Cratis.Arc.Testing.Commands;using Cratis.Chronicle.Events;using Cratis.Specifications;using Xunit;using Library.Authors;using Library.Authors.Registration;
namespace when_registering;
public class and_author_does_not_exist : Specification{ readonly CommandScenario<RegisterAuthor> _scenario = new(); CommandResult _result = null!;
async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("J.R.R.", "Tolkien"));
[Fact] void should_succeed() => _result.ShouldBeSuccessful();
[Fact] void should_append_one_event() => _scenario.AppendedEvents.Count.ShouldEqual(1);
[Fact] async Task should_record_the_names_under_the_returned_identity() { var authorId = ((CommandResult<AuthorId>)_result).Response!; authorId.ShouldNotEqual(AuthorId.NotSet); await _scenario.ShouldHaveAppendedEvent<RegisterAuthor, AuthorRegistered>( (EventSourceId)authorId, @event => @event.FirstName.Value == "J.R.R." && @event.LastName.Value == "Tolkien"); }
void Destroy() => _scenario.Dispose();}import static org.junit.jupiter.api.Assertions.assertEquals;
import io.cratis.arc.chronicle.ChronicleCommandScenario;import io.cratis.arc.chronicle.ChronicleCommandScenarios;import io.cratis.arc.generated.LibraryArcArtifactModule;import io.cratis.arc.testing.CommandScenario;import io.cratis.arc.testing.java.BlockingCommandScenario;import org.junit.jupiter.api.Test;
// src/test/java/library/authors/registration/WhenRegisteringAnAuthor.javapublic class WhenRegisteringAnAuthor { @Test void recordsTheNamesUnderTheReturnedIdentity() { var configured = new CommandScenario<>(new LibraryArcArtifactModule(), RegisterAuthor.class); ChronicleCommandScenario chronicle = ChronicleCommandScenarios.chronicle(configured); var authorId = AuthorId.newId();
try (var scenario = new BlockingCommandScenario<>(configured)) { var result = scenario .execute(new RegisterAuthor(authorId, new AuthorName("J.R.R."), new AuthorName("Tolkien"))) .shouldSucceed(); assertEquals(authorId, result.shouldHaveResponse(AuthorId.class)); }
chronicle.shouldHaveAppendedEvents(1); var registered = chronicle.shouldHaveAppendedEvent(authorId.value().toString(), AuthorRegistered.class); assertEquals(new AuthorRegistered(new AuthorName("J.R.R."), new AuthorName("Tolkien")), registered); }}import io.cratis.arc.chronicle.chronicleimport io.cratis.arc.generated.LibraryArcArtifactModuleimport io.cratis.arc.testing.CommandScenarioimport kotlin.test.assertEqualsimport kotlinx.coroutines.runBlockingimport org.junit.jupiter.api.Test
// src/test/kotlin/library/authors/registration/WhenRegisteringAnAuthor.ktclass WhenRegisteringAnAuthor { @Test fun `records the names under the returned identity`() = runBlocking { val scenario = CommandScenario(LibraryArcArtifactModule(), RegisterAuthor::class.java) val authorId = AuthorId.new()
val result = scenario.execute(RegisterAuthor(authorId, AuthorName("J.R.R."), AuthorName("Tolkien")))
result.shouldSucceed() assertEquals(authorId, result.shouldHaveResponse(AuthorId::class.java)) scenario.chronicle().shouldHaveAppendedEvents(1) val registered = scenario.chronicle() .shouldHaveAppendedEvent<AuthorRegistered>(authorId.value().toString()) assertEquals(AuthorRegistered(AuthorName("J.R.R."), AuthorName("Tolkien")), registered) }}import { ChronicleCommandScenario } from '@cratis/arc.chronicle/testing';import { strictEqual } from 'node:assert';import { afterEach, beforeEach, describe, it } from 'vitest';import { AuthorName } from '../../../AuthorName.js';import { AuthorRegistered, RegisterAuthor, RegisterAuthorValidator } from '../../Registration.js';
// Authors/Registration/for_RegisterAuthor/when_registering/and_author_does_not_exist.tsdescribe('when registering an author that does not exist', () => { let scenario: ChronicleCommandScenario<RegisterAuthor>;
beforeEach(() => { scenario = ChronicleCommandScenario.for(RegisterAuthor, RegisterAuthorValidator, AuthorRegistered); });
afterEach(async () => { await scenario.dispose(); });
it('should record the names under the returned identity', async () => { const result = await scenario.execute({ firstName: new AuthorName('J.R.R.'), lastName: new AuthorName('Tolkien') });
result.shouldBeSuccessful(); strictEqual(result.appendedEvents.length, 1); result.shouldHaveAppendedEvent(AuthorRegistered, String(result.response), event => event.firstName.value === 'J.R.R.' && event.lastName.value === 'Tolkien'); });});What each scenario needs:
- C#: a separate spec project referencing the Library project, with
Cratis.Specifications.XUnit,Cratis.Arc.Chronicle.Testing, andCratis.Arc, alongside the xUnit test runner. The event-sourced testing guide explains this setup and how direct decision specs complement it. Rundotnet test Library.Specs. - Kotlin and Java:
io.cratis:arc-testingin the test configuration alongside the Chronicle integration, which givesCommandScenarioan in-memory event log. The test supplies the identity, because it is the command key. - TypeScript:
ChronicleCommandScenariofrom@cratis/arc.chronicle/testing, which records appended events in memory.
Then establish the same name under an existing author and check the precise rejection. This spec is C#-only: it needs Chronicle to enforce the constraint, and only the C# scenario runs an in-process kernel. The Kotlin, Java and TypeScript in-memory scenarios do not enforce constraints (see the backend table).
using Cratis.Arc.Chronicle.Testing.Commands;using Cratis.Arc.Commands;using Cratis.Arc.Testing.Commands;using Cratis.Chronicle.Events;using Cratis.Chronicle.EventSequences;using Cratis.Chronicle.Testing.EventSequences;using Cratis.Specifications;using Xunit;using Library.Authors;using Library.Authors.Registration;
namespace when_registering;
public class and_author_name_already_exists : Specification{ readonly CommandScenario<RegisterAuthor> _scenario = new(); readonly AuthorId _existingAuthor = AuthorId.New(); CommandResult _result = null!;
Task Establish() => _scenario.EventScenario.Given .ForEventSource((EventSourceId)_existingAuthor) .Events(new AuthorRegistered("J.R.R.", "Tolkien"));
async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("J.R.R.", "Tolkien"));
[Fact] void should_report_the_name_constraint() => _result.ShouldHaveConstraintViolationFor(nameof(UniqueAuthorName));
[Fact] Task should_leave_only_the_original_event() => _scenario.EventLog.ShouldHaveTailSequenceNumber(EventSequenceNumber.First);
void Destroy() => _scenario.Dispose();}These C# scenarios use the in-process Chronicle kernel — no Docker or MongoDB — and check both the rejection reason and the unchanged event log. The normal Specification lifecycle runs Establish, Because, the fact, and Destroy; dispose the scenario in Destroy. Add provider-backed and HTTP tests to cover the application’s actual storage and transport.