Skip to content

Serialization

FSharpBinaryCodec, JSON fallback, and schema evolution.

Orleans.FSharp offers three serialization modes. Choose based on your needs — you can switch at any time.

ModeCE KeywordSpeedC# Project Needed?Attributes?Best For
F# BinaryuseFSharpBinarySerializationFastNoNonePure F# clusters (recommended)
JSONuseJsonFallbackSerializationGoodNoNonePrototyping, schema flexibility
Orleans Native(default)FastestYes (CodeGen)[<GenerateSerializer>] + [<Id>]Mixed F#/C# clusters

Universal Grain Pattern — auto-registration

Section titled “Universal Grain Pattern — auto-registration”

When you use the universal grain pattern (AddFSharpGrain<State, Command>), F# Binary serialization is registered automatically — you do not need to add useFSharpBinarySerialization to your silo config.

// This is all you need — FSharpBinaryCodec is registered for you
builder.Services.AddFSharpGrain<CounterState, CounterCommand>(counter) |> ignore

The registration is idempotent: calling AddFSharpGrain multiple times for different (State, Command) pairs only registers the codec once.

If you are NOT using the universal pattern (i.e., you are using per-grain C# stubs via Orleans.FSharp.CodeGen), you still need to opt in manually via useFSharpBinarySerialization or useJsonFallbackSerialization.


Binary serialization using FSharp.Reflection — fast, compact, zero boilerplate.

// Your types — plain F#, no attributes
type OrderState =
| Created of orderId: string
| Paid of amount: decimal
| Shipped of trackingNo: string
| Delivered
| Cancelled of reason: string
type OrderCommand = Place of string | Confirm | Ship of string | Cancel of string | GetStatus
// Your grain — clean
let orderGrain = grain {
defaultState (Created "")
handle (fun state cmd -> task { ... })
persist "Default"
}
// Enable in silo config
let config = siloConfig {
useLocalhostClustering
addMemoryStorage "Default"
useFSharpBinarySerialization // ← this is all you need
}

How it works: The FSharpBinaryCodecProvider inspects F# types at runtime via FSharp.Reflection, builds binary reader/writer functions, and caches them per type in a ConcurrentDictionary. First access pays the reflection cost (~1ms); subsequent calls are a dictionary lookup (~20ns).

Supported types:

  • Discriminated unions (any nesting depth)
  • Records
  • Options and ValueOptions
  • Lists, arrays, sets, maps
  • Tuples
  • All .NET primitives (int, string, float, decimal, Guid, DateTime, TimeSpan, etc.)
  • Byte arrays
  • Any nested combination of the above

When to use: Pure F# Orleans clusters. This is the recommended mode for new projects.

JSON serialization via FSharp.SystemTextJson — human-readable, flexible schema evolution.

// Same clean types — no attributes
type CounterState = { Count: int }
type CounterCommand = Increment | Decrement | GetValue
let config = siloConfig {
useLocalhostClustering
addMemoryStorage "Default"
useJsonFallbackSerialization
}

Pros:

  • Human-readable payload (useful for debugging)
  • Name-based schema evolution (add/remove fields by name, not ordinal)
  • Broad ecosystem compatibility

Cons:

  • ~2-5x slower than binary modes
  • Larger payload size (text vs binary)
  • float Infinity, NaN not supported (IEEE 754 limitation of JSON)
  • option optionSome None serializes as null, deserializes as None (known limitation)

When to use: Prototyping, debugging, or when you need flexible schema evolution.

Orleans built-in source-generated serialization — maximum performance, required for C# interop.

// Types need Orleans attributes
[<GenerateSerializer>]
type CounterState =
| [<Id(0u)>] Zero
| [<Id(1u)>] Count of int
[<GenerateSerializer>]
type CounterCommand =
| [<Id(0u)>] Increment
| [<Id(1u)>] Decrement
| [<Id(2u)>] GetValue
// No serialization keyword needed — it's the default
let config = siloConfig {
useLocalhostClustering
addMemoryStorage "Default"
}

Requirements:

  • [<GenerateSerializer>] attribute on every type crossing grain boundaries
  • [<Id(n)>] attribute on every DU case and record field (ordinal position)
  • A C# CodeGen project (Orleans.FSharp.CodeGen) that references your F# types — Orleans Roslyn source generators only work on C# projects
  • A C# grain class per grain definition (inherits Grain, delegates to F# handler)

Why so much boilerplate? Orleans uses Roslyn source generators to produce optimized binary serializers at compile time. Roslyn does not support F# — hence the C# bridge project.

Mixed F#/C# clusters. If your Orleans cluster has both F# silos (using Orleans.FSharp) and C# silos (using standard Orleans), they need to agree on serialization format. Orleans Native is the common format both understand.

F# Silo ←→ C# Silo → Orleans Native (both understand [GenerateSerializer])
F# Silo ←→ F# Silo → F# Binary (recommended) or JSON
F# Silo only → F# Binary (recommended)

Migrating from C# to F#. If you’re gradually moving C# grains to F#, start with Orleans Native for compatibility. Once all silos are F#, switch to F# Binary.

Korat pattern (C# core + F# new grains). Existing C# grains keep Orleans Native serialization. New F# grains can use F# Binary — they have separate state types that don’t cross the C#/F# boundary.

  1. Create a C# class library project:
Terminal window
dotnet new classlib -lang C# -n MyApp.CodeGen
dotnet add MyApp.CodeGen package Microsoft.Orleans.Sdk
dotnet add MyApp.CodeGen reference ../MyApp.Grains/MyApp.Grains.fsproj
  1. Add the assembly attribute:
AssemblyAttributes.cs
using Orleans;
[assembly: GenerateCodeForDeclaringAssembly(typeof(MyApp.Grains.SomeType))]
  1. For each F# grain, create a C# grain class:
CounterGrainImpl.cs
[GenerateSerializer]
public class CounterGrainImpl : Grain, ICounterGrain
{
private readonly GrainDefinition<CounterState, CounterCommand> _def;
// ... constructor, HandleMessage delegation to F# handler
}
  1. Reference the CodeGen project from your Silo project.

You can use multiple modes in the same silo. Orleans resolves serializers in priority order:

  1. Orleans Native (types with [GenerateSerializer]) — highest priority
  2. F# Binary / JSON (fallback for types without attributes)

This means you can use Orleans Native for shared C#/F# types and F# Binary for F#-only types:

let config = siloConfig {
useLocalhostClustering
addMemoryStorage "Default"
useFSharpBinarySerialization // fallback for F#-only types
// Orleans Native types still work via [GenerateSerializer]
}
ScenarioJSONF# BinaryOrleans Native
Add DU case at endWorksWorksWorks (with new [Id])
Remove DU caseOld data with removed case failsSameSame
Add record fieldFails (FSharp.SystemTextJson strict)Fails (ordinal-based)Fails (ordinal-based)
Rename DU caseFails (name-based)Works (ordinal-based)Works (ordinal-based)

For schema migrations across versions, use the StateMigration module.

Measured over 10,000 roundtrips of a typical DU with 5 cases:

ModeTimePayload SizeRelative Speed
Orleans Native~1msSmallest1x (baseline)
F# Binary~2msSmall~2x
JSON~5msLarge (text)~5x

All modes are fast enough for real-world Orleans usage. Grain call network latency (~100-500μs) dominates serialization time.