1. Your first full-stack slice
Every app starts with one feature. Ours is registering an author — the first thing a librarian does before they can catalog a single book. It’s deliberately small, but building it touches the entire Arc loop: a command that expresses intent, the read model it updates, the query that serves it, and the React screen that calls both — all of it typed end to end.
In a layered app these would be files scattered across Commands/, Handlers/, and ReadModels/, and you’d jump between folders to follow one behavior. Arc organizes by feature: everything below lives in one Features/Authors/ folder you read top to bottom. Here’s the slice we’re about to build:
The backend half
Section titled “The backend half”-
Give the domain strong types. Never thread raw
Guids andstrings through your domain — wrap them so the compiler keeps them straight and your signatures document themselves:public record AuthorId(Guid Value) : ConceptAs<Guid>(Value){public static AuthorId New() => new(Guid.NewGuid());}public record AuthorName(string Value) : ConceptAs<string>(Value){public static implicit operator AuthorName(string value) => new(value);} -
Write the command — with
Handle()on the record. A command is arecordmarked[Command]. The behavior lives in aHandle()method on the record itself; there’s no separate handler class to hunt for. Here it writes the new author straight to the database:[Command]public record RegisterAuthor(AuthorId Id, AuthorName Name){public Task Handle(IMongoCollection<Author> authors) =>authors.InsertOneAsync(new Author(Id, Name));}[Command]public record RegisterAuthor(AuthorId Id, AuthorName Name){public async Task Handle(LibraryDbContext db){db.Authors.Add(new Author(Id, Name));await db.SaveChangesAsync();}}Handle()returnsTaskbecause there’s nothing to report back — the write is the outcome. Arc injects the dependency it declares (the Mongo collection, or yourDbContext) from the container. -
Declare the read model and its query. The read model is just the shape you want to query, marked
[ReadModel]. A static method is the query — return an observable so consumers get live updates:[ReadModel]public record Author([property: Key] AuthorId Id, AuthorName Name){public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) =>collection.Observe();}[ReadModel]public record Author([property: Key] AuthorId Id, AuthorName Name){public static ISubject<IEnumerable<Author>> AllAuthors(LibraryDbContext db) =>db.Authors.Observe();}// A BaseDbContext registered via WithEntityFrameworkCore — see the EF integration guide.public class LibraryDbContext(DbContextOptions<LibraryDbContext> options) : BaseDbContext(options){public DbSet<Author> Authors => Set<Author>();} -
Wire the database in once at startup, then build.
var builder = WebApplication.CreateBuilder(args);builder.AddCratisArc();builder.UseCratisMongoDB();var builder = WebApplication.CreateBuilder(args);builder.AddCratisArc(configureBuilder: arc => arc.WithEntityFrameworkCore());// your DbContext is auto-discovered and registeredTerminal window dotnet buildBuilding compiles your C# and — the part that matters most for the next half — generates TypeScript proxies for
RegisterAuthorandAllAuthors. Your frontend is about to call them as if they were local, typed code.
The frontend half
Section titled “The frontend half”The proxies now exist, generated from your C#. Normally this is exactly where type safety ends — you’d hand-write a fetch, redeclare the shapes in TypeScript, and hope the two stay in sync. We skip all of it. None of this React changes whether the backend is MongoDB or EF Core.
-
Read the authors with the query proxy. Because
AllAuthorsis an observable query, the.use()hook re-renders whenever the read model changes — live, no polling:Authors.tsx import { AllAuthors } from './Authors/Author'; // generated proxyexport const Authors = () => {const [authors] = AllAuthors.use();return (<ul>{authors.data.map(a => <li key={String(a.id)}>{a.name}</li>)}</ul>);}; -
Register one with the command proxy.
CommandDialogruns a generated command — it instantiates it, renders the form fields and the confirm/cancel buttons, and disables confirm while it executes:AddAuthor.tsx import { CommandDialog } from '@cratis/components/CommandDialog';import { InputTextField } from '@cratis/components/CommandForm';import { RegisterAuthor } from './Authors/RegisterAuthor'; // generated proxyexport const AddAuthor = () => (<CommandDialog<RegisterAuthor> command={RegisterAuthor} title="Add author" okLabel="Add"><InputTextField<RegisterAuthor> value={i => i.name} title="Name" /></CommandDialog>);
Run the app, register an author, and the list updates the moment you confirm — you didn’t write a line of refresh logic. AllAuthors.use() is subscribed to the read model, so when the command writes its document, the screen re-renders itself.
Where the type safety lives
Section titled “Where the type safety lives”Look at the accessor i => i.name. It isn’t a string you typed and hope matches — it’s a property on the generated RegisterAuthor type. Rename Name in the C# command, rebuild, and i => i.name stops compiling until you fix it. The whole feature is one thing expressed in two languages, and the build is what keeps them honest:
[Command]public record RegisterAuthor(AuthorId Id, AuthorName Name){ public Task Handle(IMongoCollection<Author> authors) => authors.InsertOneAsync(new Author(Id, Name));}// generated from the C# above — call it, don't redeclare it<CommandDialog<RegisterAuthor> command={RegisterAuthor} title="Add author"> <InputTextField<RegisterAuthor> value={i => i.name} title="Name" /></CommandDialog>What you built
Section titled “What you built”In one folder, read top to bottom:
- a
[Command]withHandle()— intent and implementation together, no handler class, - a
[ReadModel]whose query method is served over HTTP, live, and - a React screen that reads and writes it through generated, typed proxies.
That’s a complete vertical slice, backend to browser, over a plain database. The next feature will be another folder just like it.
There’s one problem, though: right now a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A real app has to say no. Let’s make it trustworthy →