Skip to content

Build a full-stack feature

This is where the three products meet. You’ll build one vertical slice of a library app — registering an author and listing authors — end to end. Choose C#, Kotlin, Java, or TypeScript for the backend; the TypeScript backend is a source preview with no scaffold yet, so you start it from a sample. In every version, an Arc command produces a Chronicle event; a read model turns that event into a list; a React screen (Components) uses generated, type-safe proxies to call the backend. No hand-written API client, no DTO duplication.

Keep the feature under one module — that’s the vertical-slice idea: you navigate by behavior, not by technical layer.

Here’s the slice as an event model — read left to right, the user acts on a screen, a command records a fact, that fact is projected into a read model, and the next screen reads it:

UI/A: AuthorsUI/A: AuthorsC/RM: AuthorsC/RM: AuthorsStream: Authors
AddAuthor
RegisterAuthor



id: uuid, name: string
AuthorRegistered



name: string
Author
Authors

The blocks are the slice, one per product: the ui screens are Components, the cmd and the rmo’s query are Arc, the evt is Chronicle, and the build generates the typed proxies that connect them. Frames 01–03 are the command pattern (intent → fact); 03–05 are the view pattern (fact → read model → screen). Pick a backend language in the tabs below; your selection follows you through the steps.

Choose one backend to follow:

  • C#: Install the .NET 10 template and run dotnet new cratis -o Library. The buildable Capstone sample contains this exact slice. From Library/, run docker compose up -d, dotnet build, then dotnet run. The development Compose file exposes its ports on all interfaces; bind them to 127.0.0.1 or use a trusted machine. Start the Vite frontend with the package manager selected by your scaffold (for example, npm run dev). For brevity, the C# slice keeps its four types (AuthorId, RegisterAuthor, AuthorRegistered, Author) in one Authors/ folder at the project root; the generated proxies land beside them, which is why the screen below imports ./RegisterAuthor and ./Author.
  • Kotlin or Java: Run dotnet new cratis --language Kotlin -o Library or dotnet new cratis --language Java -o Library. With JDK 17 active, run docker compose up -d, ./gradlew build, then ./gradlew bootRun from Library/. As with C#, the development Compose file exposes its ports on all interfaces; bind them to 127.0.0.1 or use a trusted machine. Start the Vite frontend separately, following the generated Kotlin or Java README. The Gradle build generates the proxies under build/generated/arc-proxies/.
  • TypeScript (source preview): There is no TypeScript scaffold yet. Start from the Arc TypeScript Library sample: from the Arc.TypeScript repository root run yarn install, docker compose -f Samples/Library/docker-compose.yml up -d, then yarn workspace @cratis/arc.sample.library dev. The TypeScript tabs mirror the sample’s Features/Authors/AuthorId.ts, AuthorName.ts, Registration/Registration.ts and Listing/Listing.ts, without its Librarian role, unique-name constraint and authorsPage query; AuthorName.ts adds the name validation. The sample builds its proxies beside each feature as *.proxy.ts; see its README for setup and the local-only development identity.

The scaffolded C# and JVM Compose configurations start Chronicle with the default MongoDB choice, which persists the projected read model. Keep the four backend roles in one feature: host, author id, command/event, and read model/query. The sample layout differs by language; follow its neighboring files for imports and wiring.

The steps below are the same for every backend. Where the code differs, this table says how, and each row names the step it applies to:

BehaviorC#Kotlin and JavaTypeScript (preview)
Host change (step 1)None; the scaffold’s AddCratis hostAdd the author types to KnownClientArtifactsNone; the Library sample’s host
Author id in the generated proxy (step 2)Guid, from EventSourceId<Guid>Guid, from a UUID-backed conceptGuid, from ConceptAs<Guid>
Anonymous callers (step 3)Accepted; the command declares no authorizationRejected by default; the author types carry @AllowAnonymousAccepted; the command declares no authorization
Read model (step 4)Projection from the event, with AutoMapReducer keyed by the event’s source idProjection from the event
Author list (step 4)Live: re-renders when the projection changesLive: observes the first page of 50 materialized instances; needs Chronicle 19.22.2 or laterLive: re-renders when the projection changes
Generated proxies (step 4)dotnet build, beside the types in Authors/./gradlew build, under build/generated/arc-proxies/The sample’s build, as *.proxy.ts beside each feature

The host connects Arc to Chronicle and exposes generated command and query endpoints. The scaffolds and Library sample supply this wiring; find it before changing the feature. C# and TypeScript need no change: their tabs show the existing host for orientation. On Kotlin and Java, add the three author types to the scaffold’s KnownClientArtifacts in ChronicleConfiguration, so an executable jar registers them. Keep the scaffold’s existing imports and add imports for the author types. Keep its sink fallback too: a non-MongoDB --Database generates WellKnownSinkTypes.SQL there, not MONGODB.

