CQRS in C# and .NET with Arc and Chronicle
CQRS (Command Query Responsibility Segregation) means commands change state and queries read it, each with its own model. In C# and .NET, Cratis Arc is an open-source (MIT) CQRS application framework for ASP.NET Core: a command is a record with a Handle() method, a query is a static method on a read model, and Arc generates the HTTP endpoints and typed TypeScript proxies from them. Cratis Chronicle is optional; add it when you also want the write side stored as events.
This page is for .NET developers who search for a CQRS framework that is not a mediator library. If you are still deciding whether CQRS fits, read CQRS explained first.
What you get in C#
Section titled “What you get in C#”| Capability | How it looks in Arc | Maturity |
|---|---|---|
| Commands | [Command] record with Handle(); endpoint generated | Released |
| Queries and read models | [ReadModel] record with static query methods | Released |
| Live queries | A query returning ISubject<T> streams changes to the client | Released |
| Validation | Data annotations or FluentValidation CommandValidator<T>, run before Handle() | Released |
| Authorization | [Roles] and [Authorize] on commands and query methods | Released |
| Typed frontend | TypeScript proxies generated from the same types | Released |
| Event sourcing | Chronicle: Handle() returns an event; a projection fills the read model | Released, optional |
“Released” means the packages are published. The capability matrix shows each row per language, checked against source and tests. Arc runs on ASP.NET Core and on a lighter standalone Arc.Core host; the integrations for MongoDB and Entity Framework Core are separate packages.
A command and a query
Section titled “A command and a query”A library application registers authors and lists them. The command is a record. Its parameters are its input and its logic lives in Handle(). This version writes a document to MongoDB and needs no Chronicle:
using Cratis.Arc.Commands.ModelBound;using MongoDB.Driver;
[Command]public record RegisterAuthor(Guid Id, string Name){ public Task Handle(IMongoCollection<Author> authors) => authors.InsertOneAsync(new Author(Id, Name));}The query side is a read model with a static method. Arc resolves IMongoCollection<Author> from the container, serves the method over HTTP, and generates a proxy for the frontend:
using Cratis.Arc.Queries.ModelBound;using MongoDB.Driver;
[ReadModel]public record Author(Guid Id, string Name){ public static IEnumerable<Author> AllAuthors(IMongoCollection<Author> authors) => authors.Find(_ => true).ToList();}These are excerpts. A runnable host also needs the setup from Arc’s .NET getting started guide. Neither type has a controller or a route attribute. The command never returns the author list and the query never changes anything, so you can test, secure and change each on its own.
Validation, authorization and live queries
Section titled “Validation, authorization and live queries”Validation and authorization sit at the boundary, so Handle() stays about behavior. Annotate the record’s properties and Arc rejects bad input before Handle() runs. This example is from the validation guide:
[Command]public record AllocateLabeledIdentifier( [property: Required] string Label, [property: Range(1, 100)] int Copies){ public Guid Handle() => Guid.NewGuid();}Put [Roles(...)] on a command or on a query method and Arc checks the caller’s roles first. Authorization applies when the call goes through Arc’s pipelines; calling Handle() directly from C# bypasses it. See authorizing commands and queries.
For a live read model, return ISubject<T>. The client receives the current result and then each change, and the generated React proxy exposes it as a hook:
[ReadModel]public record Author([property: Key] AuthorId Id, AuthorName Name){ [Roles(nameof(UserRole.Librarian))] public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) => collection.Observe();}const [authors] = AllAuthors.use();Add event sourcing with Chronicle
Section titled “Add event sourcing with Chronicle”CQRS does not need an event log. When you want one, the command changes and the query does not. Handle() returns the fact, and Arc appends it to Chronicle:
using Cratis.Arc.Commands.ModelBound;using Cratis.Chronicle.Events;
[Command]public record RegisterAuthor(EventSourceId Id, string Name){ public AuthorRegistered Handle() => new(Name);}
[EventType]public record AuthorRegistered(string Name);A projection then fills the read model. With [FromEvent<AuthorRegistered>], Chronicle maps the event’s Name onto the read model by convention, and the query keeps its signature:
[ReadModel][FromEvent<AuthorRegistered>]public record Author(AuthorId Id, AuthorName Name){ public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) => collection.Observe();}The generated proxies and React screens do not change. Moving an existing slice needs more than these type changes: a running Chronicle server, a sink that matches the collection the query reads, and a plan for existing rows. The add event sourcing guide covers each. In an application that uses both products, the Cratis package wires Arc and Chronicle with AddCratis and UseCratis.
Chronicle also provides reducers, reactors and projections for the read side. Projections and read models explains the choice between them. For the storage side, see event sourcing in .NET.
When CQRS in .NET is worth it
Section titled “When CQRS in .NET is worth it”Use it when reads and writes have different shapes, load or rules; when the frontend needs typed, live data; or when you expect to add event sourcing later and want the contract between commands and queries to stay stable.
It is not worth it for a single form over a single table, where plain CRUD is cheaper. It also adds types: a command, a read model and often a validator per behavior. Reads that follow a write can lag when the read model is built asynchronously, as it is with Chronicle projections, so design the screen for that. CQRS explained lists more trade-offs.
Arc is not a mediator library. It owns hosting, endpoints and proxies as well as dispatch. If you only want in-process command handlers behind your own controllers, a smaller library fits better. Coming from MediatR and MVC maps the two.
Frequently asked questions
Section titled “Frequently asked questions”What is the best way to do CQRS in C#?
Section titled “What is the best way to do CQRS in C#?”It depends on how much you want a framework to own. You can write handlers by hand or use a mediator library. Arc is the option where commands, queries, validation, authorization and frontend proxies share one model. Try its getting started guide and compare against what you have.
Do I need MediatR for CQRS in .NET?
Section titled “Do I need MediatR for CQRS in .NET?”No. CQRS is a design choice, not a library. Arc dispatches commands and queries itself and exposes them over generated HTTP endpoints.
Can I use CQRS without event sourcing in .NET?
Section titled “Can I use CQRS without event sourcing in .NET?”Yes. Arc works with MongoDB or Entity Framework Core and has no Chronicle dependency. See CQRS without event sourcing.
How does the frontend call commands and queries?
Section titled “How does the frontend call commands and queries?”Arc generates typed TypeScript proxies from your C# commands and queries after the build. React components call them as typed methods and hooks. See understanding the proxy boundary.
Does Arc work with languages other than C#?
Section titled “Does Arc work with languages other than C#?”Yes. See CQRS in Kotlin and Java and CQRS in TypeScript and Node.js. They do not have full parity with .NET.