Idiomatic F# for Microsoft Orleans -- computation expressions, not boilerplate
Orleans is a powerful virtual actor framework, but using it from F# means fighting C# idioms at every turn: mutable state bags, attribute-heavy classes, interface-plus-codegen ceremony. Orleans.FSharp replaces all of that: a grain's public surface is a plain F# record of functions, its behavior is pure state -> reply handlers, and silos are configured with computation expressions -- discriminated unions as state, explicit storage writes, no code generation anywhere. The full Orleans runtime does the heavy lifting underneath.
The grain's public surface is a plain F# record of functions — no interface, no attributes, no code generation:
open System.Threading.Tasks
open Orleans.FSharp
// 1. The API record IS the grain's surface — and its type identity
type CounterApi =
{ increment: unit -> Task<int>
value: unit -> Task<int> }
// 2. The contract: stable wire identity — grain type, version, key encoding
let counterContract =
contract<string, CounterApi> {
grainType "counter"
version 1
stringKey
readOnly (_.value)
}
// 3. The definition: pure state -> reply handlers; storage is written only when you say so
let counter =
grainFor counterContract {
defaultState (fun () -> 0)
handle (_.increment) (fun _ctx n () -> task { return n + 1, n + 1 })
handleQuery (_.value) (fun _ctx n () -> task { return n })
}Host it and call it — the call site is the record itself:
// Silo: siloConfig { } for hosting, AddFunctionalGrain for the definition
let config = siloConfig {
useLocalhostClustering
addMemoryStorage "Default"
}
builder.UseOrleans(fun siloBuilder ->
siloBuilder.AddFunctionalGrain(counter) |> ignore)
// Client or another grain — no proxy interface, no cast
let api = FunctionalGrain.ref counterContract factory "my-counter"
let! n = api.increment ()Need two grain types over one API record, or a record type you can swap without moving the
grain's identity? Declare a dedicated brand type and use the full form,
grainContract<'Actor, 'Key, 'Api> — see
the actor brand.
The key type is your domain's, not Orleans'. A unit of measure plus a mapped key codec makes the
grain key an int64<UserId> all the way to the call site, so handing it an order id is a compile
error and costs nothing at run time:
[<Measure>] type UserId
let userId (raw: int64) : int64<UserId> = raw * 1L<UserId>
let rawId (id: int64<UserId>) : int64 = int64 id
let userContract =
contract<int64<UserId>, UserApi> {
grainType "user"
int64KeyMapped rawId userId
}
// int64<UserId> is what this reference takes; 42L<OrderId> is error FS0001, not a 3am incident
let user = FunctionalGrain.ref userContract factory (userId 42L)The same holds for any domain key type — stringKeyMapped / guidKeyMapped and the compound
forms. See examples/typesafe-ids for the full example and
key-codec identity rules for what a codec has
to guarantee.
Older authoring models. The original
grain { }CE (shown further below) still compiles and runs, but its public surface (grain { },GrainDefinition, the oldGrainContext,[<FSharpGrain>],AddFSharpGrain,FSharpGrain.*,Timers,Reminder) carries[<Obsolete>]-- a warning, not an error -- and the classiceventSourcedGrain { }is superseded by the functionaljournaledGrainFor. See Functional Grain Runtime for the before/after mapping of every deprecated entry point.siloConfig { }andclientConfig { }are current and unaffected.
Everything Orleans offers, as contract or definition operations — no attributes, no codegen:
| Where | Operations |
|---|---|
contract<'Key, 'Api> { } / grainContract<'Actor, 'Key, 'Api> { } |
grainType (optional for ephemeral grains), version, key codecs (stringKey / guidKey / int64Key, compound + mapped forms), per-operation readOnly / oneWay / alwaysInterleave / operationId / sinceVersion / transactional, whole-grain reentrant / mayInterleave, acceptsVersions |
grainFor { } |
defaultState / initialState / stateFrom, usePersistentState, transactionalStateFrom, onActivate / onDeactivate / onLifecycle, onTimer / onReminder, onStream / onBroadcast (implicit subscriptions), statelessWorker / placement, collectionAge, handle / handleQuery (reply-only, readOnly operations) / handleStream |
journaledGrainFor { } |
event sourcing over Orleans' own log-consistency providers: initialEventState, pure apply fold, handlers that raise events — see Event Sourcing |
| API field shapes | 'Arg -> Task<'Reply> and 'Arg -> IAsyncEnumerable<'Item> (streaming replies) |
| From C# | a typed facade over any contract: awaited calls and await foreach — Calling from C# |
grain { } -- Grain Definition (deprecated -- see Functional Grain Runtime)
| Keyword | Description |
|---|---|
defaultState |
Set the initial state value |
handle |
Register a state -> msg -> Task<state * obj> handler |
handleState |
Simpler: state -> msg -> Task<state> — result IS the new state |
handleTyped |
Typed result without manual boxing: state -> msg -> Task<state * 'R> |
handleWithContext |
Handler with GrainContext for grain-to-grain calls and DI |
handleStateWithContext |
GrainContext + state-only result |
handleTypedWithContext |
GrainContext + typed result |
handleWithServices |
Alias for handleWithContext emphasizing DI access |
handleStateWithServices |
Services + state-only result |
handleTypedWithServices |
Services + typed result |
handleCancellable |
Handler with CancellationToken support |
handleStateCancellable |
State-only result + cancellation |
handleTypedCancellable |
Typed result + cancellation |
handleWithContextCancellable |
Context + cancellation |
handleWithServicesCancellable |
Services + cancellation |
persist |
Name the storage provider for state persistence |
additionalState |
Declare a named secondary persistent state |
onActivate |
Hook that runs on grain activation |
onDeactivate |
Hook that runs on grain deactivation |
onReminder |
Register a named reminder handler |
onTimer |
Register a declarative timer with dueTime + period |
onLifecycleStage |
Hook into grain lifecycle stages |
interleaveMessage |
Allow a message type to interleave: interleaveMessage typeof<Query> |
Per-grain Orleans attributes — use the C# CodeGen path.
[Reentrant],[StatelessWorker],[MayInterleave],[ReadOnly],[OneWay], placement strategies,[ImplicitStreamSubscription], and[GrainType]are applied through the per-grainOrleans.FSharp.CodeGenpath, where each grain compiles to its own C# class/method that carries the real Orleans attribute. They are notgrain { }CE keywords: the universal grain pattern shares a singleFSharpGrainImplclass and one handler method, so per-grain class/method attributes cannot be expressed there. The one reentrancy lever that fits the universal pattern isinterleaveMessage typeof<'Msg>.This caveat is about the deprecated
grain { }model only. On the functional grain runtime every one of those concepts is a first-classgrainContract/grainForoperation —readOnly,oneWay,alwaysInterleave,grainType,collectionAge,statelessWorker,placement, andonStream/onBroadcastfor implicit stream and broadcast-channel subscriptions. No C# and no code generation.
| Keyword | Description |
|---|---|
useLocalhostClustering |
Local dev clustering |
addRedisClustering |
Redis-based clustering |
addAzureTableClustering |
Azure Table clustering |
addAdoNetClustering |
ADO.NET clustering (Postgres, SQL Server) |
addMemoryStorage |
In-memory grain storage |
addRedisStorage |
Redis grain storage |
addAzureBlobStorage |
Azure Blob grain storage |
addAzureTableStorage |
Azure Table grain storage |
addAdoNetStorage |
ADO.NET grain storage |
addCosmosStorage |
Cosmos DB grain storage |
addDynamoDbStorage |
DynamoDB grain storage |
addCustomStorage |
Custom storage provider |
addMemoryStreams |
In-memory stream provider |
addPersistentStreams |
Durable stream provider |
addBroadcastChannel |
Broadcast channel provider |
addMemoryReminderService |
In-memory reminders |
addRedisReminderService |
Redis reminders |
addCustomReminderService |
Custom reminder service |
useSerilog |
Wire Serilog as logging provider |
useFSharpBinarySerialization |
F# binary codec for F# types |
useJsonFallbackSerialization |
JSON fallback for types the binary codec declines |
configureServices |
Register custom DI services |
addIncomingFilter |
Incoming grain call filter |
addOutgoingFilter |
Outgoing grain call filter |
addGrainService |
Register a GrainService type |
addStartupTask |
Run a task when the silo starts |
enableHealthChecks |
Register health check endpoints |
useTls / useTlsWithCertificate |
TLS encryption |
useMutualTls / useMutualTlsWithCertificate |
Mutual TLS |
addDashboard / addDashboardWithOptions |
Orleans Dashboard |
useGrainVersioning |
Grain interface versioning |
clusterId / serviceId / siloName |
Cluster identity |
siloPort / gatewayPort / advertisedIpAddress |
Endpoints |
grainCollectionAge |
Global idle deactivation timeout |
| Keyword | Description |
|---|---|
useLocalhostClustering |
Local dev clustering |
useStaticClustering |
Static gateway endpoints |
addMemoryStreams |
In-memory stream provider |
configureServices |
Register custom DI services |
useTls / useTlsWithCertificate |
TLS encryption |
useMutualTls |
Mutual TLS |
clusterId / serviceId |
Cluster identity |
gatewayListRefreshPeriod |
Gateway refresh interval |
preferredGatewayIndex |
Preferred gateway |
Universal Grain Pattern (deprecated — FSharpGrain.* carries [<Obsolete>]; the functional runtime is the codegen-free path)
Call any registered F# grain without defining a per-grain C# interface:
// Silo startup — register your grain definition
siloBuilder.Services.AddFSharpGrain<PingState, PingCommand>(pingGrain) |> ignore
// Client / handler — string, GUID, or int key
let handle = FSharpGrain.ref<PingState, PingCommand> factory "ping-1"
let! state = handle |> FSharpGrain.send Ping // returns Task<PingState>
do! handle |> FSharpGrain.post Ping // true one-way: fire-and-forget, no round-trip
// ask returns a type you choose — useful when the handler returns something other than the state
let! count = handle |> FSharpGrain.ask<PingState, PingCommand, int> GetCount
// GUID and integer keys
let h = FSharpGrain.refGuid<S, M> factory (Guid.NewGuid())
let! s = h |> FSharpGrain.sendGuid MyCommand
let! r = h |> FSharpGrain.askGuid<S, M, string> QueryCmd
let h = FSharpGrain.refInt<S, M> factory 42L
do! h |> FSharpGrain.postInt MyCommandThe universal pattern works with any F# discriminated union as the command type — including cases with fields (Append of string) and nullary cases in mixed DUs. No CodeGen project is required; Orleans discovers the grains through Orleans.FSharp.Abstractions.
eventSourcedGrain { } -- Event Sourcing (classic model; superseded by journaledGrainFor)
| Keyword | Description |
|---|---|
defaultState |
Initial state before any events |
apply |
Pure event fold: state -> event -> state |
handle |
Command handler: state -> command -> event list |
logConsistencyProvider |
Orleans log consistency provider name |
dotnet add package Orleans.FSharp # contracts, definitions, the functional runtime surface
dotnet add package Orleans.FSharp.Runtime # silo/client hosting: AddFunctionalGrain, siloConfig { }That is the whole functional-runtime setup: Orleans.FSharp.Abstractions (the fixed transport —
request envelopes, protocol tokens, and Orleans proxies precompiled once inside the package) comes
in transitively, and there is nothing to generate in your projects.
Optional packages:
dotnet add package Orleans.FSharp.Testing # Test harness + FsCheck
dotnet add package Orleans.FSharp.EventSourcing # the classic eventSourcedGrain { } model only —
# functional journaledGrainFor ships in the core packageScaffold a new project in seconds:
dotnet new install Orleans.FSharp.Templates
dotnet new orleans-fsharp -n MyApp4.0 is the functional-era major. The functional grain
runtime (grainContract / grainFor / journaledGrainFor) is the recommended authoring model,
with full Orleans parity as first-class operations — transactions, event sourcing over Orleans'
log-consistency providers, implicit stream subscriptions, IAsyncEnumerable streaming replies,
reentrancy policies, version-tolerant contracts, placement, lifecycle hooks, and a typed C#
facade. Everything you had keeps compiling: the old grain { } / FSharpGrain.* surface is
[<Obsolete>] warnings, each message naming its replacement. The placeholder
Orleans.FSharp.EventSourcing.Marten package (which never contained a Marten integration) was
removed and delisted. Details in the CHANGELOG.
3.0 is a breaking major. The Universal Grain Pattern (AddFSharpGrain +
FSharpGrain.ref/send/ask/post) became the canonical path within the grain { } model --
note that the whole grain { } model is now itself deprecated in favour of the
functional grain runtime, though it keeps working. The non-functional
grain { } CE keywords that were deprecated in 2.x have been removed: reentrant,
statelessWorker, maxActivations, the old string-based mayInterleave, interleave,
oneWay, readOnly, grainType, deactivationTimeout, implicitStreamSubscription, and the
placement operations (preferLocalPlacement, randomPlacement, hashBasedPlacement,
activationCountPlacement, resourceOptimizedPlacement, siloRolePlacement,
customPlacement). To apply the equivalent Orleans attributes per grain, use the
Orleans.FSharp.CodeGen path. To allow a message type to interleave under the universal
pattern, use interleaveMessage typeof<'Msg>. FSharpGrain.post is now a true one-way
(fire-and-forget) call. See the CHANGELOG for the full breaking-change list.
| Guide | Description |
|---|---|
| Getting Started | Zero to working grain in 15 minutes |
| Grain Definition | Complete grain { } CE reference (deprecated authoring model) |
| Functional Grain Runtime | User-authored API records: contracts, key codecs, delivery semantics, immutable state |
| Silo Configuration | Complete siloConfig { } CE reference |
| Client Configuration | clientConfig { } CE reference |
| Serialization | 3 modes: F# Binary, JSON, Orleans Native |
| Streaming | Publish, subscribe, TaskSeq, broadcast |
| Event Sourcing | journaledGrainFor { } — a grain whose state is the fold of an event journal (and the superseded eventSourcedGrain { } CE) |
| Server-Streaming Replies | 'Arg -> IAsyncEnumerable<'Item> — items delivered as they are produced, over Orleans' async-enumerable grain extension |
| Testing | TestHarness, FsCheck, GrainMock |
| Analyzers | OF0001: async {} detection, AllowAsync opt-out |
| Security | TLS, mTLS, filters, secrets |
| Advanced | Transactions, OpenTelemetry, shutdown, migration |
| Resilience | Polly v8 retry, circuit-breaker, and timeout patterns |
| Calling from C# | Bind a hand-written C# interface to a functional grain contract |
| Redis Example | End-to-end shopping cart with Redis storage/clustering |
| API Reference | All public modules, types, functions |
| Package | Description |
|---|---|
Orleans.FSharp |
Core: the functional grain runtime (grainContract/grainFor/journaledGrainFor), observers, streaming, logging, serialization — plus the deprecated grain { } CE |
Orleans.FSharp.Runtime |
Silo hosting, client config, grain discovery |
Orleans.FSharp.Abstractions |
The fixed functional transport: envelopes, protocol tokens, precompiled Orleans proxies (arrives transitively) |
Orleans.FSharp.EventSourcing |
The classic eventSourcedGrain { } model (functional journaledGrainFor lives in core) |
Orleans.FSharp.CodeGen |
Optional: per-grain C# code generation for custom grain interfaces (legacy pattern) |
Orleans.FSharp.Testing |
Test harness, GrainArbitrary, GrainMock, log capture |
Orleans.FSharp.Analyzers |
F# analyzer: OF0001 warns on async { } usage; [<AllowAsync>] opt-out |
Orleans.FSharp.Templates |
dotnet new project template |
Never inline connection strings containing passwords or secrets in source code. Load them from configuration or environment variables at runtime.
Recommended: Use IConfiguration or environment variables:
let connStr = Environment.GetEnvironmentVariable("REDIS_CONNECTION")
let config = siloConfig {
useLocalhostClustering
addRedisStorage "Default" connStr
}Avoid: Hardcoding secrets in source files:
// DO NOT do this -- secrets will leak into version control
addRedisStorage "Default" "redis://user:password@host:6379"When using useTls or useMutualTls, always use valid certificates from a trusted certificate authority in production. Do not disable certificate validation in production environments.
Contributions are welcome! Please open an issue or pull request on GitHub.
This project is licensed under the MIT License.