Skip to content

Understanding the proxy boundary

Every full-stack app has a seam down the middle: C# on one side, TypeScript on the other, HTTP in between. Normally you are the glue across that seam — you write a controller, hand-write a DTO, redeclare its shape in TypeScript, wrap it in a fetch, and then spend the rest of the project keeping both sides in agreement. Nothing tells you when they drift: a renamed field compiles happily on each side and fails in the browser at runtime.

Arc removes that seam. You write the command or query once, in C#, and the build generates the TypeScript your frontend calls. There’s no second declaration to keep in sync, because there’s no second declaration at all. This page is about why that works and what keeps it honest — the idea at the center of everything Cratis calls “full-stack type safety.”

The thing to internalize: your C# command and query records are the contract. The TypeScript proxy isn’t a parallel definition you maintain — it’s a projection of the C# types, regenerated on every build. There is exactly one place the shape of RegisterAuthor is defined, and it’s the C# record.

dotnet build · Roslyn generator

import + call

HTTP — typed both ends

C# record

[Command] RegisterAuthor

generated TS proxy

RegisterAuthor

React

CommandDialog · .use()

Because the proxy is generated from the very types Arc uses to bind the incoming HTTP request, the client and the server can’t disagree about the wire format. The compiler — on both sides — is enforcing one contract.

  1. Write the slice in C#. A command is a record; the behavior lives in Handle() on the record. This is the only place the shape is declared:

    [Command]
    public record RegisterAuthor(AuthorId Id, AuthorName Name)
    {
    public AuthorRegistered Handle() => new(Name);
    }
  2. Build. dotnet build runs the Arc proxy generator — a Roslyn source generator wired in as an MSBuild step. It reads your compiled assembly, finds every [Command], every [ReadModel] query method, and the types and enums they touch, and writes a typed TypeScript proxy for each — mirroring your namespace folders, with an index.ts per folder.

  3. Import the proxy in React. It’s already there, generated from the C# above — call it as if it were local, typed code:

    import { RegisterAuthor } from './Authors/RegisterAuthor'; // generated
    <CommandDialog command={RegisterAuthor} title="Add author">
    <InputTextField<RegisterAuthor> value={i => i.name} title="Name" />
    </CommandDialog>

Here’s the payoff — and the whole reason the boundary is generated rather than written. Suppose you rename Name to FullName on the command:

[Command]
public record RegisterAuthor(AuthorId Id, AuthorName FullName) // was Name
{
public AuthorRegistered Handle() => new(FullName);
}

You didn’t run the app. You didn’t open the browser. The TypeScript compiler caught the drift the instant the contract changed, because the accessor i => i.name points at a property the regenerated type no longer has. That’s the difference between a generated boundary and a hand-written one: drift is a compile error, not a production incident.

Each build, Arc emits typed proxies for:

  • Commands — a class you instantiate and execute (or hand to a CommandDialog), with every parameter — route, query string, body — flattened into typed properties.
  • Queries — a proxy exposing a React .use() hook that returns the typed result with its loading and error state; observable queries return a live subscription that re-renders on change, with no perform to call and no polling.
  • Types and enums — every complex type and enum your commands and queries reference, so nested shapes are typed too.
  • Identity details — your custom identity type, so the signed-in user is typed on the frontend.

Each has a detailed reference: Commands, Queries, and Identity details. The frontend proxy-generation guide shows the generated TypeScript for each, with the .use() and useSuspense() hooks.

A few things follow from the proxies being generated, and they’re worth holding onto:

  • You never edit a generated file. Treat the generated folder like build output — change the C#, rebuild, and the TypeScript follows.
  • The generator tracks what it made and removes files for commands or queries you delete, so the generated tree doesn’t accumulate ghosts. (See File index tracking.)
  • The sequencing is fixed: backend compiles → proxies generate → the frontend can reference them. For a single slice you can’t build the React half before the C# half compiles — which is exactly why the workflow is backend-first.

Plenty of frameworks give you CQRS, or an event store, or a component library. The proxy boundary is what makes Cratis full-stack: the type safety doesn’t stop at the edge of the backend — it runs all the way to the React component. It’s why a Cratis feature is one thing expressed in two languages, kept in agreement by the build instead of by discipline.

Ready to see it run? Build your first command and query, then wire it into React — and watch the same property name flow from C# all the way to the screen.