Documentation / Foundations

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.