Design read models
A read model presents information for a particular reader. It can combine facts from several actors, omit write-side details, and organize fields for a UI or report. It is derived from accepted history, while the write-side decision remains with the responsible actor or commit policy.
On this page
The term covers several implementations. Troolio supplies TroolioReadModel and a resolver that folds an event stream. A larger application can also maintain semantic SQL tables through durable projections. Choose deliberately: the cost, freshness, and access boundary differ.
A pure immutable model
The example's RegistrationDetails has a Guid constructor, fields for a registration detail view, and On methods that return a new record.
public sealed record RegistrationDetails(Guid Id) : TroolioReadModel
{
public Guid OwnerId { get; init; }
public Guid WorkshopId { get; init; }
public string DisplayName { get; init; } = "";
public string Status { get; init; } = "absent";
public RegistrationDetails On(EventEnvelope<RegistrationRequested> envelope) =>
this with { OwnerId = envelope.Event.AttendeeId, WorkshopId = envelope.Event.WorkshopId,
DisplayName = envelope.Event.DisplayName, Status = "requested" };
public RegistrationDetails On(EventEnvelope<RegistrationCancelled> _) =>
this with { Status = "cancelled" };
public override Func<Metadata, bool> Authorized =>
headers => OwnerId != Guid.Empty && headers.UserId == OwnerId;
}
The with expression preserves the previous record and changes the relevant fields. It does not perform a database read or call another actor. That makes the calculation useful for deterministic event folding and for testing historical sequences.
A record does not make everything deeply immutable. Arrays, lists, and mutable objects inside a record can still change. Use immutable collections or copy mutable input before retaining it as model state.
How the Troolio resolver works
ITroolioClient.Get<TReadModel>(id) asks ReadModelResolver<TReadModel> to construct the model using the parsed GUID ID and apply the events in its read-model stream. ReadModelProjectionActor<T> can map source envelopes to a model key and record links; the chosen IStore must resolve those link records correctly.
This is useful for a small exact model with a known event history. It is not automatically an indexed SQL catalogue, and fetching one model can fold its linked history again. Monitor the cost of that read as history grows.
The resolver's local folding envelope versions should not be mistaken for the authoritative source revision of a multi-stream projection. If the UI needs an explicit freshness contract, persist the relevant source positions or operation receipt in the application read model.
Map a source into the linked model
The optional linker selects the registration ID as the destination model key:
[ProjectionStreamSubscription(typeof(RegistrationActor))]
public sealed class RegistrationDetailsLinks(IStore store)
: ReadModelProjectionActor<RegistrationDetails>(store)
{
public string Handle(EventEnvelope<RegistrationRequested> envelope) =>
envelope.Event.RegistrationId.ToString("D");
public string Handle(EventEnvelope<RegistrationCancelled> envelope) =>
envelope.Event.RegistrationId.ToString("D");
}
This linked fold assumes an ordered relevant history. The model's pure methods do not supply durable delivery deduplication or protect against out-of-order link appends by themselves. The host must provide that source contract. For the worked application's normal SQL reads, use RegistrationCardsProjection and its inbox writer instead.
Enforce authorization explicitly
TroolioReadModel.Authorized defaults to allowing access. An override expresses a model rule, but the host must invoke and enforce it with headers constructed from the current authenticated caller. The generic client resolver does not establish that security boundary for you.
public static async Task<RegistrationDetails> ReadDetailsAsync(ITroolioClient client,
Guid registrationId, Metadata verifiedCaller)
{
RegistrationDetails details = await client.Get<RegistrationDetails>(registrationId.ToString("D"));
if (!details.Authorized(verifiedCaller)) throw new UnauthorizedAccessException();
return details;
}
Do not run this check against user IDs copied from an untrusted request body. Do not cache an authorized result indefinitely after the user loses access. A staff reader and an attendee reader may need separate rules rather than one broad view.
SQL serving models
For lists, cross-actor catalogues, search, notifications, and reporting, a durable projection can maintain typed SQL rows. The sample's exact reader applies the attendee filter in the database:
public async Task<RegistrationCard?> GetForUserAsync(Guid registrationId, Guid authenticatedUserId,
CancellationToken cancellationToken)
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = """
SELECT RegistrationId, WorkshopId, AttendeeId, DisplayName, Status
FROM dbo.WorkshopRegistrationCard WHERE RegistrationId = @id AND AttendeeId = @user;
""";
command.Parameters.Add("@id", SqlDbType.UniqueIdentifier).Value = registrationId;
command.Parameters.Add("@user", SqlDbType.UniqueIdentifier).Value = authenticatedUserId;
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? new(reader.GetGuid(0), reader.GetGuid(1), reader.GetGuid(2), reader.GetString(3), reader.GetString(4))
: null;
}
A page reader should also have a fixed maximum size and stable continuation cursor. Fetching every record and filtering in memory makes access control, memory use, and latency harder to reason about. Treat missing, unavailable, and not-yet-current projection outcomes distinctly where the API needs that information.
Shape the model for the task
A registration detail page needs one card. A roster page needs a list and maybe a count. A reminder worker needs an indexed due-time query. A reporting page may need historical aggregates. Keep those shapes explicit rather than making every caller reconstruct and interpret the full actor state.
A stale read model can explain a recent state to a user, but a business invariant still needs current write-side checks. A projected attendee count alone cannot allocate the final available place safely.
Continue with CQRS in practice, projection design, and realtime updates. See the workshop sample for both the immutable model and the SQL reader.