Add a projection
A projection receives journal events in order and updates data designed for queries. It also records an offset identifying the last event it committed. This page's single most important rule: commit the read-model update and the new offset in the same database transaction.
For a handler that receives a library-owned transaction and can wait for a journal snapshot, follow Catch up projections. The offset-based registration on this page remains available when the application manages its own progress and transaction.
The rule matters because a crash can land between any two separate writes, and each ordering fails differently:
- Offset first, then data. A crash in between advances the bookmark past an event that never reached the read model. On restart the projection resumes after it. Nothing errors; the view is simply missing that update and stays wrong until the read model is rebuilt.
- Data first, then offset. A crash in between leaves the bookmark behind, so the event is applied again on restart. An insert-or-replace absorbs the repeat; a counter or an append does not, and the view drifts.
- Same transaction. The crash commits both or neither. Retrying the uncommitted event gives exactly-once updates within that store.
Motivation: Storing data and offset together removes ambiguity after a restart. The projection either committed the event and moves past it, or committed neither and can safely try it again.
Create the read model and one offset row for this projection:
create table if not exists Documents (
Id text primary key,
Title text not null,
Body text not null
);
create table if not exists Offsets (
OffsetName text primary key,
OffsetCount integer not null
);
insert or ignore into Offsets (OffsetName, OffsetCount)
values ('DocumentProjection', 0);
Handle every event transactionally
The handler receives the journal offset and an obj because the stream contains events from every
aggregate and saga. Match the envelope types this projection needs. Advance the offset for every event,
including event types that do not change this read model.
Shared setup
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
open FCQRS.Model.Data
module Document =
type Document = { Id: Guid; Title: ShortString; Content: LongString }
type Command = CreateOrUpdate of Document
type Event = Updated of Document | Rejected of string
open Document
SystemFCQRSModelFCQRS.Model.DataFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
Fcqrs_450-how-to_004-add-a-projection.md_page.DocumentFcqrs_450-how-to_004-add-a-projection.md_page.Document.DocumentId: GuidSystem.GuidRepresents a globally unique identifier (GUID).
Title: ShortStringFCQRS.Model.Data.ShortStringValidated non-blank string up to 255 chars inclusive.
Content: LongStringFCQRS.Model.Data.LongStringRepresents any string at least 1 chars
Fcqrs_450-how-to_004-add-a-projection.md_page.Document.CommandCreateOrUpdateFcqrs_450-how-to_004-add-a-projection.md_page.Document.EventUpdatedRejectedstringAn abbreviation for the CLI type . Basic Types
// NuGet: Dapper, Microsoft.Data.Sqlite
open Dapper
open Microsoft.Data.Sqlite
open FCQRS.Common
open FCQRS.FSharp
let handle (connString: string) (offset: int64) (event: obj) : unit =
use conn = new SqliteConnection(connString)
conn.Open()
use tx = conn.BeginTransaction()
match event with
| :? Event<Document.Event> as e ->
match e.EventDetails with
| Document.Updated doc ->
conn.Execute(
"insert or replace into Documents (Id, Title, Body) values (@Id, @Title, @Body)",
{| Id = doc.Id.ToString(); Title = doc.Title.ToString(); Body = doc.Content.ToString() |}, tx)
|> ignore
| _ -> ()
| _ -> ()
// Advance for every event, in the same transaction as the read-model write.
conn.Execute(
"update Offsets set OffsetCount = @n where OffsetName = 'DocumentProjection'",
{| n = offset |}, tx)
|> ignore
tx.Commit()
DapperMicrosoftDataSqliteFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
handle: string -> int64 -> obj -> unitconnString: stringstringAn abbreviation for the CLI type . Basic Types
offset: int64int64An abbreviation for the CLI type . Basic Types
event: objobjAn abbreviation for the CLI type . Basic Types
unitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
conn: SqliteConnectionMicrosoft.Data.Sqlite.SqliteConnectionRepresents a connection to a SQLite database. Connection Strings Async Limitations
Open: unit -> unitOpens a connection to the database using the value of . If Mode=ReadWriteCreate is used (the default) the file is created, if it doesn't already exist. A SQLite error occurs while opening the connection.
tx: SqliteTransactionBeginTransaction: unit -> SqliteTransactionBegins a transaction on the connection. The transaction. A SQLite error occurs during execution. Transactions Database Errors
FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
Fcqrs_450-how-to_004-add-a-projection.md_page.DocumentFcqrs_450-how-to_004-add-a-projection.md_page.Document.Evente: Event<Event>EventDetails: 'EventDetailsThe specific details or payload of the event.
Updateddoc: DocumentExecute: string * obj * Data.IDbTransaction * Nullable<int> * Nullable<Data.CommandType> -> intExecute parameterized SQL. The connection to query on. The SQL to execute for this query. The parameters to use for this query. The transaction to use for this query. Number of seconds before command execution timeout. Is it a stored proc or a batch? The number of rows affected.
Id: stringToString: unit -> stringReturns a string representation of the value of this instance in registry format. The value of this , formatted by using the "D" format specifier as follows: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx where the value of the GUID is represented as a series of lowercase hexadecimal digits in groups of 8, 4, 4, 4, and 12 digits and separated by hyphens. An example of a return value is "382c74c3-721d-4f34-80e5-57657b6cbc27". To convert the hexadecimal digits from a through f to uppercase, call the method on the returned string.
Id: GuidTitle: stringToString: unit -> stringTitle: ShortStringBody: stringContent: LongString(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
ignore: 'T -> unitIgnore the passed value. This is often used to throw away results of a computation. The value to ignore. ignore 55555 // Evaluates to ()
n: int64Commit: unit -> unitApplies the changes made in the transaction.
// C#: the same projection as a void method.
using static FCQRS.Common; // Event<>
using Dapper;
using Microsoft.Data.Sqlite;
public static void HandleEventWrapper(string connString, long offset, object eventObj)
{
using var conn = new SqliteConnection(connString);
conn.Open();
using var tx = conn.BeginTransaction();
if (eventObj is Event<DocumentEvent> { EventDetails: DocumentEvent.Updated u })
conn.Execute(
"insert or replace into Documents (Id, Title, Body) values (@Id, @Title, @Body)",
new { Id = u.Document.Id.ToString(), Title = u.Document.Title.ToString(), Body = u.Document.Content.ToString() }, tx);
// Advance for every event, in the same transaction as the read-model write.
conn.Execute(
"update Offsets set OffsetCount = @n where OffsetName = 'DocumentProjection'",
new { n = offset }, tx);
tx.Commit();
}
Resume from the stored offset
Read DocumentProjection from Offsets during startup and pass that value to the projection:
Shared setup
let register (api: IActor) (connString: string) =
register: IActor -> string -> FCQRS.Query.ISubscribeapi: IActorFCQRS.Common.IActorDefines the core functionalities and context provided by the FCQRS environment to actors. This interface provides access to essential Akka.NET services and FCQRS initialization methods.
connString: stringstringAn abbreviation for the CLI type . Basic Types
let getLastOffset (connString: string) : int64 =
use conn = new SqliteConnection(connString)
conn.Open()
conn.ExecuteScalar<int64>(
"select OffsetCount from Offsets where OffsetName = 'DocumentProjection'")
let subscriptions =
Fcqrs.projection api
(Projection.single (getLastOffset connString) (handle connString))
getLastOffset: string -> int64connString: stringstringAn abbreviation for the CLI type . Basic Types
int64An abbreviation for the CLI type . Basic Types
conn: SqliteConnectionMicrosoft.Data.Sqlite.SqliteConnectionRepresents a connection to a SQLite database. Connection Strings Async Limitations
Open: unit -> unitOpens a connection to the database using the value of . If Mode=ReadWriteCreate is used (the default) the file is created, if it doesn't already exist. A SQLite error occurs while opening the connection.
ExecuteScalar: string * obj * Data.IDbTransaction * Nullable<int> * Nullable<Data.CommandType> -> 'TExecute parameterized SQL that selects a single value. The type to return. The connection to execute on. The SQL to execute. The parameters to use for this command. The transaction to use for this command. Number of seconds before command execution timeout. Is it a stored proc or a batch? The first cell returned, as .
subscriptions: FCQRS.Query.ISubscribeFCQRS.FSharp.Fcqrsprojection: IActor -> Projection -> FCQRS.Query.ISubscribeRegister the read-model projection and return the subscription stream.
api: IActorFCQRS.FSharp.ProjectionModuleConstructors for the two projection-handler shapes.
single: int64 -> (int64 -> obj -> unit) -> ProjectionSingle-event handler: just update the read model (returns unit); each aggregate event is then published to subscribers as-is. The common case when every event is worth notifying.
handle: string -> int64 -> obj -> unitShared setup
subscriptions
subscriptions: FCQRS.Query.ISubscribe// C#: resolve both the handler and last offset from application services.
static long GetLastOffset(string connString)
{
using var conn = new SqliteConnection(connString);
conn.Open();
return conn.ExecuteScalar<long>(
"select OffsetCount from Offsets where OffsetName = 'DocumentProjection'");
}
services.AddProjection(
handler: sp => (offset, evt) => HandleEventWrapper(connString, offset, evt),
lastOffset: _ => GetLastOffset(connString));
Fcqrs.projection returns an ISubscribe. A client can subscribe to a correlation id and wait until
this handler commits the matching event. Aggregate .Send waits only for the aggregate reply; the
projection subscription is the separate read-side confirmation.
The C# host builder supports one projection per FCQRS runtime: a second AddProjection call throws
InvalidOperationException at registration. A handler may update several read models in the same
process, and a side-by-side rebuild runs as a separate process (see
Rebuild a read model). Independently deployed projection consumers should
keep independent offsets.
Choose which events notify callers
| F# helper | C# handler result | Subscription behaviour |
|---|---|---|
Projection.single |
void |
publish every aggregate event after handling |
Projection.filtered |
Notify |
publish or suppress the handled aggregate event |
Projection.multi |
IMessageWithCID list |
publish the exact notification list returned |
Use filtering when one command produces several events but a caller should wake only after the event that completes all required read-model updates. The notification must be published only after those updates commit.
Handle failures visibly
Do not catch a storage exception and advance the offset. Let the handler fail. For the offset-based registration on this page, FCQRS terminates the process when a handler fails so the stream cannot stop silently while the host appears healthy. The process supervisor can restart it from the last committed offset after the storage problem or handler bug is corrected.
A projection writing to several stores cannot use one local transaction for all updates. Make each destination idempotent and record enough progress to retry safely.
To correct derived data, follow Rebuild a read model. Do not edit the event journal to repair a projection. Background: The read side.