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.
Prerequisites
Section titled “Prerequisites”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.
Follow the backend half
Section titled “Follow the backend half”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.
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:
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;}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();}@Command@AllowAnonymouspublic record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); }}
@ReadModel@AllowAnonymouspublic record Author(AuthorId id, AuthorName name) { public static Flow.Publisher<List<Author>> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); }}@Command@AllowAnonymousdata class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) }}
@ReadModel@AllowAnonymousdata class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic fun allAuthors(@FromServices queries: MongoObservableQuery): Flow<List<Author>> = queries.observe<Author>() }}import { field } from '@cratis/fundamentals';import { command, inject, query, readModel, service, type ObservableSource } from '@cratis/arc.core';
@command()export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@inject(AuthorRepository) handle(authors: AuthorRepository): Promise<void> { return authors.save({ id: this.id, name: this.name }); }}
@readModel()export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource<Author[]> { return authors.observeAll(); }}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();}@Entity@Table(name = "authors")class AuthorRow { @Id UUID id; @Column(name = "name", nullable = false) String name; protected AuthorRow() {} AuthorRow(UUID id, String name) { this.id = id; this.name = name; }}
@Command@AllowAnonymouspublic record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); }}
@ReadModel@AllowAnonymouspublic record Author(AuthorId id, AuthorName name) { public static Flow.Publisher<List<Author>> allAuthors(@FromServices JpaObservableQuery queries) { return queries.observePublisher(Author.class, em -> em.createQuery("select a from AuthorRow a", AuthorRow.class).getResultList().stream() .map(row -> new Author(new AuthorId(row.id), new AuthorName(row.name))).toList()); }}@Entity@Table(name = "authors")class AuthorRow( @field:Id var id: UUID = UUID(0, 0), @field:Column(name = "name", nullable = false) var name: String = "")
@Command@AllowAnonymousdata class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) }}
@ReadModel@AllowAnonymousdata class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic fun allAuthors(@FromServices queries: JpaObservableQuery): Flow<List<Author>> = queries.observe(Author::class.java, JpaSnapshotQuery { em -> em.createQuery("select a from AuthorRow a", AuthorRow::class.java) .resultList.map { Author(AuthorId(it.id), AuthorName(it.name)) } }) }}import { field } from '@cratis/fundamentals';import { command, inject, query, readModel, service, type ObservableSource } from '@cratis/arc.core';
@command()export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@inject(AuthorRepository) handle(authors: AuthorRepository): Promise<void> { return authors.save({ id: this.id, name: this.name }); }}
@readModel()export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource<Author[]> { return authors.observeAll(); }}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.
Build before importing
Section titled “Build before importing”dotnet build -c DebugWith 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.
Set up the browser project
Section titled “Set up the browser project”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:
npm init -ynpm pkg set type=modulenpm 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.0npm install --save-dev vite @types/react @types/react-domFor 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.
Read and write from React
Section titled “Read and write from React”-
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
Guidwhen passing ids to commands and queries. -
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, notonBeforeExecute(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:
npx tsc -p tsconfig.web.json --noEmitnpx vite buildnpx viteFor 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:
- Open the author list and add an author with a nonblank name.
- Confirm that the list displays the saved name without refresh.
- Open a new dialog and add another author. Inspect the query data: the two ids must differ.
- 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.
Where type safety lives
Section titled “Where type safety lives”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));}@Command@AllowAnonymouspublic record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); }}@Command@AllowAnonymousdata class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) }}import { field } from '@cratis/fundamentals';import { command, inject } from '@cratis/arc.core';
@command()export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@inject(AuthorRepository) handle(authors: AuthorRepository): Promise<void> { return authors.save({ id: this.id, name: this.name }); }}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.
What you built
Section titled “What you built”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 →