var builder = WebApplication.CreateBuilder(args);
builder.AddCratis(
configureArcBuilder: arcBuilder => arcBuilder.WithMongoDB(configureMongoDB: mongoBuilder => mongoBuilder.WithCamelCaseNamingPolicy()),
configureChronicleBuilder: chronicleBuilder => chronicleBuilder.WithCamelCaseNamingPolicy());
builder.Services.AddControllers();
builder.Services.AddMvc();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options => options.AddConcepts());
var app = builder.Build();
app.UseRouting();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseWebSockets();
app.MapControllers();
app.UseCratis();
app.UseSwagger();
app.UseSwaggerUI();
app.MapFallbackToFile("/index.html");
await app.RunAsync();

View C# snippet source on GitHub

Chronicle uses the command key to select the author’s event stream. Every backend backs AuthorId with a GUID, so the generated command proxy types the id as a Guid whichever backend you chose. In C#, AuthorId derives from EventSourceId<Guid>, which also makes it the read model’s key without a [Key] attribute:

public record AuthorId(Guid Value) : EventSourceId<Guid>(Value)
{
public static readonly AuthorId NotSet = new(Guid.Empty);
public static implicit operator AuthorId(Guid value) => new(value);
public static AuthorId New() => new(Guid.NewGuid());
}

View C# snippet source on GitHub

The command expresses intent; its handler returns the event that happened. Chronicle appends that event to the stream selected by the id. The Kotlin and Java command and read model carry @AllowAnonymous, as the scaffold’s own Register and Listing do, because Arc.Kotlin rejects anonymous callers by default and the JVM scaffolds have no sign-in:

[Command]
public record RegisterAuthor(AuthorId Id, string Name)
{
public AuthorRegistered Handle() => new(Name);
}
[EventType]
public record AuthorRegistered(string Name);

View C# snippet source on GitHub

4. Build and query the author list (backend)

Section titled “4. Build and query the author list (backend)”

The read model describes the list entry; the event feeds it, and a query exposes the list. C# and TypeScript declare a projection from the event; in C#, AutoMap matches AuthorRegistered.Name to Name. Kotlin and Java use a reducer keyed by the event context’s source id, rather than a model-bound event mapping:

[ReadModel]
[FromEvent<AuthorRegistered>]
public record Author(AuthorId Id, string Name)
{
public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) =>
collection.Observe();
}

View C# snippet source on GitHub

Build your backend now (dotnet build, ./gradlew build, or the Library sample’s yarn workspace @cratis/arc.sample.library build). The build generates TypeScript command and query proxies: change a property on the backend and the frontend must compile against the updated shape. Chronicle projects asynchronously, so a successful command can precede the updated list. On Kotlin and Java, allAuthors observes a page of materialized instances (see Author list in the table above).

The screen below is the shared React flow from the compiled C# Capstone sample: run a generated command in a dialog, read a generated query, mount a route. Use the same Components with every backend, adjusting the generated proxy imports:

  • C#: The snippets below import proxies co-located in Authors/; the id in the command proxy is a Guid.
  • Kotlin and Java: Import RegisterAuthor.ts and AllAuthors.ts under build/generated/arc-proxies/ via your scaffold’s Api/ alias; the id in the command proxy is a Guid, as in C#. AllAuthors is observable, so .use() re-renders when the new author is projected. It can take a moment to appear, because Chronicle projects asynchronously.
  • TypeScript: Import RegisterAuthor from Features/Authors/Registration/RegisterAuthor.proxy.ts and AllAuthors from Features/Authors/Listing/AllAuthors.proxy.ts; keep Guid.create() for its id. Its RegisterAuthorForm seeds the id before validation with initialValues.
  1. A dialog that executes the command. AddAuthor.tsx uses CommandDialog to instantiate and run the generated command and render the OK/Cancel footer — supply the fields:

    import { CommandDialog } from '@cratis/components/CommandDialog';
    import { InputTextField } from '@cratis/components/CommandForm';
    import { RegisterAuthor } from './RegisterAuthor';
    import { Guid } from '@cratis/fundamentals';
    export const AddAuthor = () => (
    <CommandDialog<RegisterAuthor>
    command={RegisterAuthor}
    title="Add author"
    okLabel="Add"
    initialValues={{ id: Guid.create() }}>
    <InputTextField<RegisterAuthor> value={i => i.name} title="Name" />
    </CommandDialog>
    );
  2. A screen that reads the query. In Authors.tsx, the generated AllAuthors proxy is observable: .use() re-renders when the projection changes. Kotlin and Java observe a page of materialized instances (see the table). The button opens the dialog above:

    import { useDialog } from '@cratis/arc.react/dialogs';
    import { AllAuthors } from './Author';
    import { AddAuthor } from './AddAuthor';
    export const Authors = () => {
    const [AddAuthorDialog, showAddAuthor] = useDialog(AddAuthor);
    const [authors] = AllAuthors.use();
    return (
    <main>
    <h1>Authors</h1>
    <button type="button" onClick={() => { void showAddAuthor(); }}>Add author</button>
    <ul>
    {authors.data.map(a => <li key={String(a.id)}>{a.name}</li>)}
    </ul>
    <AddAuthorDialog />
    </main>
    );
    };
  3. Mount the screen. In the scaffold’s App.tsx, import Authors from ./Authors/Authors and add this route inside the existing <Routes> (alongside / and /demo):

    <Route path='/authors' element={<Authors />} />

    Open /authors in the browser to see the list and the Add author button.