Complete guide to the grain { } computation expression.
- Every keyword in the
grain { }CE - How to define handlers, lifecycle hooks, reminders, and timers
- Allowing message types to interleave with
interleaveMessage - Multiple named states, and where per-grain Orleans attributes live (the C# CodeGen path)
The grain { } CE builds a GrainDefinition<'State, 'Message> -- an immutable record that fully describes a grain's behavior. The Orleans.FSharp.CodeGen package reads this definition at build time and generates the corresponding C# grain class with all the correct Orleans attributes.
open Orleans.FSharp
let myGrain = grain {
defaultState initialValue
handle handlerFunction
persist "StorageProviderName"
}Every grain definition requires at minimum:
- A
defaultState— the initial state value - At least one handler from the table below
| Variant | Signature | Use when |
|---|---|---|
handle |
'S -> 'M -> Task<'S * obj> |
Full control, manual box |
handleState |
'S -> 'M -> Task<'S> |
Caller only needs state |
handleTyped |
'S -> 'M -> Task<'S * 'R> |
Typed result, no box |
handleWithContext |
GrainContext -> 'S -> 'M -> Task<'S * obj> |
Need grain-to-grain calls or DI |
handleStateWithContext |
GrainContext -> 'S -> 'M -> Task<'S> |
Context + state-only return |
handleTypedWithContext |
GrainContext -> 'S -> 'M -> Task<'S * 'R> |
Context + typed result |
handleCancellable |
'S -> 'M -> CancellationToken -> Task<'S * obj> |
Long-running ops, manual box |
handleStateCancellable |
'S -> 'M -> CancellationToken -> Task<'S> |
Long-running ops, state-only |
handleTypedCancellable |
'S -> 'M -> CancellationToken -> Task<'S * 'R> |
Long-running ops, typed result |
handleWithContextCancellable |
GrainContext -> 'S -> 'M -> CancellationToken -> Task<'S * obj> |
Context + cancellation |
handleStateWithContextCancellable |
GrainContext -> 'S -> 'M -> CancellationToken -> Task<'S> |
Context + cancellation, state-only |
handleTypedWithContextCancellable |
GrainContext -> 'S -> 'M -> CancellationToken -> Task<'S * 'R> |
Context + cancellation, typed result |
Aliases: handleWithServices = handleWithContext, handleStateWithServices = handleStateWithContext,
handleWithServicesCancellable = handleWithContextCancellable,
handleStateWithServicesCancellable = handleStateWithContextCancellable,
handleTypedWithServicesCancellable = handleTypedWithContextCancellable.
Sets the initial state of the grain when first activated. Required for every grain.
let myGrain = grain {
defaultState 0
handle (fun state msg -> task { return state + 1, box(state + 1) })
}For DU state:
let myGrain = grain {
defaultState Zero
handle (fun state msg -> task { return Count 1, box 1 })
}Registers the message handler. Takes the current state and a message, returns a Task<'State * obj> (new state and a boxed result).
grain {
defaultState 0
handle (fun state msg ->
task {
match msg with
| Add n -> return state + n, box(state + n)
| Get -> return state, box state
})
}A simpler variant of handle for grains where the caller only needs the updated state. The handler returns Task<'State> — no need to box a result separately.
grain {
defaultState { Count = 0 }
handleState (fun state msg ->
task {
match msg with
| Increment -> return { Count = state.Count + 1 }
| Decrement -> return { Count = state.Count - 1 }
| GetValue -> return state
})
}The result returned to the caller is the new state (boxed internally).
Like handle, but accepts a strongly-typed result instead of obj. No manual box call needed.
grain {
defaultState { Count = 0 }
handleTyped (fun state msg ->
task {
match msg with
| Increment ->
let ns = { Count = state.Count + 1 }
return ns, ns.Count // 'Result = int — no box needed
| GetValue ->
return state, state.Count
})
}The 'Result type is inferred from the handler return type. The framework boxes it internally before sending to the caller.
Like handle, but the handler receives a GrainContext as the first argument. Use this when you need to call other grains or resolve DI services from within the handler.
grain {
defaultState Map.empty
handleWithContext (fun ctx state msg ->
task {
match msg with
| Aggregate key ->
let otherGrain =
GrainContext.getGrainByString<IOtherGrain> ctx key
let! value = GrainRef.invoke otherGrain (fun g -> g.GetValue())
return state |> Map.add key value, box value
})
}The GrainContext provides:
| Member | Description |
|---|---|
GrainContext.getService<'T> |
Resolve a DI service |
GrainContext.getState<'T> name |
Get a named additional persistent state |
GrainContext.getGrainByString<'T> key |
Get a grain ref by string key |
GrainContext.getGrainByGuid<'T> key |
Get a grain ref by GUID key |
GrainContext.getGrainByInt64<'T> key |
Get a grain ref by int64 key |
GrainContext.getGrainByGuidCompound<'T> guid ext |
Compound GUID+string key |
GrainContext.getGrainByIntCompound<'T> key ext |
Compound int64+string key |
GrainContext.deactivateOnIdle |
Request deactivation when idle |
GrainContext.delayDeactivation span |
Delay deactivation |
GrainContext.grainId |
Get the GrainId |
GrainContext.primaryKeyString |
Get the string primary key |
GrainContext.primaryKeyGuid |
Get the Guid primary key |
GrainContext.primaryKeyInt64 |
Get the int64 primary key |
Like handleWithContext, but returns only the new state — no need to manually box a result. Use this when the handler needs GrainContext but the caller only needs the updated state.
grain {
defaultState { Score = 0 }
handleStateWithContext (fun ctx state msg ->
task {
match msg with
| AddPoints n ->
return { Score = state.Score + n }
| SubtractPoints n ->
return { Score = state.Score - n }
| NotifyAchievement achievementId ->
let notifier = GrainContext.getService<IAchievementNotifier> ctx
do! notifier.NotifyAsync(achievementId)
return state
})
}Pair with
FSharpGrain.sendto receive the updated state back.
Like handleWithContext, but returns a strongly-typed result — no manual box call needed. Combines context access with the clean handleTyped return style.
grain {
defaultState { Balance = 0m }
handleTypedWithContext (fun ctx state msg ->
task {
match msg with
| Deposit amount ->
let ns = { Balance = state.Balance + amount }
return ns, ns.Balance // 'Result = decimal
| GetBalance ->
return state, state.Balance
})
}Pair with
FSharpGrain.ask<'S,'C,decimal>to receive the typed result.
Alias for handleWithContext that emphasizes DI access. Identical behavior.
grain {
defaultState []
handleWithServices (fun ctx state msg ->
task {
let logger = GrainContext.getService<ILogger<_>> ctx
Log.logInfo logger "Processing {Command}" [| box msg |]
return state, box ()
})
}Like handle, but the handler receives a CancellationToken for cooperative cancellation of long-running operations.
grain {
defaultState ""
handleCancellable (fun state msg ct ->
task {
let! result = longRunningOperation ct
return result, box result
})
}Like handleState, but the handler receives a CancellationToken. Returns only the updated state — no manual box needed.
grain {
defaultState { Items = [] }
handleStateCancellable (fun state msg ct ->
task {
match msg with
| FetchAndAppend url ->
let! item = fetchWithTimeout url ct
return { Items = item :: state.Items }
| Clear ->
return { Items = [] }
})
}The CancellationToken comes from the Orleans runtime and allows long-running fetch/IO operations to be cancelled cleanly when the silo shuts down.
Like handleTyped, but the handler also receives a CancellationToken. Returns a strongly-typed result — no box needed.
grain {
defaultState { Results = [] }
handleTypedCancellable (fun state msg ct ->
task {
match msg with
| ComputeSum inputs ->
let sum = List.sum inputs
return { Results = sum :: state.Results }, sum // 'Result = int
| GetLatest ->
let latest = state.Results |> List.tryHead |> Option.defaultValue 0
return state, latest
})
}Pair with
FSharpGrain.ask<'S,'C,int>to receive the typed result.
Combines GrainContext and CancellationToken.
grain {
defaultState 0
handleWithContextCancellable (fun ctx state msg ct ->
task {
let httpClient = GrainContext.getService<HttpClient> ctx
let! response = httpClient.GetAsync("https://api.example.com", ct)
return state + 1, box response.StatusCode
})
}Alias for handleWithContextCancellable.
Combines GrainContext, CancellationToken, and state-only return — no manual box needed. The maximum set of inputs with the simplest return.
grain {
defaultState { Items = [] }
handleStateWithContextCancellable (fun ctx state msg ct ->
task {
match msg with
| FetchAndStore url ->
let http = GrainContext.getService<HttpClient> ctx
let! body = http.GetStringAsync(url, ct)
return { Items = body :: state.Items }
| Clear ->
return { Items = [] }
})
}Alias for handleStateWithContextCancellable.
The full combination: GrainContext, CancellationToken, and a typed result — no box needed.
grain {
defaultState { Total = 0 }
handleTypedWithContextCancellable (fun ctx state msg ct ->
task {
match msg with
| FetchAndAdd url ->
let http = GrainContext.getService<HttpClient> ctx
let! n = http.GetStringAsync(url, ct) |> Task.map int
return { Total = state.Total + n }, state.Total + n // 'Result = int
| GetTotal ->
return state, state.Total
})
}Pair with
FSharpGrain.ask<'S,'C,int>to receive the typed result.
Alias for handleTypedWithContextCancellable.
Names the Orleans storage provider for state persistence. The provider must be registered in the silo configuration.
grain {
defaultState Zero
handle (fun state msg -> task { return Count 1, box 1 })
persist "Default"
}Without persist, the grain state is in-memory only and is lost on deactivation.
Declares a named secondary persistent state that can be accessed via GrainContext.getState. Use this when a grain needs multiple independently persisted state values.
grain {
defaultState { Balance = 0m }
additionalState "AuditLog" "AuditStore" ([] : AuditEntry list)
handleWithContext (fun ctx state msg ->
task {
let auditState =
GrainContext.getState<AuditEntry list> ctx "AuditLog"
let currentAudit = auditState.State
// ... process and update both states
return newState, box result
})
persist "Default"
}Parameters: additionalState name storageName defaultValue
Runs when the grain is activated. Receives the current state and returns a potentially modified state.
grain {
defaultState { Cache = Map.empty; LastRefresh = DateTime.MinValue }
handle myHandler
persist "Default"
onActivate (fun state ->
task {
printfn "Grain activated with state: %A" state
return { state with LastRefresh = DateTime.UtcNow }
})
}Runs when the grain is being deactivated. Receives the current state for cleanup.
grain {
defaultState { ConnectionId = None }
handle myHandler
persist "Default"
onDeactivate (fun state ->
task {
match state.ConnectionId with
| Some id -> printfn "Closing connection %s" id
| None -> ()
})
}Hooks into Orleans grain lifecycle stages for fine-grained control. Standard stages:
| Stage | Value | Description |
|---|---|---|
GrainLifecycleStage.First |
2000 | First stage after creation |
GrainLifecycleStage.SetupState |
4000 | State setup |
GrainLifecycleStage.Activate |
6000 | Activation |
GrainLifecycleStage.Last |
int.MaxValue | Final stage |
grain {
defaultState 0
handle myHandler
onLifecycleStage 2000 (fun ct ->
task {
printfn "Early lifecycle hook firing"
})
onLifecycleStage 6000 (fun ct ->
task {
printfn "Activation-stage hook firing"
})
}Multiple hooks at the same stage are executed in registration order.
Registers a named reminder handler. Reminders are persistent periodic triggers that survive grain deactivation and silo restarts. The silo must have a reminder service configured.
grain {
defaultState { CheckCount = 0 }
handle myHandler
persist "Default"
onReminder "HealthCheck" (fun state reminderName tickStatus ->
task {
printfn "Reminder %s fired at %A" reminderName tickStatus.CurrentTickTime
return { state with CheckCount = state.CheckCount + 1 }
})
}To register the reminder at runtime, use the Reminder module:
Reminder.register grain "HealthCheck" (TimeSpan.FromMinutes 1.) (TimeSpan.FromMinutes 5.)Registers a declarative timer that is automatically started on grain activation and stopped on deactivation. Timers are in-memory only -- they do not survive deactivation.
grain {
defaultState { HeartbeatCount = 0 }
handle myHandler
onTimer
"Heartbeat"
(TimeSpan.FromSeconds 10.) // dueTime: first fire after 10s
(TimeSpan.FromSeconds 30.) // period: then every 30s
(fun state ->
task {
return { state with HeartbeatCount = state.HeartbeatCount + 1 }
})
}By default each grain processes one message at a time. Under the universal grain pattern the
one concurrency lever is interleaveMessage.
Registers a message type whose calls may interleave with other calls on the same grain — the
working replacement for the removed string-based mayInterleave keyword.
grain {
defaultState Map.empty
handle myHandler
interleaveMessage typeof<Query> // allow all Query cases to interleave
}All F# grains share Orleans.FSharp.FSharpGrainImpl, which carries a single class-level
[MayInterleave] predicate keyed on the incoming message's runtime type. Each registered type
is pushed into the process-wide FSharpInterleaveRegistry when the grain is added to the silo.
Pass the discriminated-union type itself — the predicate matches by assignability, so
field-carrying DU cases (which compile to nested subtypes that cannot be named in source) are
covered. Repeated registrations of the same type are de-duplicated.
Caution: registering a broad base type, interface, or
objwill make every assignable message type interleavable — always register specific DU or message types.
Reentrancy beyond interleaveMessage, stateless workers, placement strategies, one-way and
read-only methods, implicit stream subscriptions, and custom grain-type names are not
grain { } CE keywords. The universal grain pattern shares a single FSharpGrainImpl class and
one handler method, so per-grain class-level or per-method attributes cannot be expressed there.
To use them, define the grain through the per-grain Orleans.FSharp.CodeGen path: each grain
compiles to its own C# class/method that carries the real Orleans attribute.
| Concept | Orleans attribute (CodeGen path) |
|---|---|
| Reentrant grain | [Reentrant] |
| Custom interleave predicate | [MayInterleave("Predicate")] |
| Always-interleaved / read-only method | [AlwaysInterleave] / [ReadOnly] |
| Stateless worker (+ max activations) | [StatelessWorker] / [StatelessWorker(n)] |
| Fire-and-forget method | [OneWay] |
| Custom grain type name | [GrainType("name")] |
| Implicit stream subscription | [ImplicitStreamSubscription("namespace")] |
| Placement | [PreferLocalPlacement], [RandomPlacement], [HashBasedPlacement], [ActivationCountBasedPlacement], [ResourceOptimizedPlacement], [SiloRoleBasedPlacement], or a custom PlacementStrategy |
Per-grain idle deactivation timeouts are configured globally with
grainCollectionAgeinsiloConfig { }; there is no per-graingrain { }keyword for it.
Here is a grain that uses many features together:
open System
open System.Threading.Tasks
open Orleans.FSharp
[<GenerateSerializer>]
type ChatState =
| [<Id(0u)>] Empty
| [<Id(1u)>] Active of messages: string list * participants: Set<string>
[<GenerateSerializer>]
type ChatCommand =
| [<Id(0u)>] Join of user: string
| [<Id(1u)>] Leave of user: string
| [<Id(2u)>] Send of user: string * text: string
| [<Id(3u)>] GetHistory
| [<Id(4u)>] GetParticipants
let chatRoom =
grain {
defaultState Empty
handleWithContext (fun ctx state cmd ->
task {
match state, cmd with
| Empty, Join user ->
let newState = Active([], Set.singleton user)
return newState, box true
| Active(msgs, users), Join user ->
return Active(msgs, users |> Set.add user), box true
| Active(msgs, users), Leave user ->
let remaining = users |> Set.remove user
if Set.isEmpty remaining then
return Empty, box true
else
return Active(msgs, remaining), box true
| Active(msgs, users), Send(user, text) ->
let entry = $"[{DateTime.UtcNow:HH:mm}] {user}: {text}"
return Active(entry :: msgs, users), box entry
| _, GetHistory ->
let msgs = match state with Active(m, _) -> m | _ -> []
return state, box msgs
| _, GetParticipants ->
let users = match state with Active(_, u) -> u | _ -> Set.empty
return state, box users
| _ -> return state, box false
})
persist "Default"
interleaveMessage typeof<ChatCommand> // read-heavy queries may interleave
onTimer "Cleanup" (TimeSpan.FromMinutes 5.) (TimeSpan.FromMinutes 5.)
(fun state ->
task {
match state with
| Active(msgs, users) when msgs.Length > 1000 ->
return Active(msgs |> List.take 500, users)
| _ -> return state
})
}- Silo Configuration -- configure storage, clustering, and streaming for your grains
- Streaming -- publish and subscribe to events
- Testing -- test your grain definitions with FsCheck and TestHarness
- Advanced -- transactions, grain directory, OpenTelemetry, shutdown, state migration