Skip to content

Unified Serialization

ActualLab provides a unified serialization infrastructure used throughout Fusion, RPC, and the Operations Framework. This guide covers the core serialization APIs, type-decorated serialization for polymorphism, lazy serialization wrappers, and how to configure serialization globally.

Required Package

PackagePurpose
ActualLab.CoreCore serialization infrastructure

Overview

The serialization infrastructure provides:

  • Multiple format support: System.Text.Json, Newtonsoft.Json, MemoryPack, and MessagePack
  • Type-decorated serialization: Preserves type information for polymorphic deserialization
  • Lazy serialization wrappers: Defer serialization/deserialization until needed
  • Multi-format types: Single type that works with any serializer (for cross-format scenarios)

Core Abstractions

IByteSerializer

Binary serializers implement IByteSerializer:

cs
public interface IByteSerializer
{
    object? Read(ReadOnlyMemory<byte> data, Type type, out int readLength);
    void Write(IBufferWriter<byte> bufferWriter, object? value, Type type);
}

ITextSerializer

Text (JSON) serializers implement ITextSerializer:

cs
public interface ITextSerializer
{
    object? Read(string data, Type type);
    string Write(object? value, Type type);
}

Both interfaces have generic typed versions (IByteSerializer<T>, ITextSerializer<T>) for improved performance.

Built-in Serializers

Default Serializers

Global default serializers are accessible via static properties:

cs
// Binary serializer - MemoryPack on .NET 6+, MessagePack on .NET Standard
IByteSerializer binary = ByteSerializer.Default;

// Text serializer - System.Text.Json
ITextSerializer text = TextSerializer.Default;

You can change defaults globally:

cs
// Use Newtonsoft.Json as default text serializer
TextSerializer.Default = NewtonsoftJsonSerializer.Default;

Serializer Classes

ClassFormatTypeNotes
MemoryPackByteSerializerMemoryPackBinaryDefault binary on .NET 6+
MessagePackByteSerializerMessagePackBinaryDefault binary on .NET Standard
SystemJsonSerializerSystem.Text.JsonTextDefault text serializer
NewtonsoftJsonSerializerNewtonsoft.JsonTextFlexible JSON with polymorphism support

Each has a static Default property and options for customization:

cs
// Access default instances
var memPack = MemoryPackByteSerializer.Default;
var msgPack = MessagePackByteSerializer.Default;
var sysJson = SystemJsonSerializer.Default;
var newtonsoft = NewtonsoftJsonSerializer.Default;

// Create with custom options
var prettyJson = new SystemJsonSerializer(new JsonSerializerOptions { WriteIndented = true });

Type-Decorated Serialization

Type-decorated serializers embed type information in the serialized output, enabling polymorphic deserialization when the exact type isn't known at compile time.

TypeDecoratingTextSerializer

Embeds type info as a comment prefix in JSON:

cs
var serializer = TypeDecoratingTextSerializer.Default;

// Serialize
string json = serializer.Write(value, typeof(object));
// Output: /* @type MyNamespace.MyClass, MyAssembly */ {"name":"test"}

// Deserialize - type is recovered from the prefix
object? result = serializer.Read(json, typeof(object));
// result is MyClass

Format details:

  • Prefix: /* @type TypeName */
  • When serialized type equals declared type, uses shorthand: /* @type . */
  • Assembly versions are stripped for forward compatibility

TypeDecoratingByteSerializer

Prepends TypeRef to binary data:

cs
var serializer = TypeDecoratingByteSerializer.Default;

// Type info is binary-encoded before the payload
var buffer = serializer.Write(value, typeof(object));
object? result = serializer.Read(buffer.WrittenMemory, typeof(object), out _);

Default Type-Decorated Instances

Each serializer class provides a type-decorated variant:

cs
var sysJsonTD = SystemJsonSerializer.DefaultTypeDecorating;
var newtonsoftTD = NewtonsoftJsonSerializer.DefaultTypeDecorating;
var memPackTD = MemoryPackByteSerializer.DefaultTypeDecorating;
var msgPackTD = MessagePackByteSerializer.DefaultTypeDecorating;

Serialized<T> Wrappers

Lazy serialization wrappers defer (de)serialization until accessed. This is useful when:

  • Data might not be needed (avoids unnecessary deserialization)
  • Data needs to pass through multiple serialization boundaries
  • Working with heterogeneous storage formats

ByteSerialized<T>

Wraps a value that serializes to/from bytes:

cs
[DataContract, MemoryPackable, MessagePackObject]
public partial record MyMessage(
    [property: DataMember, MemoryPackOrder(0), Key(0)] ByteSerialized<MyPayload> Payload);
cs
// Create with a value - serialization is deferred
var wrapper1 = ByteSerialized.New(myPayload);

// Or create from serialized data - deserialization is deferred
var wrapper2 = ByteSerialized.New<MyPayload>(bytes);

