Skip to content

Grain Definition

Complete guide to the grain computation expression.

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:

  1. A defaultState — the initial state value
  2. At least one handler from the table below
VariantSignatureUse 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
handleWithContextGrainContext -> 'S -> 'M -> Task<'S * obj>Need grain-to-grain calls or DI
handleStateWithContextGrainContext -> 'S -> 'M -> Task<'S>Context + state-only return
handleTypedWithContextGrainContext -> '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
handleWithContextCancellableGrainContext -> 'S -> 'M -> CancellationToken -> Task<'S * obj>Context + cancellation
handleStateWithContextCancellableGrainContext -> 'S -> 'M -> CancellationToken -> Task<'S>Context + cancellation, state-only
handleTypedWithContextCancellableGrainContext -> '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:

MemberDescription
GrainContext.getService<'T>Resolve a DI service
GrainContext.getState<'T> nameGet a named additional persistent state
GrainContext.getGrainByString<'T> keyGet a grain ref by string key
GrainContext.getGrainByGuid<'T> keyGet a grain ref by GUID key
GrainContext.getGrainByInt64<'T> keyGet a grain ref by int64 key
GrainContext.getGrainByGuidCompound<'T> guid extCompound GUID+string key
GrainContext.getGrainByIntCompound<'T> key extCompound int64+string key
GrainContext.deactivateOnIdleRequest deactivation when idle
GrainContext.delayDeactivation spanDelay deactivation
GrainContext.grainIdGet the GrainId
GrainContext.primaryKeyStringGet the string primary key
GrainContext.primaryKeyGuidGet the Guid primary key
GrainContext.primaryKeyInt64Get 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.send to 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:

StageValueDescription
GrainLifecycleStage.First2000First stage after creation
GrainLifecycleStage.SetupState4000State setup
GrainLifecycleStage.Activate6000Activation
GrainLifecycleStage.Lastint.MaxValueFinal 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 obj will make every assignable message type interleavable — always register specific DU or message types.


Per-grain Orleans attributes (C# CodeGen path)

Section titled “Per-grain Orleans attributes (C# CodeGen path)”

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.

ConceptOrleans 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 grainCollectionAge in siloConfig { }; there is no per-grain grain { } 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