Skip to content

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.

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.

Arc.Core — commands · queries · generated proxies

MongoDB

EF Core / SQL

Chronicle — event sourcing

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.

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();
}

View C# snippet source on GitHub

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));
}

View C# snippet source on GitHub

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 });
}

View C# snippet source on GitHub

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();

View C# snippet source on GitHub

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>

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 doesinserts a documentappends an event
What fills the read modelthe command, directlya projection over the event
What you can readcurrent statecurrent 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.

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.