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 the generated C# → 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. In the backend docs, Chronicle, MongoDB, and Entity Framework are integrations: each gives commands and queries somewhere to read and write, while Chronicle adds the event-sourced backbone.

Arc.Core

commands · queries · generated proxies

MongoDB

EF Core / SQL

Chronicle

event sourcing

Pick MongoDB or EF Core and you have a complete, fully-typed CQRS app without an event log. Pick Chronicle and the same Arc boundary records facts, builds projections, and keeps history.

Here’s the whole thing — register an author, and list authors live — with the data stored straight in a MongoDB collection.

The read model is just a document. Mark it [ReadModel] so Arc exposes its query methods; there’s no [FromEvent] and no projection. A static method is the query, and returning an ISubject<> makes it live:

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

The command writes the document directly. Inject the collection and insert — Handle() returns nothing, because there’s no event to record:

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

That’s the backend. Register MongoDB once at startup:

var builder = WebApplication.CreateBuilder(args);
builder.AddCratisArc();
builder.UseCratisMongoDB();
var app = builder.Build();
app.UseCratisArc();
app.Run();

Build, and Arc generates the typed proxies for RegisterAuthor and AllAuthors exactly as it would for an event-sourced slice. The React side is unchanged from any other Arc app:

const [authors] = AllAuthors.use(); // live — re-renders when the collection changes
<CommandDialog command={RegisterAuthor} title="Add author">
<InputTextField value={i => i.name} title="Name" />
</CommandDialog>

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”

Set this slice next to the same slice with Chronicle added later. The query and the React are identical. The only thing that differs is the command’s write path:

Standalone (this page)With Chronicle
What Handle() 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

So adopting Chronicle later is a write-side change. Your queries, your generated proxies, and your screens don’t move.

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 the write side to Chronicle — the read side and the entire frontend come along unchanged. Adopting Cratis walks through doing exactly that, one step at a time.