Configuration
FCQRS starts from an embedded Akka.NET configuration and merges application configuration over it. This page lists the defaults, FCQRS runtime keys, persistence providers, and the settings required to move from one local process to a cluster.
Minimal configuration
Fcqrs.connect supplies the persistence provider and connection string. An empty IConfiguration
accepts the rest of the embedded defaults:
open FCQRS.FSharp
let connection = Fcqrs.connect FCQRS.Actor.DBType.Sqlite "Data Source=app.db;"
// An empty IConfiguration accepts the embedded Akka.NET defaults.
let config = Microsoft.Extensions.Configuration.ConfigurationBuilder().Build()
let loggerFactory =
Microsoft.Extensions.Logging.LoggerFactory.Create(fun _ -> ())
let api = Fcqrs.actor config loggerFactory (Some connection) "MyCluster"
FCQRSFCQRS.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 / Projections.startTracked / 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 -> ...)
connection: FCQRS.Actor.ConnectionFCQRS.FSharp.Fcqrsconnect: FCQRS.Actor.DBType -> string -> FCQRS.Actor.ConnectionBuild a SQLite/etc. Connection from a raw connection string of any length.
SqliteSQLite using Microsoft.Data.Sqlite provider
FCQRS.ActorFCQRS.Actor.DBTypeRepresents the type of database connection
config: Extensions.Configuration.IConfigurationRootMicrosoft``.ctor``: unit -> unitExtensionsConfigurationBuild: unit -> Extensions.Configuration.IConfigurationRootBuilds an with keys and values from the set of providers registered in . An with keys and values from the registered providers.
loggerFactory: Extensions.Logging.ILoggerFactoryCreate: System.Action<Extensions.Logging.ILoggingBuilder> -> Extensions.Logging.ILoggerFactoryCreates new instance of configured using provided delegate. A delegate to configure the . The that was created.
LoggingMicrosoft.Extensions.Logging.LoggerFactoryProduces instances of classes based on the given providers.
api: FCQRS.Common.IActoractor: Extensions.Configuration.IConfiguration -> Extensions.Logging.ILoggerFactory -> FCQRS.Actor.Connection option -> string -> FCQRS.Common.IActorCreate the actor system from plain values (cluster name as a string).
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddFcqrs(
connectionString: "Data Source=app.db;",
clusterName: "MyCluster");
The supported DBType values are listed in Configure the database.
The C# host-builder overload creates the same setup with SQLite.
What the defaults set up for you
The embedded configuration provides:
- a SQL journal for persisted events;
- a SQL read journal consumed by projections;
- a SQL snapshot store;
- automatic persistence-table initialization;
- FCQRS and Akka.NET serializers;
- the Akka.NET cluster actor provider and distributed pub/sub;
- cluster sharding with remembered entities;
- a localhost transport on a dynamic port;
- a one-node cluster formed by joining the process to itself.
The query journal polls for new events every 100 ms by default. Those persistence-plugin settings may be overridden with HOCON.
FCQRS runtime keys
The .NET configuration path uses colons. The equivalent HOCON path uses nested objects.
| .NET configuration key | Default | Purpose |
|---|---|---|
config:akka:persistence:snapshot-version-count |
30 |
Snapshot interval used by SnapshotPolicy.Default |
config:akka:fcqrs:saga-start-timeout |
30 |
Maximum seconds allowed for the saga-start handshake before fail-fast |
config:akka:fcqrs:command-timeout |
30s |
Deadline from command subscription setup to its matching aggregate reply; nonmatching events do not extend it |
config:akka:fcqrs:notification-buffer |
1024 |
Maximum queued notifications per subscriber; a full subscriber queue drops its oldest notification |
config:akka:loglevel |
OFF |
Akka.NET internal log level |
config:akka:stdout-loglevel |
OFF |
Akka.NET standard-output log level |
The two timeout keys share one unit rule: a bare number means seconds. command-timeout also
accepts HOCON durations such as 500ms or 1m. HOCON's own duration parser would read a bare number
as milliseconds; FCQRS reads it as seconds, matching saga-start-timeout. The command timeout is a deadline that starts when the command subscription
accepts the command. Non-matching events on the same correlation topic do not restart it. The same
key bounds the projection wait in the F# facade's sendAwaiting: a projection that suppresses the
matching notification raises TimeoutException instead of hanging the caller.
The notification buffer is not a durable queue. Notifications without an active subscriber may be dropped, which is correct for the request-scoped read-your-writes mechanism.
Transactional projections use TransactionalProjectionOptions for background discovery
(PollInterval, default 1s), per-identity batch size (BatchSize, default 500), the complete
catch-up deadline (CatchUpTimeout, default 30s), how late a write may commit and still be found by
a query of recent writes (LateWriteWindow, default 30s), and how often a query scans the whole
journal (FullScanInterval, default 5 minutes). These are registration options rather than HOCON
keys. Projections registered with Fcqrs.projection or AddProjection use the same defaults. An
aggregate that stores an event wakes the projections on its node before the next poll. See
Catch up projections for their transaction and snapshot
boundaries.
The saga-start handshake holds no thread: an aggregate waits for the sagas an event starts as
messages. FCQRS 6.9.0 removed max-worker-threads and saga-batch-ttl, which configured the
thread-pool floor and the coordinator of the earlier blocking handshake; both keys are now ignored. See
Sagas: durable coordination.
Snapshot policy resolves in this order:
- the aggregate or saga's
Every norNoSnapshotssetting; - the C# builder's
WithDefaultSnapshotPolicyvalue; config:akka:persistence:snapshot-version-count;- the fallback value
30.
See Deferring, snapshots, and passivation before tuning the cadence. A snapshot changes replay cost, not the events that define recoverable state.
Time in tests
FCQRS stamps each command and event with the time Akka.NET's scheduler tells, and
IActor.TimeProvider reads the same time. A test changes that time by choosing the scheduler:
ShiftedSchedulertells real time moved by itsShift, and runs its timers on real time. A test that lets two days pass setsShiftto two days: aggregates decide, and code that readsTimeProviderruns, two days later, without the test waiting. Command timeouts, saga handshakes and cluster sharding keep their real-time timers.ObservingSchedulerruns a virtual clock that moves only when the test advances it, timers included. Use it only in tests that control delayed saga commands with that clock.
config.akka.scheduler.implementation = "FCQRS.Scheduler+ShiftedScheduler, FCQRS"
Shared setup
module Account =
open FCQRS.Common
// What a caller can ask an account to do.
type AccountCommand =
| Open of owner: string
| Deposit of amount: decimal
| Withdraw of amount: decimal
// What the account replies. Rejected is a reply only: it is never stored.
type AccountEvent =
| Opened of owner: string
| Deposited of amount: decimal
| Withdrawn of amount: decimal
| Rejected of reason: string
// What the account knows now, rebuilt from its events.
type AccountState = { Owner: string option; Balance: decimal }
// The state before the account's first event.
let initial = { Owner = None; Balance = 0m }
// Chooses what to do with a command, based on the current state.
let decide (command: Command<AccountCommand>) (state: AccountState) =
match command.CommandDetails, state.Owner with
| Open _, Some _ -> DeferEvent(Rejected "The account is already open")
| Open owner, None -> PersistEvent(Opened owner)
| _, None -> DeferEvent(Rejected "The account is not open")
| (Deposit amount | Withdraw amount), _ when amount <= 0m ->
DeferEvent(Rejected "The amount must be positive")
| Deposit amount, _ -> PersistEvent(Deposited amount)
| Withdraw amount, _ when amount > state.Balance ->
DeferEvent(Rejected $"Insufficient funds: {state.Balance} available")
| Withdraw amount, _ -> PersistEvent(Withdrawn amount)
// Applies one event to the state. A rejection changes nothing.
let fold (event: Event<AccountEvent>) (state: AccountState) =
match event.EventDetails with
| Opened owner -> { state with Owner = Some owner }
| Deposited amount -> { state with Balance = state.Balance + amount }
| Withdrawn amount -> { state with Balance = state.Balance - amount }
| Rejected _ -> state
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
open Account
Fcqrs_502-configuration.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_502-configuration.md_page.Account.AccountCommandOpenowner: stringstringAn abbreviation for the CLI type . Basic Types
Depositamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
WithdrawFcqrs_502-configuration.md_page.Account.AccountEventOpenedDepositedWithdrawnRejectedreason: stringFcqrs_502-configuration.md_page.Account.AccountStateOwner: string optionoptionThe 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
Balance: decimalinitial: AccountStateNoneThe representation of "No value"
decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>command: Command<AccountCommand>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
state: AccountStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version. A deferred event does not start a saga; running sagas still receive it.
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
(>): 'T -> 'T -> boolStructural greater-than The first parameter. The second parameter. The result of the comparison. 5 > 1 // Evaluates to true 5 > 5 // Evaluates to false (1, "a") > (1, "z") // Evaluates to false
fold: Event<AccountEvent> -> AccountState -> AccountStateevent: Event<AccountEvent>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>
EventDetails: 'EventDetailsThe specific details or payload of the event.
SystemModelFCQRS.Model.DataFCQRS.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 / Projections.startTracked / 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 -> ...)
let scheduler = api.System.Scheduler :?> FCQRS.Scheduler.ShiftedScheduler
scheduler.Shift <- TimeSpan.FromDays 2.0
scheduler: FCQRS.Scheduler.ShiftedSchedulerapi: IActorScheduler: Akka.Actor.ISchedulerGets the scheduler. The scheduler.
System: Akka.Actor.ActorSystemGets the hosting ActorSystem.
FCQRSFCQRS.Scheduler.ShiftedSchedulerAkka.NET's default scheduler on real time, telling a time moved by `Shift`. FCQRS stamps commands and events with the scheduler's `Now`, and `IActor.TimeProvider` tells the same time. A test that lets time pass sets `Shift`, and every aggregate decision and every reader of `TimeProvider` sees the later time without the test waiting for it. Timers keep running on real time, so command timeouts, saga handshakes and cluster sharding behave as they do in production. `ObservingScheduler` is the choice when a test must control when timers fire instead. Set it with `akka.scheduler.implementation = "FCQRS.Scheduler+ShiftedScheduler, FCQRS"` and reach it as `api.System.Scheduler :?> ShiftedScheduler`. A shift back makes later commands carry earlier dates than earlier ones; a test that does so must not compare dates across it.
FCQRS.SchedulerShift: TimeSpanHow far the time told is from real time.
System.TimeSpanRepresents a time interval.
FromDays: float -> TimeSpanReturns a that represents a specified number of days, where the specification is accurate to the nearest millisecond. A number of days, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
A shift back makes later commands carry earlier dates than earlier ones. A test that shifts back, for example to start its next case on real time, must not compare dates across that change.
Passivation timing
An aggregate actor that receives no message for akka.cluster.sharding.passivate-idle-entity-after
is stopped and releases its in-memory state. Akka.NET's default is 120s. Raise it for aggregates
whose replay is expensive relative to their idle memory, lower it for a large keyspace touched once,
and set 0 to disable idle passivation entirely.
config.akka.cluster.sharding.passivate-idle-entity-after = 30m
Keys nested under the entity name override the shared block for that entity type alone:
config.akka.cluster.sharding {
passivate-idle-entity-after = 30m # every aggregate type
Account.passivate-idle-entity-after = 2h # the Account aggregate only
Session.passivate-idle-entity-after = 30s
}
The override key is the Name in the aggregate definition, the same string used to build the
persistence id, so renaming an aggregate moves this key along with its journal contract.
An aggregate whose idle policy belongs to the domain rather than to the deployment can carry it in its definition, where it outranks both configuration levels:
Fcqrs.aggregate api
{ Name = "Account"
Initial = initial
Decide = decide
Fold = fold
Snapshots = Default
Passivation = PassivationPolicy.After(TimeSpan.FromHours 2.0) }
FCQRS.FSharp.Fcqrsaggregate: IActor -> Aggregate<'State,'Command,'Event> -> AggregateHandle<'Command,'Event>Register an aggregate and return its typed handle. Calling this IS the registration (it initializes the sharding region).
api: IActorName: stringInitial: 'Stateinitial: AccountStateDecide: Command<'Command> -> 'State -> EventAction<'Event>handleCommand (decide): command + current state -> what to do.
decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>Fold: Event<'Event> -> 'State -> 'StateapplyEvent (fold): event + current state -> next state (pure).
fold: Event<AccountEvent> -> AccountState -> AccountStateSnapshots: SnapshotPolicySnapshot cadence: Default (config / 30), NoSnapshots, or Every n.
DefaultUse the global config (config:akka:persistence:snapshot-version-count), or 30.
Passivation: PassivationPolicyIdle passivation: PassivationPolicy.Default (configuration, then Akka's 120s), After an idle period, or Never.
FCQRS.Common.PassivationPolicyIdle passivation for an aggregate type, set per entity at registration. Passivation stops an idle actor and releases its in-memory state; the next command recovers it from the journal. Only messages routed through cluster sharding count as activity. Sagas ignore this: their shard regions remember entities, which disables idle passivation in Akka.NET. A saga stops at StopSaga or abort instead.
AfterPassivate after this idle period, overriding configuration. A non-positive value means Never.
System.TimeSpanRepresents a time interval.
FromHours: float -> TimeSpanReturns a that represents a specified number of hours, where the specification is accurate to the nearest millisecond. A number of hours accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
public sealed class Account
: Aggregate<AccountState, AccountCommand, AccountEvent>
{
public override PassivationPolicy PassivationPolicy =>
PassivationPolicy.NewAfter(TimeSpan.FromHours(2));
}
PassivationPolicy.Never keeps the entity resident until the node stops or the shard moves.
PassivationPolicy.Default leaves configuration in charge, so the full order is:
- the aggregate definition's
AfterorNever; akka.cluster.sharding.<EntityName>.passivate-idle-entity-after;akka.cluster.sharding.passivate-idle-entity-after;- Akka.NET's
120s.
Choosing Never means the entity holds memory for as long as the node runs. It bounds recovery
cost, not memory, so it suits a small, bounded set of hot aggregates rather than an open keyspace.
Two limits apply:
- Only messages routed through cluster sharding count as activity. Messages an entity sends to
itself, and direct sends to a resolved
IActorRef, do not reset the idle timer. - Sagas are never idle-passivated. FCQRS starts saga regions with remembered entities, and Akka
disables idle passivation whenever that is on. A saga stops when its workflow reaches
StopSagaor aborts, so a saga that never terminates stays resident by design.
Passivation is not a per-instance setting: every entity of a type shares one timeout, whether it comes from configuration or from the definition. An individual aggregate instance cannot be given its own.
Deferring, snapshots, and passivation covers what passivation does and does not discard. Passivation costs a replay, so tune it together with the snapshot cadence.
Overriding with HOCON
Application configuration is added after the embedded HOCON, so matching application keys win. The example below overrides the three SQLite persistence stores explicitly:
config {
connection-string = "Data Source=app.db;"
akka {
persistence {
journal.sql {
connection-string = ${config.connection-string}
provider-name = "SQLite.MS"
auto-initialize = true
}
query.journal.sql {
connection-string = ${config.connection-string}
provider-name = "SQLite.MS"
auto-initialize = true
}
snapshot-store.sql {
connection-string = ${config.connection-string}
provider-name = "SQLite.MS"
auto-initialize = true
}
}
}
}
Load the file with ConfigurationBuilder().AddHoconFile("config.hocon").Build() and pass the result to
Fcqrs.actor, or add the same keys through another IConfiguration provider.
When overriding the database provider, change the journal, query journal, and snapshot store together. Pointing them at different databases is possible but changes backup, recovery, and availability behaviour and should be an explicit design choice.
Logging and diagnostics
FCQRS emits a message-flow log through ILogger and spans through ActivitySource. Configure payload
visibility before handling sensitive data. Observe your system lists the
categories, source names, switches, and fatal-flush hook.
Akka.NET internal logging defaults to OFF; FCQRS application-flow logs still use the supplied
ILoggerFactory. Enable Akka.NET internals with
builder.WithAkkaLogging(AkkaLogLevel.Info) from the hosting builder, or set config:akka:loglevel
in your IConfiguration.
Scaling to a cluster
FCQRS runs on one node. It places aggregates and sagas with Akka.NET cluster sharding, so domain definitions do not depend on the number of nodes, but several FCQRS nodes on one journal do not work yet:
- every node joins itself, so nodes configured with the same seed nodes stay separate one-node clusters;
- a command's reply travels over a publish-subscribe topic that other nodes learn about about a second later, so a caller on one node can miss the reply of an aggregate on another and time out although the command succeeded.
Run one node under a supervisor that restarts it, as When FCQRS stops the process describes. A restarted node recovers every aggregate and saga from the journal.
Observe your system covers runtime diagnostics.