CQRS in practice
CQRS gives commands and queries different jobs. A command asks the write side to decide a change. A query asks for information in a shape that is useful to a reader. The two can share a database, or use different stores, while still having different models.
On this page
In Troolio, an event-sourced actor is a natural command boundary. A query to that actor reads its current reconstructed state. A projection can also produce a SQL view that answers cross-actor questions without loading many actors.
Start from the question
A workshop application has several readers:
| Question | Suitable model |
|---|---|
| What is the status of this one registration? | A registration actor query or an exact registration card read. |
| Show my registrations for this workshop | An indexed SQL view filtered by current user and workshop. |
| How many places can still be allocated safely? | The authoritative write-side capacity decision. |
| Show activity across all workshops | A separate reporting projection with paging and access control. |
The same event can update more than one view. There is no need to expose a complete actor snapshot when the page only needs a name, status, and workshop ID.
Commands communicate intent
The sample makes registration and cancellation explicit commands. It records accepted outcomes as past-tense events.
public interface IRegistrationActor : IActor { }
[GenerateSerializer]
public record RequestRegistration(Metadata Headers, Guid RegistrationId,
Guid WorkshopId, string DisplayName) : Command<IRegistrationActor>(Headers);
[GenerateSerializer]
public record CancelRegistration(Metadata Headers) : Command<IRegistrationActor>(Headers);
[GenerateSerializer]
public record GetRegistration : Query<IRegistrationActor, RegistrationState>;
[GenerateSerializer]
public record RegistrationRequested(Metadata Headers, Guid RegistrationId,
Guid WorkshopId, Guid AttendeeId, string DisplayName) : Event(Headers);
[GenerateSerializer]
public record RegistrationCancelled(Metadata Headers, Guid RegistrationId,
Guid WorkshopId, Guid AttendeeId, string DisplayName) : Event(Headers);
A command can fail because its inputs or current state are invalid. A query should not create a registration as a side effect. Make the API outcome clear enough that the client knows whether a request was rejected, accepted for processing, or committed under the selected protocol.
Read models communicate useful facts
A registration card is a deliberately small result:
public sealed record RegistrationCard(Guid RegistrationId, Guid WorkshopId,
Guid AttendeeId, string DisplayName, string Status);
The SQL reader filters by the current authenticated user in the query itself. It does not load every card and filter the result in memory. For a list, choose an index and stable ordering that match the question, and return a bounded page with a continuation cursor.
Microsoft's CQRS guidance describes the distinction between read and write models and the consistency consequences of separately updated stores. Use this pattern where the different responsibilities are useful; the actor and provider contracts determine your application's actual guarantees.
Handle the gap between writes and reads
A command may commit before the SQL card changes. Tell callers what that means. A UI can display the accepted command result, show a short updating state, then refresh the view when its projection is current. A stale view should not be silently used as authority for a conflicting command.
A returned source version can help a client track the update of one source stream. It is not a total ordering across unrelated actors. If a page depends on several streams, define a version vector, operation receipt, or other freshness contract appropriate to that page.
Avoid expensive read paths
A cross-actor catalogue should not reconstruct every actor on each API request. A growing history can turn a once-small replay query into a slow endpoint. Promote that view into an application-owned durable projection, index it, and run any catch-up as bounded work.
An exact read of one small linked-event model can still be useful. Read-model design explains the current Troolio resolver and why it is a different serving choice from a SQL catalogue.
Continue with projection design, realtime updates, and actor boundaries.