// Access triggers (de)serialization
MyPayload value = wrapper1.Value;
ReadOnlyMemory<byte> data = wrapper1.Data;

TextSerialized<T>

Same pattern for text/JSON:

cs
var wrapper = TextSerialized.New(myObject);
string json = wrapper.Data;    // Serialize on access
MyObject value = wrapper.Value; // Deserialize on access

Specialized Variants

TypeSerializer
MemoryPackSerialized<T>MemoryPack binary
MessagePackSerialized<T>MessagePack binary
SystemJsonSerialized<T>System.Text.Json
NewtonsoftJsonSerialized<T>Newtonsoft.Json

Multi-Format Types: UniSerialized<T>

UniSerialized<T> works with all four serialization formats. The format is determined by which property the serializer accesses:

cs
[DataContract, MemoryPackable, MessagePackObject]
public readonly partial struct UniSerialized<T>
{
    [JsonIgnore, IgnoreDataMember, MemoryPackIgnore, IgnoreMember]
    public T Value { get; init; }

    // System.Text.Json uses this
    [JsonInclude]
    public string Json { get; init; }

    // Newtonsoft.Json uses this (via JsonProperty attribute)
    public string NewtonsoftJson { get; init; }

    // MemoryPack uses this
    [MemoryPackOrder(0)]
    public byte[] MemoryPack { get; init; }

    // MessagePack uses this
    [DataMember, Key(0)]
    public MessagePackData MessagePack { get; init; }
}

This design allows the same data structure to work correctly regardless of which serializer is used on each side of the communication.

TypeDecoratingUniSerialized<T>

Adds type decoration for polymorphism:

cs
// Used by PropertyBag for storing heterogeneous values
var item = TypeDecoratingUniSerialized.New<object>(myValue);

PropertyBag Serialization

PropertyBag stores key-value pairs where values can be any type. It uses TypeDecoratingUniSerialized<object> to preserve type information:

cs
// Internal structure of PropertyBagItem
[DataContract, MemoryPackable, MessagePackObject]
public partial record struct PropertyBagItem(
    [property: DataMember] string Key,
    [property: DataMember] TypeDecoratingUniSerialized<object> Serialized);

This allows:

  • Heterogeneous value types in the same bag
  • Cross-format serialization (JSON to binary and back)
  • Type preservation across serialization boundaries

Operations Framework

For details on how PropertyBag is used in the Operations Framework, see Operations Framework Serialization.

Annotating Types for Serialization

For types to work with all serializers, apply multiple attributes:

cs
[DataContract, MemoryPackable(GenerateType.VersionTolerant), MessagePackObject(true)]
public partial record MyRecord(
    [property: DataMember(Order = 0), MemoryPackOrder(0)] string Name,
    [property: DataMember(Order = 1), MemoryPackOrder(1)] int Value)
{
    [System.Text.Json.Serialization.JsonConstructor, MemoryPackConstructor, SerializationConstructor]
    public MyRecord() : this("", 0) { }
}
AttributePurpose
[DataContract]MessagePack and Newtonsoft.Json
[MemoryPackable(...)]MemoryPack source generator
[MessagePackObject(true)]MessagePack with string keys
[DataMember(Order = n)]Property order for MessagePack/DataContract
[MemoryPackOrder(n)]Property order for MemoryPack
[JsonConstructor]System.Text.Json constructor
[MemoryPackConstructor]MemoryPack constructor
[SerializationConstructor]MessagePack constructor

SerializerKind Enum

For code that needs to select serializers dynamically:

cs
// Get default serializer for a kind
IByteSerializer serializer = SerializerKind.MemoryPack.GetDefaultSerializer();

// Get type-decorated variant
IByteSerializer tdSerializer = SerializerKind.MemoryPack.GetDefaultTypeDecoratingSerializer();

Configuration

Global Default Changes

cs
// Change default binary serializer
ByteSerializer.Default = MessagePackByteSerializer.Default;

// Change default text serializer
TextSerializer.Default = NewtonsoftJsonSerializer.Default;

// Change System.Text.Json options
SystemJsonSerializer.DefaultOptions = new JsonSerializerOptions {
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
};

// Change Newtonsoft.Json settings
NewtonsoftJsonSerializer.DefaultSettings = new JsonSerializerSettings {
    TypeNameHandling = TypeNameHandling.Auto,
    NullValueHandling = NullValueHandling.Ignore,
};

Best Practices

  1. Use consistent annotations: Apply all serialization attributes to types that may cross serialization boundaries
  2. Provide parameterless constructors: Most serializers require them; use the appropriate constructor attributes
  3. Test cross-format scenarios: If data might be serialized with one format and deserialized with another, test explicitly
  4. Use TypeDecorating for polymorphism: When storing/sending base types that may contain derived instances
  5. Consider version tolerance: Use MemoryPackable(GenerateType.VersionTolerant) for types that may evolve