Build a full-stack feature
This is where the three products meet. You’ll build one vertical slice of a library app — registering an author and listing authors — end to end. Choose C#, Kotlin, Java, or TypeScript for the backend; the TypeScript backend is a source preview with no scaffold yet, so you start it from a sample. In every version, an Arc command produces a Chronicle event; a read model turns that event into a list; a React screen (Components) uses generated, type-safe proxies to call the backend. No hand-written API client, no DTO duplication.
Keep the feature under one module — that’s the vertical-slice idea: you navigate by behavior, not by technical layer.
Here’s the slice as an event model — read left to right, the user acts on a screen, a command records a fact, that fact is projected into a read model, and the next screen reads it:
The blocks are the slice, one per product: the ui screens are Components, the cmd and the rmo’s query are Arc, the evt is Chronicle, and the build generates the typed proxies that connect them. Frames 01–03 are the command pattern (intent → fact); 03–05 are the view pattern (fact → read model → screen). Pick a backend language in the tabs below; your selection follows you through the steps.
Before you start
Section titled “Before you start”Choose one backend to follow:
- C#: Install the .NET 10 template and run
dotnet new cratis -o Library. The buildable Capstone sample contains this exact slice. FromLibrary/, rundocker compose up -d,dotnet build, thendotnet run. The development Compose file exposes its ports on all interfaces; bind them to127.0.0.1or use a trusted machine. Start the Vite frontend with the package manager selected by your scaffold (for example,npm run dev). For brevity, the C# slice keeps its four types (AuthorId,RegisterAuthor,AuthorRegistered,Author) in oneAuthors/folder at the project root; the generated proxies land beside them, which is why the screen below imports./RegisterAuthorand./Author. - Kotlin or Java: Run
dotnet new cratis --language Kotlin -o Libraryordotnet new cratis --language Java -o Library. With JDK 17 active, rundocker compose up -d,./gradlew build, then./gradlew bootRunfromLibrary/. As with C#, the development Compose file exposes its ports on all interfaces; bind them to127.0.0.1or use a trusted machine. Start the Vite frontend separately, following the generated Kotlin or Java README. The Gradle build generates the proxies underbuild/generated/arc-proxies/. - TypeScript (source preview): There is no TypeScript scaffold yet. Start from the Arc TypeScript Library sample: from the Arc.TypeScript repository root run
yarn install,docker compose -f Samples/Library/docker-compose.yml up -d, thenyarn workspace @cratis/arc.sample.library dev. The TypeScript tabs mirror the sample’sFeatures/Authors/AuthorId.ts,AuthorName.ts,Registration/Registration.tsandListing/Listing.ts, without itsLibrarianrole, unique-name constraint andauthorsPagequery;AuthorName.tsadds the name validation. The sample builds its proxies beside each feature as*.proxy.ts; see its README for setup and the local-only development identity.
The scaffolded C# and JVM Compose configurations start Chronicle with the default MongoDB choice, which persists the projected read model. Keep the four backend roles in one feature: host, author id, command/event, and read model/query. The sample layout differs by language; follow its neighboring files for imports and wiring.
How the backends differ in this slice
Section titled “How the backends differ in this slice”The steps below are the same for every backend. Where the code differs, this table says how, and each row names the step it applies to:
| Behavior | C# | Kotlin and Java | TypeScript (preview) |
|---|---|---|---|
| Host change (step 1) | None; the scaffold’s AddCratis host | Add the author types to KnownClientArtifacts | None; the Library sample’s host |
| Author id in the generated proxy (step 2) | Guid, from EventSourceId<Guid> | Guid, from a UUID-backed concept | Guid, from ConceptAs<Guid> |
| Anonymous callers (step 3) | Accepted; the command declares no authorization | Rejected by default; the author types carry @AllowAnonymous | Accepted; the command declares no authorization |
| Read model (step 4) | Projection from the event, with AutoMap | Reducer keyed by the event’s source id | Projection from the event |
| Author list (step 4) | Live: re-renders when the projection changes | Live: observes the first page of 50 materialized instances; needs Chronicle 19.22.2 or later | Live: re-renders when the projection changes |
| Generated proxies (step 4) | dotnet build, beside the types in Authors/ | ./gradlew build, under build/generated/arc-proxies/ | The sample’s build, as *.proxy.ts beside each feature |
1. Wire the host (backend)
Section titled “1. Wire the host (backend)”The host connects Arc to Chronicle and exposes generated command and query endpoints. The scaffolds and Library sample supply this wiring; find it before changing the feature. C# and TypeScript need no change: their tabs show the existing host for orientation. On Kotlin and Java, add the three author types to the scaffold’s KnownClientArtifacts in ChronicleConfiguration, so an executable jar registers them. Keep the scaffold’s existing imports and add imports for the author types. Keep its sink fallback too: a non-MongoDB --Database generates WellKnownSinkTypes.SQL there, not MONGODB.
var builder = WebApplication.CreateBuilder(args);
builder.AddCratis( configureArcBuilder: arcBuilder => arcBuilder.WithMongoDB(configureMongoDB: mongoBuilder => mongoBuilder.WithCamelCaseNamingPolicy()), configureChronicleBuilder: chronicleBuilder => chronicleBuilder.WithCamelCaseNamingPolicy());
builder.Services.AddControllers();builder.Services.AddMvc();builder.Services.AddEndpointsApiExplorer();builder.Services.AddSwaggerGen(options => options.AddConcepts());
var app = builder.Build();
app.UseRouting();
app.UseDefaultFiles();app.UseStaticFiles();
app.UseWebSockets();app.MapControllers();app.UseCratis();
app.UseSwagger();app.UseSwaggerUI();app.MapFallbackToFile("/index.html");
await app.RunAsync();import io.cratis.chronicle.ChronicleOptions;import io.cratis.chronicle.artifacts.IArtifactActivator;import io.cratis.chronicle.artifacts.KnownClientArtifacts;import io.cratis.chronicle.connection.ChronicleConnectionString;import io.cratis.chronicle.sinks.WellKnownSinkTypes;import io.cratis.chronicle.spring.ChronicleProperties;import java.util.List;import kotlin.jvm.JvmClassMappingKt;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)public class ChronicleConfiguration { @Bean public ChronicleOptions chronicleOptions( ChronicleProperties properties, IArtifactActivator artifactActivator, @Value("${spring.application.name:Unknown}") String applicationName ) { var artifacts = new KnownClientArtifacts(List.of( JvmClassMappingKt.getKotlinClass(Registered.class), JvmClassMappingKt.getKotlinClass(Listing.class), JvmClassMappingKt.getKotlinClass(ListingReducer.class), JvmClassMappingKt.getKotlinClass(RegistrationReactor.class), JvmClassMappingKt.getKotlinClass(AuthorRegistered.class), JvmClassMappingKt.getKotlinClass(Author.class), JvmClassMappingKt.getKotlinClass(AuthorReducer.class))); var sinkType = properties.getDefaultSinkTypeId(); if (sinkType == null) sinkType = System.getenv("CHRONICLE_SINK_TYPE"); if (sinkType == null) sinkType = WellKnownSinkTypes.MONGODB; var programIdentifier = properties.getProgramIdentifier() == null ? applicationName : properties.getProgramIdentifier(); return new ChronicleOptions( ChronicleConnectionString.Companion.parse(properties.getConnectionString()), programIdentifier, sinkType, properties.getAutoDiscoverAndRegister(), artifacts, artifactActivator); }}import io.cratis.chronicle.ChronicleOptionsimport io.cratis.chronicle.artifacts.IArtifactActivatorimport io.cratis.chronicle.artifacts.KnownClientArtifactsimport io.cratis.chronicle.connection.ChronicleConnectionStringimport io.cratis.chronicle.sinks.WellKnownSinkTypesimport io.cratis.chronicle.spring.ChroniclePropertiesimport org.springframework.beans.factory.annotation.Valueimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration
@Configuration(proxyBeanMethods = false)class ChronicleConfiguration { @Bean fun chronicleOptions( properties: ChronicleProperties, artifactActivator: IArtifactActivator, @Value("\${spring.application.name:Unknown}") applicationName: String ): ChronicleOptions = ChronicleOptions( connectionString = ChronicleConnectionString.parse(properties.connectionString), programIdentifier = properties.programIdentifier ?: applicationName, defaultSinkTypeId = properties.defaultSinkTypeId ?: System.getenv("CHRONICLE_SINK_TYPE") ?: WellKnownSinkTypes.MONGODB, autoDiscoverAndRegister = properties.autoDiscoverAndRegister, artifacts = KnownClientArtifacts( Registered::class, Listing::class, ListingReducer::class, RegistrationReactor::class, AuthorRegistered::class, Author::class, AuthorReducer::class ), artifactActivator = artifactActivator )}import express from 'express';import { ArcApplication } from '@cratis/arc.core';import { cratisArc } from '@cratis/arc.express';import { withChronicle } from '@cratis/arc.chronicle';import { metadata } from './Features/generatedMetadata.js';
const builder = ArcApplication.createBuilder({ development: true, nativePrincipal: true, tenancy: { resolve: () => 'Default' }, query: { allowedOrigins: ['http://127.0.0.1:5173'] } });builder.useGeneratedMetadata(metadata);withChronicle(builder, process.env.CHRONICLE_URL ? { connectionString: process.env.CHRONICLE_URL } : {});await builder.discover(new URL('./Features/', import.meta.url));const application = await builder.build();const host = express();// Demo-only trusted principal: never accept a role from an HTTP header.const demoIdentity = () => ({ principal: { id: 'demo-librarian', roles: ['Librarian'], isAuthenticated: true } });const arc = cratisArc(application, demoIdentity);host.use(arc);const port = Number(process.env.PORT ?? 3000);const listener = host.listen(port, '127.0.0.1', () => console.log(`Library listening on http://127.0.0.1:${port}`));const closeWebSockets = arc.injectWebSocket(listener, demoIdentity);const stop = async () => { await closeWebSockets(); await new Promise<void>((resolve, reject) => listener.close(error => error ? reject(error) : resolve())); await application.dispose();};process.once('SIGINT', () => { void stop().catch(error => { console.error(error); process.exitCode = 1; }); });process.once('SIGTERM', () => { void stop().catch(error => { console.error(error); process.exitCode = 1; }); });2. Identify the event stream (backend)
Section titled “2. Identify the event stream (backend)”Chronicle uses the command key to select the author’s event stream. Every backend backs AuthorId with a GUID, so the generated command proxy types the id as a Guid whichever backend you chose. In C#, AuthorId derives from EventSourceId<Guid>, which also makes it the read model’s key without a [Key] attribute:
public record AuthorId(Guid Value) : EventSourceId<Guid>(Value){ public static readonly AuthorId NotSet = new(Guid.Empty); public static implicit operator AuthorId(Guid value) => new(value); public static AuthorId New() => new(Guid.NewGuid());}import io.cratis.arc.concepts.ConceptAs;import java.util.UUID;
public record AuthorId(UUID value) implements ConceptAs<UUID>, io.cratis.chronicle.concepts.ConceptAs<UUID> { @Override public UUID getValue() { return value; }}import io.cratis.arc.concepts.ConceptAs as ArcConceptAsimport io.cratis.chronicle.concepts.ConceptAs as ChronicleConceptAsimport java.util.UUID
data class AuthorId(private val id: UUID) : ArcConceptAs<UUID>, ChronicleConceptAs<UUID> { override fun value(): UUID = id override val value: UUID get() = id
companion object { fun new(): AuthorId = AuthorId(UUID.randomUUID()) }}import { ConceptAs, Guid } from '@cratis/fundamentals';
export class AuthorId extends ConceptAs<Guid> { static readonly valueType = Guid; static create(): AuthorId { return new AuthorId(Guid.create()); }}3. Register the author (backend)
Section titled “3. Register the author (backend)”The command expresses intent; its handler returns the event that happened. Chronicle appends that event to the stream selected by the id. The Kotlin and Java command and read model carry @AllowAnonymous, as the scaffold’s own Register and Listing do, because Arc.Kotlin rejects anonymous callers by default and the JVM scaffolds have no sign-in:
[Command]public record RegisterAuthor(AuthorId Id, string Name){ public AuthorRegistered Handle() => new(Name);}
[EventType]public record AuthorRegistered(string Name);import io.cratis.arc.artifacts.Command;import io.cratis.arc.artifacts.CommandKey;import io.cratis.arc.authorization.AllowAnonymous;import io.cratis.chronicle.events.EventType;
@Command@AllowAnonymouspublic record RegisterAuthor(@CommandKey AuthorId id, String name) { public AuthorRegistered handle() { return new AuthorRegistered(name); }}
@EventTypepublic record AuthorRegistered(String name) { }import io.cratis.arc.artifacts.Commandimport io.cratis.arc.artifacts.CommandKeyimport io.cratis.arc.authorization.AllowAnonymousimport io.cratis.chronicle.events.EventType
@Command@AllowAnonymousdata class RegisterAuthor(@CommandKey val id: AuthorId, val name: String) { fun handle(): AuthorRegistered = AuthorRegistered(name)}
@EventTypedata class AuthorRegistered(val name: String = "")import { field } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle/events';import { command, key } from '@cratis/arc.core';import { AuthorId } from '../AuthorId.js';import { AuthorName } from '../AuthorName.js';
@eventType()export class AuthorRegistered { @field(AuthorName) name: AuthorName; constructor(name: AuthorName = new AuthorName('')) { this.name = name; }}
@command()export class RegisterAuthor { @key() @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
handle(): AuthorRegistered { return new AuthorRegistered(this.name); }}4. Build and query the author list (backend)
Section titled “4. Build and query the author list (backend)”The read model describes the list entry; the event feeds it, and a query exposes the list. C# and TypeScript declare a projection from the event; in C#, AutoMap matches AuthorRegistered.Name to Name. Kotlin and Java use a reducer keyed by the event context’s source id, rather than a model-bound event mapping:
[ReadModel][FromEvent<AuthorRegistered>]public record Author(AuthorId Id, string Name){ public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) => collection.Observe();}import io.cratis.arc.artifacts.FromServices;import io.cratis.arc.authorization.AllowAnonymous;import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.java.ReadModelsJavaBridge;import io.cratis.chronicle.observation.Reducer;import io.cratis.chronicle.IEventStore;import java.util.List;import java.util.concurrent.Flow;
@io.cratis.arc.artifacts.ReadModel@io.cratis.chronicle.readModels.ReadModel@AllowAnonymouspublic record Author(String id, String name) { public static Flow.Publisher<List<Author>> allAuthors(@FromServices IEventStore store) { return ReadModelsJavaBridge.observeMaterializedInstancesPublisher(store.getReadModels(), Author.class, 0, 50); }}
@Reducerpublic final class AuthorReducer { public Author registered(AuthorRegistered event, Author state, EventContext context) { return new Author(context.getEventSourceId(), event.name()); }}import io.cratis.arc.artifacts.FromServicesimport io.cratis.arc.artifacts.ReadModel as ArcReadModelimport io.cratis.arc.authorization.AllowAnonymousimport io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModel as ChronicleReadModelimport kotlinx.coroutines.flow.Flow
@ArcReadModel@ChronicleReadModel@AllowAnonymousdata class Author(val id: String = "", val name: String = "") { companion object { @JvmStatic fun allAuthors(@FromServices eventStore: IEventStore): Flow<List<Author>> = eventStore.readModels.materialized.observeInstances(Author::class, 0, 50) }}
@Reducerclass AuthorReducer { fun registered(event: AuthorRegistered, state: Author?, context: EventContext): Author = Author(id = context.eventSourceId, name = event.name)}import { field } from '@cratis/fundamentals';import { query, readModel, service } from '@cratis/arc.core';import { ChronicleReadModels } from '@cratis/arc.chronicle';import type { Observable } from 'rxjs';import { fromEvent } from '@cratis/chronicle/projections';import { AuthorRegistered } from '../Registration/Registration.js';import { AuthorId } from '../AuthorId.js';import { AuthorName } from '../AuthorName.js';
@readModel()@fromEvent(AuthorRegistered)export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@query({ observable: true }, service(ChronicleReadModels)) static allAuthors(models: ChronicleReadModels): Observable<Author[]> { return models.observeAll(Author, author => author.id.toString()); }}Build your backend now (dotnet build, ./gradlew build, or the Library sample’s yarn workspace @cratis/arc.sample.library build). The build generates TypeScript command and query proxies: change a property on the backend and the frontend must compile against the updated shape. Chronicle projects asynchronously, so a successful command can precede the updated list. On Kotlin and Java, allAuthors observes a page of materialized instances (see Author list in the table above).
5. Build the screen (frontend)
Section titled “5. Build the screen (frontend)”The screen below is the shared React flow from the compiled C# Capstone sample: run a generated command in a dialog, read a generated query, mount a route. Use the same Components with every backend, adjusting the generated proxy imports:
- C#: The snippets below import proxies co-located in
Authors/; the id in the command proxy is aGuid. - Kotlin and Java: Import
RegisterAuthor.tsandAllAuthors.tsunderbuild/generated/arc-proxies/via your scaffold’sApi/alias; the id in the command proxy is aGuid, as in C#.AllAuthorsis observable, so.use()re-renders when the new author is projected. It can take a moment to appear, because Chronicle projects asynchronously. - TypeScript: Import
RegisterAuthorfromFeatures/Authors/Registration/RegisterAuthor.proxy.tsandAllAuthorsfromFeatures/Authors/Listing/AllAuthors.proxy.ts; keepGuid.create()for its id. Its RegisterAuthorForm seeds the id before validation withinitialValues.
-
A dialog that executes the command.
AddAuthor.tsxusesCommandDialogto instantiate and run the generated command and render the OK/Cancel footer — supply the fields:import { CommandDialog } from '@cratis/components/CommandDialog';import { InputTextField } from '@cratis/components/CommandForm';import { RegisterAuthor } from './RegisterAuthor';import { Guid } from '@cratis/fundamentals';export const AddAuthor = () => (<CommandDialog<RegisterAuthor>command={RegisterAuthor}title="Add author"okLabel="Add"initialValues={{ id: Guid.create() }}><InputTextField<RegisterAuthor> value={i => i.name} title="Name" /></CommandDialog>); -
A screen that reads the query. In
Authors.tsx, the generatedAllAuthorsproxy is observable:.use()re-renders when the projection changes. Kotlin and Java observe a page of materialized instances (see the table). The button opens the dialog above:import { useDialog } from '@cratis/arc.react/dialogs';import { AllAuthors } from './Author';import { AddAuthor } from './AddAuthor';export const Authors = () => {const [AddAuthorDialog, showAddAuthor] = useDialog(AddAuthor);const [authors] = AllAuthors.use();return (<main><h1>Authors</h1><button type="button" onClick={() => { void showAddAuthor(); }}>Add author</button><ul>{authors.data.map(a => <li key={String(a.id)}>{a.name}</li>)}</ul><AddAuthorDialog /></main>);}; -
Mount the screen. In the scaffold’s
App.tsx, importAuthorsfrom./Authors/Authorsand add this route inside the existing<Routes>(alongside/and/demo):<Route path='/authors' element={<Authors />} />Open
/authorsin the browser to see the list and the Add author button.
Go deeper
Section titled “Go deeper”- C# backend: Commands and Queries in Arc, and the Cratis package that
AddCratisconfigures. - Kotlin and Java backend: the Chronicle guide for Arc.Kotlin.
- TypeScript backend: the Arc TypeScript Library sample.
- Every backend: Projections in Chronicle.
- Frontend: CommandDialog and data tables in Components.
- The reasoning: Why developers choose Cratis and Why Arc.