Migrant
Overview
Migrant is a fast, flexible binary serialization framework for .NET that emphasizes ease of use and version-tolerant deserialization. It can serialize virtually any .NET object graph, including private fields, circular references, and types implementing ISerializable, without requiring explicit attributes or schema definitions. Migrant generates optimized serialization code at runtime using IL emission, delivering performance close to hand-written serialization. It is particularly well-suited for simulation state snapshots, game save systems, and any scenario where you need to persist complex in-memory object graphs with minimal configuration.
Basic Serialization
Serialize and deserialize objects with zero configuration.
using Antmicro.Migrant;
using System.IO;
public class GameState
{
public string PlayerName { get; set; } = string.Empty;
public int Level { get; set; }
public double Health { get; set; }
public List<string> Inventory { get; set; } = new();
public Dictionary<string, int> Skills { get; set; } = new();
}
var serializer = new Serializer();
var state = new GameState
{
PlayerName = "Alice",
Level = 42,
Health = 87.5,
Inventory = { "Sword", "Shield", "Potion" },
Skills = { ["Combat"] = 10, ["Magic"] = 7 }
};
// Serialize to file
using (var fileStream = File.Create("savegame.bin"))
{
serializer.Serialize(state, fileStream);
}
// Deserialize from file
using (var fileStream = File.OpenRead("savegame.bin"))
{
var loaded = serializer.Deserialize<GameState>(fileStream);
}
Serializer Settings
Configure Migrant behavior with the Settings class.
using Antmicro.Migrant;
using Antmicro.Migrant.Customization;
var settings = new Settings(
// Support types implementing ISerializable
supportForISerializable: true,
// Use buffering for better performance
useBuffering: true,
// Disable the stamp check for version tolerance
disableTypeStamping: false,
// Control reference tracking
referencePreservation: ReferencePreservation.Preserve,
// Allow version differences during deserialization
versionTolerance: VersionToleranceLevel.AllowFieldAddition
| VersionToleranceLevel.AllowFieldRemoval
);
var serializer = new Serializer(settings);
Version-Tolerant Deserialization
Migrant handles type evolution gracefully when fields are added or removed.
using Antmicro.Migrant;
using Antmicro.Migrant.Customization;
using System.IO;
// Version 1 of the class
public class ConfigV1
{
public string Name { get; set; } = string.Empty;
public int Timeout { get; set; }
}
// Version 2 adds a new field
public class ConfigV2
{
public string Name { get; set; } = string.Empty;
public int Timeout { get; set; }
public string Region { get; set; } = "us-east-1"; // new field
public bool Enabled { get; set; } = true; // new field
}
var settings = new Settings(
versionTolerance: VersionToleranceLevel.AllowFieldAddition
| VersionToleranceLevel.AllowFieldRemoval
| VersionToleranceLevel.AllowGuidChange);
var serializer = new Serializer(settings);
// Data serialized with V1 can be deserialized as V2
// New fields get their default values
using var stream = new MemoryStream();
serializer.Serialize(new ConfigV1 { Name = "prod", Timeout = 30 }, stream);
stream.Position = 0;
// The new fields (Region, Enabled) will have their default values
var config = serializer.Deserialize<ConfigV2>(stream);
// config.Region == "us-east-1", config.Enabled == true
Circular Reference and Deep Graph Handling
Migrant handles circular references and deep object graphs automatically.
using Antmicro.Migrant;
using System.IO;
public class SimulationNode
{
public string Id { get; set; } = string.Empty;
public double Value { get; set; }
public SimulationNode? Next { get; set; }
public SimulationNode? Previous { get; set; }
public List<SimulationNode> Connections { get; set; } = new();
}
var serializer = new Serializer();
// Build a circular linked structure
var node1 = new SimulationNode { Id = "A", Value = 1.0 };
var node2 = new SimulationNode { Id = "B", Value = 2.0 };
var node3 = new SimulationNode { Id = "C", Value = 3.0 };
node1.Next = node2; node2.Previous = node1;
node2.Next = node3; node3.Previous = node2;
node3.Next = node1; node1.Previous = node3; // circular
node1.Connections.AddRange(new[] { node2, node3 });
node2.Connections.AddRange(new[] { node1, node3 });
// Serialize the circular graph without issues
using var stream = new MemoryStream();
serializer.Serialize(node1, stream);
stream.Position = 0;
var restored = serializer.Deserialize<SimulationNode>(stream);
// restored.Next.Previous == restored (references preserved)
Deep Cloning via Serialization
Use Migrant's serializer for deep cloning complex object graphs.
using Antmicro.Migrant;
using System.IO;
public static class DeepCloner
{
private static readonly Serializer _serializer = new(new Settings(
useBuffering: true,
referencePreservation: ReferencePreservation.Preserve));
public static T Clone<T>(T obj)
{
using var stream = new MemoryStream();
_serializer.Serialize(obj, stream);
stream.Position = 0;
return _serializer.Deserialize<T>(stream);
}
}
// Usage: deep clone a complex state object
var original = new GameState
{
PlayerName = "Bob",
Level = 10,
Inventory = { "Axe", "Torch" }
};
var snapshot = DeepCloner.Clone(original);
// snapshot is a completely independent copy
snapshot.Inventory.Add("Gem");
// original.Inventory still has only "Axe" and "Torch"
Transient and Custom Serialization Hooks
Control serialization behavior with attributes and hooks.
using Antmicro.Migrant;
using System.IO;
public class CachedService
{
public string ServiceName { get; set; } = string.Empty;
public string ConnectionString { get; set; } = string.Empty;
[Transient] // This field is skipped during serialization
private HttpClient? _httpClient;
[PostDeserialization]
private void OnDeserialized()
{
// Rebuild transient state after deserialization
_httpClient = new HttpClient
{
BaseAddress = new Uri(ConnectionString)
};
}
public HttpClient GetClient() =>
_httpClient ?? throw new InvalidOperationException(
"Service not initialized.");
}
var serializer = new Serializer();
var service = new CachedService
{
ServiceName = "OrderAPI",
ConnectionString = "https://api.example.com"
};
using var stream = new MemoryStream();
serializer.Serialize(service, stream);
stream.Position = 0;
var restored = serializer.Deserialize<CachedService>(stream);
// _httpClient is rebuilt by [PostDeserialization] hook
Serializer Comparison
| Feature | Migrant | Hyperion | BinaryFormatter | System.Text.Json |
|---|---|---|---|---|
| Format | Binary | Binary | Binary | JSON (text) |
| Schema required | No | No | No | No |
| Circular references | Yes | Yes | Yes | No |
| Version tolerance | Configurable | Yes | Limited | Limited |
| Performance | Very fast (IL emit) | Fast | Slow | Fast (text) |
| Private fields | Yes | Yes | Yes | No (default) |
| Best for | State snapshots | Akka.NET | Legacy (obsolete) | REST APIs |
Best Practices
- Enable version tolerance flags for any data that persists beyond a single app version: use
AllowFieldAddition | AllowFieldRemovalto ensure saved data remains loadable after class changes. - Use
[Transient]for runtime-only state: mark fields like HTTP clients, caches, and database connections as transient so they are skipped during serialization and rebuilt on deserialization. - Implement
[PostDeserialization]hooks to rebuild transient state: use this attribute on private methods to reinitialize connections, caches, or computed values after deserialization. - Enable
useBufferingfor large serialization operations: buffering improves throughput by reducing the number of I/O operations to the underlying stream. - Use
ReferencePreservation.Preservewhen object identity matters: this ensures that two references to the same object remain the same reference after deserialization, critical for circular graphs. - Create a reusable
Serializerinstance: the Migrant serializer generates IL at runtime for each type it encounters; reusing the instance amortizes this startup cost across multiple operations. - Test version tolerance with actual old serialized data: keep binary snapshots from previous versions in your test suite and verify they deserialize correctly with the current type definitions.
- Avoid using Migrant for cross-process communication: Migrant embeds .NET type information that tightly couples serializer and deserializer; use protobuf-net or Bond for inter-service messaging.
- Use deep cloning sparingly in hot paths: serialization-based cloning is convenient but allocates intermediate buffers; for performance-critical cloning, consider manual copy constructors.
- Monitor serialized data size growth: as types evolve and fields accumulate, serialized payload sizes can grow; periodically benchmark payload sizes and consider migration strategies for bloated types.