Skip to content

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.

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:

  1. A user provides input
  2. That input is turned into a Command — an explicit statement of intent
  3. The command is validated (is this allowed right now?)
  4. If valid, one or more Events are appended to the event log
  5. 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.

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 form

One 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.

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

View C# snippet source on GitHub

The identity is what Chronicle appends the registration under, and each backend expresses that differently:

  • C#: AuthorId derives from EventSourceId<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: AuthorId is a ConceptAs<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 @CommandKey rather 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>.

The event, the command validation and the command live together in the slice’s registration file.

Authors/Registration/Registration.cs
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));
}

View C# snippet source on GitHub

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

View C# snippet source on GitHub

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 the AuthorId before Handle(), which returns the tuple (AuthorId, AuthorRegistered). Because AuthorId derives from EventSourceId<Guid>, the integration uses it for the append as well as the response; a ConceptAs<Guid> alone would only be an ordinary response. See model-bound commands.
  • TypeScript: provide() creates the AuthorId, and handle() returns tuple(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 @CommandKey property. Arc appends the returned event under the command key and returns the AuthorId half of the Pair as 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 Debug generates RegisterAuthor.ts (proxy generation).
  • Kotlin and Java: ./gradlew build generates RegisterAuthor.ts under build/generated/arc-proxies/ (TypeScript proxies).
  • TypeScript: the application’s proxy generation writes RegisterAuthor.proxy.ts beside the slice; the Library sample runs it as part of yarn workspace @cratis/arc.sample.library build (generated code).

The form is one component around the generated proxy. The code below is written against the C# proxy.

Authors/Registration/AddAuthor.tsx
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 RegisterAuthor proxy
  • InputTextField renders 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
  • onSuccess receives 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 fetch calls

A parent can await the returned identity and display a confirmation:

Authors/Registration/RegisterAuthorButton.tsx
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.

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.cs

Each 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.

Library.Specs/Authors/Registration/when_registering/and_author_does_not_exist.cs
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();
}

View C# snippet source on GitHub

What each scenario needs:

  • C#: a separate spec project referencing the Library project, with Cratis.Specifications.XUnit, Cratis.Arc.Chronicle.Testing, and Cratis.Arc, alongside the xUnit test runner. The event-sourced testing guide explains this setup and how direct decision specs complement it. Run dotnet test Library.Specs.
  • Kotlin and Java: io.cratis:arc-testing in the test configuration alongside the Chronicle integration, which gives CommandScenario an in-memory event log. The test supplies the identity, because it is the command key.
  • TypeScript: ChronicleCommandScenario from @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).

when_registering/and_author_name_already_exists.cs
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.