Skip to content

1. Your first full-stack slice

Every app starts with one feature. Ours is registering an author — the first thing a librarian does before cataloging a book. You have a command that writes and a query that reads; now let’s connect them to a screen without a hand-written API client.

Complete standalone ASP.NET Core setup and Your first command and query first. Those pages supply the packages, global imports, Library.Authors namespace, database configuration/schema, and Debug proxy output. Choose MongoDB or EF Core consistently; neither requires Chronicle. For the TypeScript backend, start with the standalone MongoDB host and TypeScript setup instead; it runs on port 3000 and requires generated artifact metadata.

The setup below adds Vite in the same Library/ project root; keep components under src/ and generated files in src/Authors/. Do not duplicate the backend types you already created. This tutorial uses the optional Components dialog UI; the frontend getting-started checkpoint puts the same command in an inline CommandForm instead. With our source-file proxy grouping, import its AllAuthors from ./Authors/Author, not ./Authors/AllAuthors.

Arc doesn’t force a folder layout. Here the author behavior stays together in src/Authors/: strong domain values (AuthorId, AuthorName), a command, and a read model with a query. Those files use ConceptAs<Guid> and ConceptAs<string> so the C# compiler distinguishes domain values while the generated client retains their underlying types.

Handle writes

AllAuthors

generated proxy

RegisterAuthor

database

Author

observable query

React list

On TypeScript, create Features/Authors/AuthorId.ts and AuthorName.ts as separate files first. Put RegisterAuthor in Features/Authors/RegisterAuthor.ts and Author in Features/Authors/Author.ts. Import AuthorId, AuthorName, and the runtime AuthorRepository from their feature files (with .js suffixes). The concepts match the NoteId and NoteText pattern in TypeScript setup:

Features/Authors/AuthorId.ts
import { ConceptAs, Guid } from '@cratis/fundamentals';
export class AuthorId extends ConceptAs<Guid> {
static readonly valueType = Guid;
static create(): AuthorId {
return new AuthorId(Guid.create());
}
}
// Features/Authors/AuthorName.ts — keep this declaration in its own file.
export class AuthorName extends ConceptAs<string> {
static readonly valueType = String;
}

View TypeScript (preview) snippet source on GitHub

These are excerpts from the backend checkpoint, not additional files. Each tab shows the same slice over that backend’s document store — MongoDB on C#, Spring Data MongoDB on the JVM:

[Command]
public record RegisterAuthor(AuthorId Id, AuthorName Name)
{
public Task Handle(IMongoCollection<Author> authors) =>
authors.InsertOneAsync(new Author(Id, Name));
}
[ReadModel]
public record Author(AuthorId Id, AuthorName Name)
{
public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> authors) =>
authors.Observe();
}

View C# snippet source on GitHub

The JVM’s @AllowAnonymous on these tutorial artifacts allows the unauthenticated first round trip; chapter 5 replaces it with role-based access. The dependencies are method-injected: straight into Handle() and the static query method on C#, through @FromServices on handle and the companion-object query function on the JVM, and through @inject or service(...) on TypeScript. Returning Task, Unit, or Promise<void> adds no payload: the explicit database write is the outcome. The query is the static method — @JvmStatic on the read model’s companion object in Kotlin — and none of the three backends needs a controller or a duplicated transport model.

If you chose the relational branch, .NET injects LibraryDbContext instead of the collection. The JVM and TypeScript backend snippets use repositories, whose implementations supply the relational storage:

[Command]
public record RegisterAuthor(AuthorId Id, AuthorName Name)
{
public async Task Handle(LibraryDbContext db)
{
db.Authors.Add(new Author(Id, Name));
await db.SaveChangesAsync();
}
}
[ReadModel]
public record Author(AuthorId Id, AuthorName Name)
{
public static ISubject<IEnumerable<Author>> AllAuthors(LibraryDbContext db) =>
db.Authors.Observe();
}

View C# snippet source on GitHub

On the JVM, keep Author as the Arc read-model DTO and map AuthorRow as a JPA entity. Implement AuthorRepository using JpaRepository<AuthorRow, UUID> (or the Java equivalent), converting the concept id and name to stored scalars on writes and lookups. The custom JpaObservableQuery snapshot projects rows back into Author; after each committed write, publish Author::class.java (Java: Author.class) through DatabaseChangePublisher so subscribers receive a fresh snapshot. Saving a row alone does not publish an Arc read-model change. JPA observation is in-process only.

On TypeScript, define your Drizzle authors table with id and name columns (the unique-name definition in chapter 2 adds the constraint), implement AuthorRepository over it, register the table in withDrizzle({ readModels: [{ type: Author, table: authors }], observation: DrizzleObservation.InProcess, ... }), and call notifyChanged(authors) after committed writes so in-process table observation updates subscribers. Drizzle observation does not see writes from other processes.

