CQRS without event sourcing
It’s easy to assume Arc and Chronicle are a package deal, because they work well together. We think event sourcing is the default architecture for information systems, and Chronicle is the Cratis event-sourcing platform. But Arc itself is CQRS: commands, queries, and generated TypeScript proxies that keep your React frontend in lockstep with your backend. Where the data actually lives is a separate decision.
This page shows Arc on its own — the same typed full-stack experience, backed by a plain database instead of an event log — so the line between CQRS and event sourcing is explicit. CQRS and event sourcing fit naturally together, but neither depends on the other.
The line between Arc and Chronicle
Section titled “The line between Arc and Chronicle”Arc is a layer that can sit on top of Chronicle; Chronicle never depends on Arc. That direction is the whole point — it’s why a bounded current-state slice can keep everything Arc gives you without storing events. Each backend has its own current-state integrations: MongoDB and Entity Framework on C#, Spring Data on JVM, and MongoDB or Drizzle in the TypeScript preview. Chronicle adds the optional event-sourced path.
Pick a supported current-state store for your backend and you can build a typed CQRS app without an event log. Pick Chronicle and the same Arc boundary records facts, builds projections, and keeps history.
A standalone slice, end to end
Section titled “A standalone slice, end to end”These illustrative excerpts show registering an author and listing authors live in MongoDB. For a runnable project, use standalone .NET setup and its backend checkpoint, Kotlin setup, Java setup, or TypeScript setup for domain declarations, host, database configuration, and replica-set bootstrap. The explanation here does not replace those prerequisites.
The read model is just a document. Declare a read model and Arc exposes its query methods. The model-bound query returns a live stream:
[ReadModel]public record Author(AuthorId Id, AuthorName Name){ // This static method is the query — served over HTTP, and live. public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> authors) => authors.Observe();}import org.springframework.data.mongodb.core.mapping.Document;
@Document@ReadModel@AllowAnonymouspublic record Author(AuthorId id, AuthorName name) { public static Flow.Publisher<List<Author>> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); }}import org.springframework.data.mongodb.core.mapping.Document
@Document@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 { query, readModel, service, type ObservableSource } from '@cratis/arc.core';
@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 command writes the document directly. Inject the database dependency and save. This example chooses no response payload; no event is involved:
[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 }); }}A command can take the read model it acts on. Mark the key on the command and configure the read model’s database lookup, so the command decides against current state instead of fetching it first. On the JVM MongoDB branch, Author must be a Spring Data-mapped @Document as well as an Arc @ReadModel; otherwise the MongoDB resolver cannot claim it from the mapping context at startup:
using System.ComponentModel.DataAnnotations;
[Command]public record RenameAuthor([property: Key] AuthorId Id, AuthorName NewName){ public Task Handle(Author author, IMongoCollection<Author> authors) => authors.ReplaceOneAsync(_ => _.Id == author.Id, author with { Name = NewName });}import io.cratis.arc.artifacts.CommandKey;
@Command@AllowAnonymouspublic record RenameAuthor(@CommandKey AuthorId id, AuthorName newName) { public void handle(Author author, @FromServices AuthorRepository authors) { authors.save(new Author(author.id(), newName)); }}import io.cratis.arc.artifacts.CommandKey
@Command@AllowAnonymousdata class RenameAuthor(@CommandKey val id: AuthorId, val newName: AuthorName) { suspend fun handle(author: Author, @FromServices authors: AuthorRepository) { authors.save(author.copy(name = newName)) }}import { field } from '@cratis/fundamentals';import { command, commandReadModel, inject, key } from '@cratis/arc.core';
@command()export class RenameAuthor { @field(AuthorId) @key() id!: AuthorId; @field(AuthorName) newName!: AuthorName;
@inject(commandReadModel(Author), AuthorRepository) async handle(author: Author, authors: AuthorRepository): Promise<void> { author.name = this.newName; await authors.save(author); }}// Configure Author as a MongoDB-owned read model; Arc will look it up by the declared key.On .NET this works both for a MongoDB read model and for one carried by an Entity Framework ReadOnlyDbContext. On TypeScript, configure a ReadModelForCommandResolver for the keyed model (the MongoDB integration registers one for configured readModels). Read models in commands covers what a nullable parameter means, and Read models from other providers how to declare the key when it is not a single property.
That’s the backend behavior. Start the host with the database configured as in the backend-specific setup guides. The .NET excerpt assumes Cratis:MongoDB; Arc auto-discovers collections and serializers rather than requiring per-model registrations. For the TypeScript excerpt, place the AuthorRepository abstract class shown below in Features/Authors/AuthorRepository.ts and import it into the command, query, validators, and host; it is the runtime token used by @inject and service, not a type-only interface. Import your Author, AuthorId, AuthorName, RegisterAuthor, and RenameAuthor declarations from their feature files. Keep the TypeScript setup guide’s generate.mjs on --project ./tsconfig.json and --artifacts ./Features; import its generated metadata and discover the same Features/ root in the host. Regenerate after chapter 2 adds validators, or the host will not register them. Install @cratis/arc.mongodb, set MONGODB_URI (or use the loopback default), and use a replica set for live MongoDB change streams. The scoped MongoAuthorRepository supplies the persistence methods used in the preceding excerpts:
var builder = WebApplication.CreateBuilder(args);builder.AddCratisArc();builder.UseCratisMongoDB();
var app = builder.Build();app.UseCratisArc();app.Run();import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic class LibraryApplication { public static void main(String[] args) { SpringApplication.run(LibraryApplication.class, args); }}import org.springframework.boot.autoconfigure.SpringBootApplicationimport org.springframework.boot.runApplication
@SpringBootApplicationclass LibraryApplication
fun main(args: Array<String>) { runApplication<LibraryApplication>(*args)}import { ArcApplication, type ObservableSource } from '@cratis/arc.core';import { mongoCollection, type MongoCollection } from '@cratis/arc.mongodb';import type { Document, Filter } from 'mongodb';import { metadata } from './Features/generatedMetadata.js';
// Features/Authors/AuthorRepository.ts — import Author and its concepts here.// Import this runtime token into main.ts, commands, queries, and validators.export abstract class AuthorRepository { abstract save(author: Author): Promise<void>; abstract all(): Promise<Author[]>; abstract observeAll(): ObservableSource<Author[]>; abstract findById(id: AuthorId, signal?: AbortSignal): Promise<Author | undefined>; abstract existsByName(name: AuthorName, signal?: AbortSignal): Promise<boolean>;}
// main.ts — import AuthorRepository from ./Features/Authors/AuthorRepository.js.// Also import Author, AuthorId, AuthorName, RegisterAuthor, and RenameAuthor from their feature files.class MongoAuthorRepository extends AuthorRepository { constructor(private readonly collection: MongoCollection<Author>) { super(); }
async save(author: Author): Promise<void> { const document = this.collection.codec.serialize(author); await this.collection.native.replaceOne({ _id: document._id } as Filter<Document>, document, { upsert: true }); } all(): Promise<Author[]> { return this.collection.find(); } observeAll(): ObservableSource<Author[]> { return this.collection.observe(); } async findById(id: AuthorId): Promise<Author | undefined> { return await this.collection.findById(id) ?? undefined; } async existsByName(name: AuthorName): Promise<boolean> { return (await this.collection.find({ [this.collection.codec.fieldName('name')]: name.value })).length > 0; }}
export async function start(): Promise<void> { const builder = ArcApplication.createBuilder({ tenancy: { resolve: () => 'default' } }); builder.useGeneratedMetadata(metadata); builder.withMongoDB({ server: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017', database: 'Library', readModels: [Author] }); builder.services.addScoped(AuthorRepository, async scope => new MongoAuthorRepository(await scope.resolve(mongoCollection(Author)))); // Use the same Features root as arc-proxygenerator's --artifacts argument. await builder.discover(new URL('./Features/', import.meta.url)); const app = await builder.build(); await app.run({ port: 3000 });}
await start();On .NET, install Cratis.Arc.ProxyGenerator.Build, configure CratisProxiesOutputPath, and build in Debug as shown in setup to generate RegisterAuthor and AllAuthors. Building with Arc alone does not generate them. On the JVM, run generateArcProxies; on TypeScript, run your application’s source-based generator script and regenerate metadata after changing artifacts. This React fragment assumes the frontend setup imports/providers and the per-operation id state (Guid.create() from @cratis/fundamentals), with a fresh dialog instance for each registration:
const [authors] = AllAuthors.use(); // live — re-renders when the collection changes
<CommandDialog<RegisterAuthor> command={RegisterAuthor} title="Add author" initialValues={{ id }}> <InputTextField<RegisterAuthor> value={i => i.name} title="Name" /></CommandDialog>Test the slice without Chronicle
Section titled “Test the slice without Chronicle”On C#, Arc’s command testing does not depend on Chronicle either. Start with Cratis.Arc.Testing, drive the
command through a CommandScenario<TCommand>, and assert the CommandResult exactly as you would in an
event-sourced slice. The only difference is what you assert after the command runs: a current-state slice
checks the database or application service it wrote to, while a Chronicle-backed slice can also assert
the appended events.
That keeps the CQRS boundary testable before you decide whether the slice needs an event log. See Arc testing and command scenarios for the base testing loop; add the Chronicle testing extension only when the command appends events.
What actually changes when you add Chronicle
Section titled “What actually changes when you add Chronicle”For C#, set this slice next to the same slice with Chronicle added later. The TypeScript Chronicle integration is experimental; do not assume the same migration procedure applies. You can preserve the query and frontend contracts, while replacing direct writes with events and configuring a projection. The provider lookup/observation implementation may need to change; adding a package does not migrate existing data automatically:
| Standalone (this page) | With Chronicle | |
|---|---|---|
| What the handler does | inserts a document | appends an event |
| What fills the read model | the command, directly | a projection over the event |
| What you can read | current state | current state and full history |
Adopting Chronicle changes persistence and adds projection/bootstrap work. If you preserve the query’s contract and route, its generated caller and screen can remain unchanged.
The trade-off
Section titled “The trade-off”Storing current state directly is simpler for bounded CRUD surfaces, reference data, settings, and adoption steps. What you don’t get is everything an event log buys you: an audit trail, the ability to rebuild a read model a brand-new way from history, temporal queries, and reactors that fire on facts. For information systems, we prefer starting with Chronicle because those needs show up often. Why Event Sourcing explains the default.
The reassuring part, from the table above: the boundary stays clean. If a direct-database slice later belongs in the event-sourced model, move persistence to Chronicle and deliberately preserve the public read contracts so the frontend need not change. Adopting Cratis walks through doing exactly that, one step at a time.
Go deeper
Section titled “Go deeper”- MongoDB integration — setup, serializers, class mapping, and observing collections for live queries.
- Entity Framework integration — DbContexts, read-only contexts, and observing DbSets.
- Commands and Queries — the full model-bound and controller-based reference.
- Why Arc — the problem Arc solves, and how CQRS relates to event sourcing.