Register a user
Register Alice, save the event in SQLite, and query her name. Each account can register once; later requests return the saved name. Jump to the runnable sample.
Define the messages
module Account =
open FCQRS.Common
open FCQRS.FSharp
type RegisterUser = RegisterUser of name: string
type UserRegistered = UserRegistered of name: string
Fcqrs_202-get-started.md_page.AccountFCQRSFCQRS.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_202-get-started.md_page.Account.RegisterUserRegisterUsername: stringstringAn abbreviation for the CLI type . Basic Types
Fcqrs_202-get-started.md_page.Account.UserRegisteredUserRegisteredusing static FCQRS.Common;
using static FCQRS.CSharp;
public sealed record RegisterUser(string Name);
public sealed record UserRegistered(string Name);
public sealed record AccountState(string? Name = null);
RegisterUser is a command: a request to register a name. UserRegistered is an event:
the recorded result. The account's state holds the registered name, starting with None in F#
or null in C#.
Decide what to save
Account.fs / Account.cs contains the rule. FCQRS passes the command and current state to
decide / HandleCommand:
let decide (command: Command<RegisterUser>) (state: string option) =
let (RegisterUser name) = command.CommandDetails
persistIf state.IsNone (UserRegistered(defaultArg state name))
let fold (event: Event<UserRegistered>) (_state: string option) =
let (UserRegistered name) = event.EventDetails
Some name
decide: Command<RegisterUser> -> string option -> EventAction<UserRegistered>command: Command<RegisterUser>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
Fcqrs_202-get-started.md_page.Account.RegisterUserstate: string optionstringAn abbreviation for the CLI type . Basic Types
optionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
RegisterUsername: stringCommandDetails: 'CommandDetailsThe specific details or payload of the command.
persistIf: bool -> 'e -> EventAction<'e>Persist the event when `shouldPersist`, else defer it (published and folded but not journalled). The deferred fold should preserve state, because it cannot be replayed. This is the idempotent "emit this verdict, write it only once" shape.
IsNone: boolReturn 'true' if the option is a 'None' value.
UserRegistereddefaultArg: 'T option -> 'T -> 'TUsed to specify a default value for an optional argument in the implementation of a function An option representing the argument. The default value of the argument. The argument value. If it is None, the defaultValue is returned. type Vector(x: double, y: double, ?z: double) = let z = defaultArg z 0.0 member this.X = x member this.Y = y member this.Z = z let v1 = Vector(1.0, 2.0) v1.Z // Evaluates to 0. let v2 = Vector(1.0, 2.0, 3.0) v2.Z // Evaluates to 3.0
fold: Event<UserRegistered> -> string option -> string optionevent: Event<UserRegistered>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_202-get-started.md_page.Account.UserRegistered_state: string optionEventDetails: 'EventDetailsThe specific details or payload of the event.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
public sealed class Account : Aggregate<AccountState, RegisterUser, UserRegistered>
{
public override string EntityName => "RegistrationCSharpAccount";
public override AccountState InitialState => new();
public override EventAction<UserRegistered> HandleCommand(
Command<RegisterUser> command, AccountState state) =>
EventActions.PersistConditionally(state.Name is null,
new UserRegistered(state.Name ?? command.CommandDetails.Name));
public override AccountState ApplyEvent(Event<UserRegistered> stored, AccountState state) =>
new(stored.EventDetails.Name);
}
- First registration: the name is absent, so
persistIf/PersistConditionallysaves the event. - Repeat registration: the condition is false, so FCQRS returns a deferred reply without saving
another event.
defaultArg state name/state.Name ?? command.CommandDetails.Namekeeps the existing name. - Apply the event:
fold/ApplyEventcopies its name into state. FCQRS also runs this during recovery to rebuild the account from saved events.
This state and its rules form an aggregate. FCQRS processes one account's commands one at a time.
RegisterUser and UserRegistered are your payloads; the Command<T> and Event<T> wrappers add
FCQRS metadata such as the request ID and event version.
Send the request
Program.fs / Program.cs registers the aggregate
and obtains accounts, the handle used to send it commands:
Shared setup
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
open Account
let send (accounts: AggregateHandle<RegisterUser, UserRegistered>) (id: AggregateId) = async {
FCQRSModelFCQRS.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_202-get-started.md_page.Accountsend: AggregateHandle<RegisterUser,UserRegistered> -> AggregateId -> Async<Event<UserRegistered>>accounts: AggregateHandle<RegisterUser,UserRegistered>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
Fcqrs_202-get-started.md_page.Account.RegisterUserFcqrs_202-get-started.md_page.Account.UserRegisteredid: AggregateIdFCQRS.Model.Data.AggregateIdasync: AsyncBuilderBuilds an asynchronous workflow using computation expression syntax. let sleepExample() = async { printfn "sleeping" do! Async.Sleep 10 printfn "waking up" return 6 } sleepExample() |> Async.RunSynchronously
let! reply = accounts.Send (Fcqrs.newCid ()) id (RegisterUser "Alice") (fun _ -> true)
reply: Event<UserRegistered>accounts: AggregateHandle<RegisterUser,UserRegistered>Send: CID -> AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>Send a command and await the first matching aggregate event.
FCQRS.FSharp.FcqrsnewCid: unit -> CIDA fresh correlation id (UUID v7).
id: AggregateIdRegisterUserShared setup
return reply
}
reply: Event<UserRegistered>var accounts = host.Services.GetRequiredService<Handler<RegisterUser, UserRegistered>>();
var reply = await accounts(_ => true, Values.NewCID(), id, new RegisterUser("Alice"));
id selects the account alice. The correlation ID identifies this request; the predicate
accepts its reply. Awaiting the call gives you the aggregate's result.
The sample also builds a query view from the saved event and waits for it before printing Query: Alice.
The query page shows that handler.
Run it
With .NET 10 and Git installed:
git clone https://github.com/OnurGumus/FCQRS.git
cd FCQRS
Choose a language. The first run restores the NuGet packages.
dotnet run --project samples/registration-fsharp
dotnet run --project samples/registration-csharp
Registered: Alice (version 1)
Query: Alice
Run the same command again:
Already registered: Alice (version 1)
Query: Alice
The stored registration survived the restart. The repeated request left the version at 1.
The event history, called the journal, is in bin/Debug/net10.0/registration.db inside the sample folder.
This example registers a profile; it does not implement passwords or login sessions.
To start your own project, copy just Account, Program, and the project file (.fsproj / .csproj)
from your chosen sample into an empty folder, then run dotnet run there.
Complete source: F# ยท C#. Next, change the name, then the account ID.