Header menu logo FCQRS

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"
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:

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

Maximum idle wait for a command subscription's matching aggregate reply before a TimeoutException

config:akka:fcqrs:notification-buffer

1024

Buffer used for ephemeral projection notifications

config:akka:fcqrs:max-worker-threads

1024

Ceiling on the thread-pool floor the saga starter raises to cover concurrent saga-start handshakes

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 (beware: a bare number would mean milliseconds to HOCON's duration parser — FCQRS parses bare numbers as seconds deliberately, matching saga-start-timeout). The command timeout is an idle timeout: receiving a non-matching event on the same correlation topic restarts it. Correlation topics are quiet in practice, but it is not a hard deadline. 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.

The saga-start handshake is synchronous, so each concurrent start holds the thread its aggregate runs on until the starter acknowledges. The saga starter therefore raises the CLR thread pool's minimum worker count to cover the handshakes it has outstanding, up to max-worker-threads. A minimum is a floor rather than a reservation: threads are created only as work demands them, so raising the ceiling costs nothing until a burst of saga starts arrives. The floor is captured once per process and only ever raised, never lowered or restored.

max-worker-threads is a real limit, not a tuning hint. Past it, saga-start handshakes time out and fail-fast the process, exactly as they did before the floor was adaptive. On a 12-core machine at the default ceiling, 1000 simultaneous saga starts across distinct aggregate instances complete and 1500 do not. The starter logs a warning the first time demand exceeds the ceiling, and an error if the runtime refuses the raise outright (a lower process maximum). Values below the captured baseline are ignored — the floor is never lowered. This bounds concurrent saga starts, not command throughput: commands to one aggregate serialize through one entity. See Sagas: durable coordination for why the handshake blocks.

Snapshot policy resolves in this order:

  1. the aggregate or saga's Every n or NoSnapshots setting;
  2. the C# builder's WithDefaultSnapshotPolicy value;
  3. config:akka:persistence:snapshot-version-count;
  4. 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.

Set config:akka:scheduler to FCQRS's ObservingScheduler only in tests that control delayed saga commands with a virtual clock.

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
  Order.passivate-idle-entity-after = 2h   # the Order 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 = "Order"
      Initial = initial
      Decide = decide
      Fold = fold
      Snapshots = Default
      Passivation = PassivationPolicy.After(TimeSpan.FromHours 2.0) }
public sealed class OrderAggregate : Aggregate<OrderState, OrderCommand, OrderEvent>
{
    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:

  1. the aggregate definition's After or Never;
  2. akka.cluster.sharding.<EntityName>.passivate-idle-entity-after;
  3. akka.cluster.sharding.passivate-idle-entity-after;
  4. 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:

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

The default node listens on localhost and joins itself. A multi-node deployment must override the remote hostname and port and configure seed-node discovery or another Akka.NET bootstrap mechanism. Every node must reach the shared journal and use compatible serializers and event contracts.

Cluster sharding routes an aggregate or saga id to its current node, so domain definitions do not change. Before deploying several nodes, verify rolling-version compatibility, shared storage, node discovery, coordinated shutdown, and monitoring for cluster membership and unreachable nodes.

The production tutorial provides the operational checklist.

val connection: obj
val config: obj
namespace Microsoft
val loggerFactory: obj
val api: obj
union case Option.Some: Value: 'T -> Option<'T>

Type something to start searching.