Terminal window
dotnet build -c Debug

With Cratis.Arc.ProxyGenerator.Build installed and CratisProxiesOutputPath set, a post-build tool reads the assembly and PDB and generates src/Authors/RegisterAuthor.ts and src/Authors/Author.ts. The latter includes AllAuthors in source-file output mode. Building a bare Arc project without generator configuration emits no proxies.

On TypeScript, run npm run generate from the application built in TypeScript setup; its arc-proxygenerator script writes generated metadata and co-located *.proxy.ts files. From src/Authors.tsx import AllAuthors from ../Features/Authors/AllAuthors.proxy; from src/AddAuthor.tsx import RegisterAuthor from ../Features/Authors/RegisterAuthor.proxy. The .NET/JVM src/Authors/Author and src/Authors/RegisterAuthor paths in the examples below do not exist on this backend. Regenerate after changing a backend artifact. If you customize backend routes, mirror them in the build-time proxy configuration. Generated clients do not discover runtime route changes.

If you already have a Vite app, retain its entry/configuration and adapt only the composition below. Otherwise, from Library/, initialize it without a Chronicle-enabled template:

Terminal window
npm init -y
npm pkg set type=module
npm install react@^19 react-dom@^19 @cratis/fundamentals@^7.19.6 @cratis/arc @cratis/arc.react \
@cratis/components@^4 reflect-metadata@0.2.2 tsyringe@4.10.0
npm install --save-dev vite @types/react @types/react-dom

For a .NET or JVM-only project without TypeScript installed yet, also run npm install --save-dev typescript. If you followed the TypeScript backend setup, keep its existing TypeScript 6 compiler alias (tsc6); installing plain typescript here would replace it.

type=module lets Vite load vite.config.ts as an ES module without a CommonJS warning. Use Node.js supported by your chosen Vite release. Keep @cratis/arc and @cratis/arc.react on the same version used by your generated proxies, within Components’ peer range (>=20.3.1 <23). For the TypeScript backend sample, pin both to 22.19.1: npm install @cratis/arc@22.19.1 @cratis/arc.react@22.19.1. @cratis/arc.react depends on that exact Arc version; mixing versions installs a second copy, and generated proxies fail the frontend type-check because the runtime classes have private members. The command installs Components and its required peers; this dialog needs neither a separate UI kit nor the optional pixi.js peer used by spatial components. Components is required for this chapter’s CommandDialog, not for Arc itself. See Components setup for other installation options.

If you followed the TypeScript backend setup, keep its tsconfig.json: the generator’s --project points to it, and its main.ts and Features/**/*.ts entries emit the backend’s dist/main.js. Add a separate tsconfig.web.json for Vite instead. The .NET and JVM projects can also use this separate frontend config:

{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "Features/**/*.proxy.ts", "Features/**/*.tsx"]
}

The Features entries type-check the TypeScript backend’s co-located browser proxies without adding them to its server build. Keep generate.mjs on --project ./tsconfig.json, not tsconfig.web.json; the latter has no backend artifacts.

Create index.html at the project root:

<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Library</title></head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>

Create vite.config.ts:

import { defineConfig } from 'vite';
export default defineConfig({
oxc: { decorator: { legacy: true } },
build: { outDir: 'dist-web' },
server: {
host: 'localhost',
port: 5173,
strictPort: true,
proxy: {
'/api': { target: 'http://localhost:5000' },
'/.cratis': { target: 'http://localhost:5000', ws: true }
}
}
});

Vite needs oxc.decorator.legacy for generated proxies’ @field decorators because it reads decorator settings from tsconfig.json, not tsconfig.web.json. The separate dist-web output also keeps Vite from emptying the TypeScript backend’s dist/main.js. The proxy sends same-origin command/query and identity/stream requests to the backend. ws: true also permits the optional WebSocket transport. Keep both prefixes, even when using the default SSE transport.

