Serialization in Orleans
There are broadly two kinds of serialization used in Orleans:
- Grain call serialization: Used to serialize objects passed to and from grains.
- Grain storage serialization: Used to serialize objects to and from storage systems.
Most of this article focuses on grain call serialization via the serialization framework included in Orleans. The Grain storage serializers section discusses grain storage serialization.
For the generated-code pipeline, runtime manifests, wire identity, and codec dispatch internals, see Serialization and code generation internals.
Use Orleans serialization
Section titled “Use Orleans serialization”Orleans includes an advanced and extensible serialization framework referred to as Orleans.Serialization. The serialization framework included in Orleans is designed to meet the following goals:
- High-performance: The serializer is designed and optimized for performance. More details are available in this presentation.
- High-fidelity: The serializer faithfully represents most of .NET’s type system, including support for generics, polymorphism, inheritance hierarchies, object identity, and cyclic graphs. Pointers aren’t supported since they aren’t portable across processes.
- Flexibility: You can customize the serializer to support third-party libraries by creating surrogates or delegating to external serialization libraries such as System.Text.Json, Newtonsoft.Json, MessagePack, and Google.Protobuf.
- Version-tolerance: The serializer allows application types to evolve over time, supporting:
- Adding and removing members
- Subclassing
- Numeric widening and narrowing (e.g.,
intto/fromlong,floatto/fromdouble) - Renaming types
High-fidelity representation of types is fairly uncommon for serializers, so some points warrant further explanation:
-
Dynamic types and arbitrary polymorphism: Orleans doesn’t enforce restrictions on the types passed in grain calls and maintains the dynamic nature of the actual data type. This means, for example, if a method in a grain interface is declared to accept IDictionary, but at runtime the sender passes a SortedDictionary<T, U>, the receiver indeed gets a
SortedDictionary(even though the “static contract”/grain interface didn’t specify this behavior). -
Maintaining object identity: If the same object is passed multiple times in the arguments of a grain call or is indirectly pointed to more than once from the arguments, Orleans serializes it only once. On the receiver side, Orleans restores all references correctly so that two pointers to the same object still point to the same object after deserialization. Preserving object identity is important in scenarios like the following: Imagine grain A sends a dictionary with 100 entries to grain B, and 10 keys in the dictionary point to the same object,
obj, on A’s side. Without preserving object identity, B would receive a dictionary of 100 entries with those 10 keys pointing to 10 different clones ofobj. With object identity preserved, the dictionary on B’s side looks exactly like on A’s side, with those 10 keys pointing to a single objectobj. Note that because default string hash code implementations in .NET are randomized per process, the ordering of values in dictionaries and hash sets (for example) might not be preserved.
To support version tolerance, the serializer requires you to be explicit about which types and members are serialized. We’ve tried to make this as painless as possible. Mark all serializable types with Orleans.GenerateSerializerAttribute to instruct Orleans to generate serializer code for your type. Once you’ve done this, you can use the included code fix to add the required Orleans.IdAttribute to the serializable members on your types, as demonstrated here:

