Skip to content

Understanding the proxy boundary

Every full-stack app has a seam down the middle: a backend 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 connects that seam. You declare the command or query once on the backend and generate 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: the backend declaration is the contract. The TypeScript proxy isn’t a parallel definition you maintain — it is generated from that declaration. In the C# example below, the RegisterAuthor record defines the shape; JVM and TypeScript backends use their own declarations and generators.

Debug build · post-build assembly/PDB tool

import + call

HTTP — typed both ends

C# record — [Command] RegisterAuthor

generated TS proxy — RegisterAuthor

React — CommandDialog · .use()

Generating from the same types catches structural drift at compile time when you rebuild and type-check both sides. It does not prove that a deployed client matches a deployed server, that runtime serialization customizations agree, or that build-time routes match runtime configuration. Those still need integration checks.

  1. Write the slice on the backend. A command is a record (C#), a data class (Kotlin), or a decorated class (TypeScript) with a handler on it. This standalone excerpt uses the tutorial’s domain types, imports, and provider setup:
[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

  1. Configure and generate. Each backend discovers commands, queries, referenced types and supported validation rules from its own declarations. Generation is not an automatic step of every build; configure and run the appropriate backend tool.

    On C#, install Cratis.Arc.ProxyGenerator.Build and set CratisProxiesOutputPath as in generator setup, then run dotnet build -c Debug. An MSBuild target runs after the build and invokes a post-build executable that inspects the compiled assembly and its PDB — it is not a Roslyn source generator. Without an output path the target does nothing, which is also how you skip generation on a second build.

    On Kotlin and Java, apply the io.cratis.arc Gradle plugin and configure cratisArc { proxies { } } as in TypeScript proxies. A KSP annotation processor runs as part of compilation and records what it found in an artifact manifest, and the generateArcProxies Gradle task turns that manifest into TypeScript.

    On TypeScript, the source-preview arc-proxygenerator reads backend TypeScript source with --project and --artifacts, and writes client proxies and optionally server metadata. Run your application’s generation script when declarations change; see TypeScript generator setup. The preview has its own capability reference, not a guarantee of parity with C# or JVM.

    Namespace folders, source-file placement, and index output depend on each backend’s generator configuration.

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

    import { RegisterAuthor } from './Authors/RegisterAuthor'; // generated
    // Form fragment: retain the imports/providers and per-operation id state
    // from the first-slice tutorial; mount a new dialog instance per registration.
    <CommandDialog<RegisterAuthor> command={RegisterAuthor} title="Add author" initialValues={{ id }}>
    <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 the name property on the command:

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

View C# snippet source on GitHub

Rebuild, and the generated proxy has fullName where it used to have name. The React component that still reads the old property stops compiling — caught at build, not in production:

// This line stops compiling until you fix it:
<InputTextField<RegisterAuthor> value={i => i.name} title="Name" />
// ^^^^ Property 'name' does not exist on RegisterAuthor

You didn’t run the app. You didn’t open the browser. After regeneration and a frontend type-check, the TypeScript compiler catches the drift, 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.

A configured generator build can emit:

  • 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 — no polling, no manual refresh.
  • 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.

Types are only half of what the proxy absorbs. The other half is how the call travels. Your component never writes a URL, picks an HTTP verb, or opens a socket — it executes RegisterAuthor or renders AllAuthors.use(), and the proxy owns the wire. That makes the transport an implementation detail behind the boundary: today the calls ride HTTP, and if Arc carried them over something else — gRPC-Web, say — the proxy is where that would change, not your components.

The query system already cashes in on this freedom. A regular query executes as a plain HTTP request. An observable query streams — over WebSockets or Server-Sent Events, a choice you make once with the queryTransportMethod prop on the <Arc/> context component, not in any component. And the same observable query can still be executed as a one-shot HTTP fetch by calling perform() on the proxy, when you want a snapshot instead of a subscription:

const [authors] = AllAuthors.use(); // live — streams over WebSockets or SSE
const snapshot = await new AllAuthors().perform(); // one-shot — a plain HTTP fetch, same proxy

One backend declaration, one generated frontend proxy — and whether the data arrives as a response or a stream is the proxy’s concern, not yours. Tune the live transport in the query configuration reference.

It’s a build step, not a runtime discovery call

Section titled “It’s a build step, not a runtime discovery call”

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

  • You never edit a generated file. Change the backend declaration and regenerate; TypeScript’s source preview may commit generated proxies alongside the source.
  • On C#, the generator tracks what it made and removes files for commands or queries you delete. See File index tracking; the TypeScript generator also limits stale deletion to files it owns.
  • The sequencing matters: generate proxies from updated backend declarations, then type-check the frontend. C# requires a successful Debug build first; the TypeScript generator reads source with the project configuration.

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 can cross that boundary without hand-maintaining two declarations. Regeneration and frontend type-checking catch structural drift; deployed versions still need integration checks.

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.