Port 5000 is this tutorial’s ASP.NET Core host. A Spring Boot host listens on 8080 by default; the TypeScript standalone host uses 3000. Point both proxy targets at that backend’s loopback port (or set server.port=5000 for Spring Boot), and keep the rest of the file as it is. Everything from here to the end of the chapter is generated-proxy and React code, identical on all three backends.

  1. Read authors with the query proxy. Create src/Authors.tsx:

    import { AllAuthors } from './Authors/Author';
    export const Authors = () => {
    const [authors] = AllAuthors.use();
    return (
    <ul>
    {authors.data.map(a => <li key={String(a.id)}>{a.name}</li>)}
    </ul>
    );
    };

    The hook subscribes to the observable query and re-renders on each new result. String conversion is only for React’s key; retain the generated Guid when passing ids to commands and queries.

  2. Add the command dialog. Create src/AddAuthor.tsx:

    import { useState } from 'react';
    import { Guid } from '@cratis/fundamentals';
    import { CommandDialog } from '@cratis/components/CommandDialog';
    import { InputTextField } from '@cratis/components/CommandForm';
    import { RegisterAuthor } from './Authors/RegisterAuthor';
    export const AddAuthor = () => {
    const [id] = useState(() => Guid.create());
    return (
    <CommandDialog<RegisterAuthor>
    command={RegisterAuthor}
    title="Add author"
    okLabel="Add"
    initialValues={{ id }}>
    <InputTextField<RegisterAuthor> value={i => i.name} title="Name" />
    </CommandDialog>
    );
    };

    Open this through the composition below: mount a new instance per operation and unmount on close. The id remains stable while editing and is fresh next time. It is required, so it belongs in initialValues, not onBeforeExecute (which runs too late to enable confirmation).

Mount the screen and verify the round trip

Section titled “Mount the screen and verify the round trip”

Create src/App.tsx:

import { Arc } from '@cratis/arc.react';
import { useDialog } from '@cratis/arc.react/dialogs';
import { CratisComponentsProvider } from '@cratis/components';
import { AddAuthor } from './AddAuthor';
import { Authors } from './Authors';
function Library() {
const [AddAuthorDialog, showAddAuthor] = useDialog(AddAuthor);
return (
<>
<button onClick={() => void showAddAuthor()}>Add author</button>
<Authors />
<AddAuthorDialog />
</>
);
}
export const App = () => (
<Arc>
<CratisComponentsProvider>
<Library />
</CratisComponentsProvider>
</Arc>
);

useDialog mounts its content only while open, so each registration gets new id state. <Arc> supplies runtime bindings; CratisComponentsProvider supplies the UI library context and does not replace it. No generated Bindings file is needed.

Create src/main.tsx and import Components’ styles once, with tokens first:

import 'reflect-metadata';
import '@cratis/components/tokens';
import '@cratis/components/styles';
import '@cratis/components/theme';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(<App />);

tokens supplies shared CSS variables, styles supplies component structure, and theme adds the baseline appearance, including dark mode. You can omit theme when a product design system supplies its own token values.

Those three subpaths are stylesheets, so they ship no type declarations. Current TypeScript releases check side-effect imports and report TS2882: Cannot find module or type declarations for side-effect import for each one. Declare them once in src/cratis-components-styles.d.ts:

declare module '@cratis/components/tokens';
declare module '@cratis/components/styles';
declare module '@cratis/components/theme';

This keeps side-effect checking on for every other import, so a mistyped package name still fails the type-check.

Keep the backend running on the port your Vite proxy targets — 5000 for ASP.NET Core, 8080 for default Spring Boot, or 3000 for the TypeScript standalone host. From another terminal in Library/, run:

Terminal window
npx tsc -p tsconfig.web.json --noEmit
npx vite build
npx vite

For the TypeScript backend setup, use npx tsc6 -p tsconfig.web.json --noEmit in place of npx tsc, keeping npm run generate on the original backend tsconfig.json before building the browser project. Open http://localhost:5173. The first two commands check TypeScript and the production bundle; only the following browser checks prove the round trip:

  1. Open the author list and add an author with a nonblank name.
  2. Confirm that the list displays the saved name without refresh.
  3. Open a new dialog and add another author. Inspect the query data: the two ids must differ.
  4. Reload the browser and confirm both authors remain.

These checks prove a command/query/persistence round trip. A successful build alone proves none of those effects.

The accessor i => i.name is a property on the generated command, not a string. This backend excerpt supplies that contract in each language:

[Command]
public record RegisterAuthor(AuthorId Id, AuthorName Name)
{
public Task Handle(IMongoCollection<Author> authors) =>
authors.InsertOneAsync(new Author(Id, Name));
}

View C# snippet source on GitHub

The React fragment uses the generated proxy (the id state is initialized in AddAuthor above):

<CommandDialog<RegisterAuthor> command={RegisterAuthor} title="Add author" initialValues={{ id }}>
<InputTextField<RegisterAuthor> value={i => i.name} title="Name" />
</CommandDialog>

Rename Name in C# (or name on Kotlin, Java, or TypeScript), regenerate the proxies with dotnet build -c Debug, ./gradlew generateArcProxies, or npm run generate respectively, then run the frontend type-check. The old accessor fails compilation. Never repair this by editing generated code.

One feature now has an explicit write, a live read, and typed React callers — all over current-state storage. There’s still a problem: blank and duplicate names are accepted. Let’s make it trustworthy →