Here is an example of a serializable type in Orleans, demonstrating how to apply the attributes.
[GenerateSerializer]public class Employee{ [Id(0)] public required string Name { get; set; }}Orleans supports inheritance and serializes the individual layers in the hierarchy separately, allowing them to have distinct member IDs.
[GenerateSerializer]public class Publication{ [Id(0)] public required string Title { get; set; }}
[GenerateSerializer]public class Book : Publication{ [Id(0)] public required string ISBN { get; set; }}In the preceding code, note that both Publication and Book have members with [Id(0)], even though Book derives from Publication. This is the recommended practice in Orleans because member identifiers are scoped to the inheritance level, not the type as a whole. You can add and remove members from Publication and Book independently, but you cannot insert a new base class into the hierarchy once the application is deployed without special consideration.
Orleans also supports serializing types with internal, private, and readonly members, such as in this example type:
[GenerateSerializer]public struct MyCustomStruct{ public MyCustomStruct(int intProperty, int intField) { IntProperty = intProperty; _intField = intField; }
[Id(0)] public int IntProperty { get; }
[Id(1)] private readonly int _intField; public int GetIntField() => _intField;
public override string ToString() => $"{nameof(_intField)}: {_intField}, {nameof(IntProperty)}: {IntProperty}";}By default, Orleans serializes your type by encoding its full name. You can override this by adding an Orleans.AliasAttribute. Doing so results in your type being serialized using a name resilient to renaming the underlying class or moving it between assemblies. Type aliases are globally scoped, and you cannot have two aliases with the same value in an application. For generic types, the alias value must include the number of generic parameters preceded by a backtick; for example, MyGenericType<T, U> could have the alias [Alias(“mytype`2”)].
Serializing record types
Section titled “Serializing record types”Orleans assigns implicit IDs to record primary-constructor members by their parameter position. Preserve the parameter order of an already deployed type to maintain compatibility during rolling upgrades and with serialized instances in storage and streams. Primary-constructor members and ordinary members occupy separate sections of the serialized representation, so members defined in the body can reuse the same numeric IDs.
[GenerateSerializer]public record MyRecord(string A, string B){ // ID 0 won't clash with A in primary constructor as they don't share identities [Id(0)] public required string C { get; init; }}Set IncludePrimaryConstructorParameters to false to select serializable members using explicit IdAttribute annotations.
Generating for record structs in referenced assemblies
Section titled “Generating for record structs in referenced assemblies”When an SDK project generates serialization code for a referenced assembly using GenerateCodeForDeclaringAssemblyAttribute, Orleans reads the type’s compiled metadata. For a record struct with custom accessors, make primary-constructor membership explicit in the assembly which declares the type:
[GenerateSerializer(IncludePrimaryConstructorParameters = true)][method: ActivatorUtilitiesConstructor]public readonly record struct BinaryRecord(byte[] Value){ [NonSerialized] private readonly byte[] _storage = Value;
public byte[] Value { get => _storage; init => _storage = value; }}IncludePrimaryConstructorParameters = true enables the primary-member section. [method: ActivatorUtilitiesConstructor] applies ActivatorUtilitiesConstructorAttribute to the primary constructor, selecting its parameters from metadata even when the type has other constructors. Annotate exactly one constructor. Orleans matches its parameters to properties by name and type and restores the custom init properties through their setters. These annotations also apply to mutable record struct types.
Compile the contracts assembly with a compiler which emits the method-target attribute on the primary constructor. For compiler warning CS0657 on [method: ActivatorUtilitiesConstructor], use a current .NET SDK or apply [ActivatorUtilitiesConstructor] directly to an explicitly declared constructor with the same parameter names, types, and IDs.
For ORLEANS0106 reporting “no field ids were assigned to any candidate serializable members” on an imported custom-accessor record struct, add both annotations to the declaring type and rebuild the contracts assembly. They provide the constructor identity and member inclusion information used by the generator.
Keep the existing parameter order and any parameter-level [Id(n)] annotations when adding this metadata. This preserves the primary-member section and its field IDs, matching serialization generated from the record’s source. A property-level [Id(n)] selects the ordinary-member section; choosing that layout for a deployed primary-constructor member changes its wire identity even when the numeric ID is unchanged.
MessagePack serialization
Section titled “MessagePack serialization”You can use MessagePack as an external serializer for Orleans. MessagePack is a high-performance binary serialization format that produces smaller payloads than JSON while maintaining fast serialization and deserialization speeds.
When to use MessagePack
Section titled “When to use MessagePack”Consider using MessagePack when:
- You need interoperability with non-.NET systems that support MessagePack
- You have types already annotated with MessagePack attributes (
[MessagePackObject],[Key]) - You want smaller payload sizes compared to JSON-based serializers
- You need a standardized binary format
For most Orleans applications, the default Orleans serializer is recommended because it provides higher fidelity (.NET type system support), object identity preservation, and automatic serializer generation.
Install the MessagePack serializer package
Section titled “Install the MessagePack serializer package”Add the MessagePack serializer package to your project:
dotnet add package Microsoft.Orleans.Serialization.MessagePackConfigure MessagePack serialization
Section titled “Configure MessagePack serialization”To configure MessagePack serialization, use the AddMessagePackSerializer extension method:
public static void ConfigureMessagePackBasic(string[] args){ var builder = Host.CreateApplicationBuilder(args);
builder.UseOrleans(siloBuilder => { siloBuilder.UseLocalhostClustering(); siloBuilder.Services.AddSerializer(serializerBuilder => serializerBuilder.AddMessagePackSerializer( isSerializable: type => type.Namespace?.StartsWith("MyApp.Messages", StringComparison.Ordinal) == true, isCopyable: type => false, messagePackSerializerOptions: null )); });}The isSerializable delegate controls which types are handled by the MessagePack serializer. Types not matching this predicate fall back to the default Orleans serializer.
MessagePackCodecOptions
Section titled “MessagePackCodecOptions”You can configure the MessagePack serializer using MessagePackCodecOptions:
| Property | Type | Default | Description |
|---|---|---|---|
SerializerOptions | MessagePackSerializerOptions | MessagePackSerializerOptions.Standard | The MessagePack serializer options to use for serialization. |
AllowDataContractAttributes | bool | false | When true, allows types marked with [DataContract] to be serialized using MessagePack. |
IsSerializableType | Func<Type, bool?> | null | A delegate to determine if a type should be serialized by MessagePack. |
IsCopyableType | Func<Type, bool?> | null | A delegate to determine if a type should be copied by MessagePack. |
Example: Configure with options
Section titled “Example: Configure with options”public static void ConfigureMessagePackWithOptions(string[] args){ var builder = Host.CreateApplicationBuilder(args);
builder.UseOrleans(siloBuilder => { siloBuilder.UseLocalhostClustering(); siloBuilder.Services.AddSerializer(serializerBuilder => serializerBuilder.AddMessagePackSerializer( isSerializable: type => type.GetCustomAttribute<MessagePackObjectAttribute>() != null, isCopyable: type => false, configureOptions: options => options.Configure(opts => { opts.SerializerOptions = MessagePackSerializerOptions.Standard .WithCompression(MessagePackCompression.Lz4BlockArray); opts.AllowDataContractAttributes = true; }) )); });}Define MessagePack types
Section titled “Define MessagePack types”Types serialized by MessagePack should use MessagePack attributes:
[MessagePackObject]public class OrderMessage{ [Key(0)] public required string OrderId { get; set; }
[Key(1)] public decimal Amount { get; set; }
[Key(2)] public DateTime CreatedAt { get; set; }
[Key(3)] public List<string> Items { get; set; } = [];}You can then use these types in grain interfaces:
public interface IOrderGrain : IGrainWithStringKey{ Task<OrderMessage> GetOrder(); Task PlaceOrder(OrderMessage order);}Serializer comparison
Section titled “Serializer comparison”| Feature | Orleans Native | MessagePack | System.Text.Json |
|---|---|---|---|
| Format | Binary | Binary | Text (JSON) |
| .NET type fidelity | Excellent | Good | Limited |
| Object identity | Yes | No | No |
| Payload size | Small | Smallest | Largest |
| Cross-platform | .NET only | Any MessagePack client | Any JSON client |
| Version tolerance | Yes | Yes | Yes |
| Setup required | None | Explicit attributes | Explicit attributes |
Surrogates for serializing foreign types
Section titled “Surrogates for serializing foreign types”Sometimes, you might need to pass types between grains over which you don’t have full control. In these cases, manually converting to and from a custom-defined type in your application code might be impractical. Orleans offers a solution for these situations: surrogate types. Surrogates are serialized in place of their target type and have functionality to convert to and from the target type. Consider the following example of a foreign type and a corresponding surrogate and converter:
// This is the foreign type, which you do not have control over.public struct MyForeignLibraryValueType{ public MyForeignLibraryValueType(int num, string str, DateTimeOffset dto) { Num = num; String = str; DateTimeOffset = dto; }
public int Num { get; } public string String { get; } public DateTimeOffset DateTimeOffset { get; }}
// This is the surrogate which will act as a stand-in for the foreign type.// Surrogates should use plain fields instead of properties for better performance.[GenerateSerializer]public struct MyForeignLibraryValueTypeSurrogate{ [Id(0)] public int Num;
[Id(1)] public string String;
[Id(2)] public DateTimeOffset DateTimeOffset;}
// This is a converter that converts between the surrogate and the foreign type.[RegisterConverter]public sealed class MyForeignLibraryValueTypeSurrogateConverter : IConverter<MyForeignLibraryValueType, MyForeignLibraryValueTypeSurrogate>{ public MyForeignLibraryValueType ConvertFromSurrogate( in MyForeignLibraryValueTypeSurrogate surrogate) => new(surrogate.Num, surrogate.String, surrogate.DateTimeOffset);
public MyForeignLibraryValueTypeSurrogate ConvertToSurrogate( in MyForeignLibraryValueType value) => new() { Num = value.Num, String = value.String, DateTimeOffset = value.DateTimeOffset };}In the preceding code:
MyForeignLibraryValueTypeis a type outside your control, defined in a consuming library.MyForeignLibraryValueTypeSurrogateis a surrogate type mapping toMyForeignLibraryValueType.- RegisterConverterAttribute specifies that
MyForeignLibraryValueTypeSurrogateConverteracts as a converter to map between the two types. The class implements the IConverter<T, U> interface.
Orleans supports serialization of types in type hierarchies (types deriving from other types). If a foreign type might appear in a type hierarchy (e.g., as the base class for one of your own types), you must additionally implement the Orleans.IPopulator<T, U> interface. Consider the following example:
// The foreign type is not sealed, allowing other types to inherit from it.public class MyForeignLibraryType{ public MyForeignLibraryType() { }
public MyForeignLibraryType(int num, string? str, DateTimeOffset dto) { Num = num; String = str; DateTimeOffset = dto; }
public int Num { get; set; } public string? String { get; set; } public DateTimeOffset DateTimeOffset { get; set; }}
// The surrogate is defined as it was in the previous example.[GenerateSerializer]public struct MyForeignLibraryTypeSurrogate{ [Id(0)] public int Num;
[Id(1)] public string? String;
[Id(2)] public DateTimeOffset DateTimeOffset;}
// Implement the IConverter and IPopulator interfaces on the converter.[RegisterConverter]public sealed class MyForeignLibraryTypeSurrogateConverter : IConverter<MyForeignLibraryType, MyForeignLibraryTypeSurrogate>, IPopulator<MyForeignLibraryType, MyForeignLibraryTypeSurrogate>{ public MyForeignLibraryType ConvertFromSurrogate( in MyForeignLibraryTypeSurrogate surrogate) => new(surrogate.Num, surrogate.String, surrogate.DateTimeOffset);
public MyForeignLibraryTypeSurrogate ConvertToSurrogate( in MyForeignLibraryType value) => new() { Num = value.Num, String = value.String, DateTimeOffset = value.DateTimeOffset };
public void Populate( in MyForeignLibraryTypeSurrogate surrogate, MyForeignLibraryType value) { value.Num = surrogate.Num; value.String = surrogate.String; value.DateTimeOffset = surrogate.DateTimeOffset; }}
// Application types can inherit from the foreign type, assuming they're not sealed// since Orleans knows how to serialize it.[GenerateSerializer]public sealed class DerivedFromMyForeignLibraryType : MyForeignLibraryType{ public DerivedFromMyForeignLibraryType() { }
public DerivedFromMyForeignLibraryType( int intValue, int num, string? str, DateTimeOffset dto) : base(num, str, dto) { IntValue = intValue; }
[Id(0)] public int IntValue { get; set; }}Versioning rules
Section titled “Versioning rules”Version tolerance is supported provided you follow a set of rules when modifying types. If you’re familiar with systems like Google Protocol Buffers (Protobuf), these rules will be familiar.
Compound types (class & struct)
Section titled “Compound types (class & struct)”- Inheritance is supported, but modifying the inheritance hierarchy of an object isn’t supported. You cannot add, change, or remove the base class of a class.
- With the exception of some numeric types described in the Numerics section below, you cannot change field types.
- You can add or remove fields at any point in an inheritance hierarchy.
- You cannot change field IDs.
- Field IDs must be unique for each level in a type hierarchy but can be reused between base classes and subclasses. For example, a
Baseclass can declare a field with ID0, and aSub : Baseclass can declare a different field with the same ID,0.
Numerics
Section titled “Numerics”- You cannot change the signedness of a numeric field.
- Conversions between
int&uintare invalid.
- Conversions between
- You can change the width of a numeric field.
- E.g., conversions from
inttolongorulongtoushortare supported. - Conversions narrowing the width throw an exception if the field’s runtime value causes an overflow.
- Conversion from
ulongtoushortis only supported if the runtime value is less thanushort.MaxValue. - Conversions from
doubletofloatare only supported if the runtime value is betweenfloat.MinValueandfloat.MaxValue. - Similarly for
decimal, which has a narrower range than bothdoubleandfloat.
- E.g., conversions from
Copiers
Section titled “Copiers”Orleans promotes safety by default, including safety from some classes of concurrency bugs. In particular, Orleans immediately copies objects passed in grain calls by default. Orleans.Serialization facilitates this copying. When you apply Orleans.GenerateSerializerAttribute to a type, Orleans also generates copiers for that type. Orleans avoids copying types or individual members marked with ImmutableAttribute. For more details, see Serialization of immutable types in Orleans.
Serialization best practices
Section titled “Serialization best practices”-
✅ Do give your types aliases using the
[Alias("my-type")]attribute. Types with aliases can be renamed without breaking compatibility. -
❌ Do not change a
recordto a regularclassor vice-versa. Records and classes aren’t represented identically since records have primary constructor members in addition to regular members; therefore, the two aren’t interchangeable. -
❌ Do not add new types to an existing type hierarchy for a serializable type. You must not add a new base class to an existing type. You can safely add a new subclass to an existing type.
-
✅ Do replace usages of SerializableAttribute with GenerateSerializerAttribute and corresponding IdAttribute declarations.
-
✅ Do start all member IDs at zero for each type. IDs in a subclass and its base class can safely overlap. Both properties in the following example have IDs equal to
0.[GenerateSerializer]public class MyBaseClass{[Id(0)]public int MyBaseInt { get; set; }}[GenerateSerializer]public sealed class MySubClass : MyBaseClass{[Id(0)]public int MySubInt { get; set; }} -
✅ Do widen numeric member types as needed. You can widen
sbytetoshorttointtolong.- You can narrow numeric member types, but it results in a runtime exception if observed values cannot be represented correctly by the narrowed type. For example,
int.MaxValuecannot be represented by ashortfield, so narrowing anintfield toshortcan result in a runtime exception if such a value is encountered.
- You can narrow numeric member types, but it results in a runtime exception if observed values cannot be represented correctly by the narrowed type. For example,
-
❌ Do not change the signedness of a numeric type member. You must not change a member’s type from
uinttointorinttouint, for example.
Grain storage serializers
Section titled “Grain storage serializers”Orleans includes a provider-backed persistence model for grains, accessed via the State property or by injecting one or more IPersistentState<T> values into your grain. The general-purpose IGrainStorageSerializer interface offers a consistent way to customize state serialization for each provider. Supported storage providers implement a pattern involving setting the IStorageProviderSerializerOptions.GrainStorageSerializer property on the provider’s options class, for example:
- DynamoDBStorageOptions.GrainStorageSerializer
- AzureBlobStorageOptions.GrainStorageSerializer
- AzureTableStorageOptions.GrainStorageSerializer
- GrainStorageSerializer
Grain storage serialization currently defaults to Newtonsoft.Json to serialize state. You can replace this by modifying that property at configuration time. The following example demonstrates this using OptionsBuilder<TOptions>:
public static void ConfigureGrainStorageSerializer(ISiloBuilder siloBuilder){ siloBuilder.AddAzureBlobGrainStorage( "MyGrainStorage", (OptionsBuilder<AzureBlobStorageOptions> optionsBuilder) => { optionsBuilder.Configure<MyCustomSerializer>( (options, serializer) => options.GrainStorageSerializer = serializer); });}For more information, see OptionsBuilder API.
