Serialization in .NET
When to Use This Skill
Use this skill when:
- Choosing a serialization format for APIs, messaging, or persistence
- Migrating from Newtonsoft.Json to System.Text.Json
- Implementing AOT-compatible serialization
- Designing wire formats for distributed systems
- Optimizing serialization performance
Schema-Based vs Reflection-Based
| Aspect | Schema-Based | Reflection-Based |
|---|
| Examples | Protobuf, MessagePack, System.Text.Json (source gen) | Newtonsoft.Json, BinaryFormatter |
| Type info in payload | No (external schema) | Yes (type names embedded) |
| Versioning | Explicit field numbers/names | Implicit (type structure) |
| Performance | Fast (no reflection) | Slower (runtime reflection) |
| AOT compatible | Yes | No |
| Wire compatibility | Excellent | Poor |
Recommendation: Use schema-based serialization for anything that crosses process boundaries.
| Use Case | Recommended Format | Why |
|---|
| REST APIs | System.Text.Json (source gen) | Standard, AOT-compatible |
| gRPC | Protocol Buffers | Native format, excellent versioning |
| Actor messaging | MessagePack or Protobuf | Compact, fast, version-safe |
| Event sourcing | Protobuf or MessagePack | Must handle old events forever |
| Caching | MessagePack | Compact, fast |
| Configuration | JSON (System.Text.Json) | Human-readable |
| Logging | JSON (System.Text.Json) | Structured, parseable |
| Format | Problem |
|---|
| BinaryFormatter | Security vulnerabilities, deprecated, never use |
| Newtonsoft.Json default | Type names in payload break on rename |
| DataContractSerializer | Complex, poor versioning |
| XML | Verbose, slow, complex |
System.Text.Json with Source Generators
For JSON serialization, use System.Text.Json with source generators for AOT compatibility and performance.
Setup
csharp
1// Define a JsonSerializerContext with all your types
2[JsonSerializable(typeof(Order))]
3[JsonSerializable(typeof(OrderItem))]
4[JsonSerializable(typeof(Customer))]
5[JsonSerializable(typeof(List<Order>))]
6[JsonSourceGenerationOptions(
7 PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
8 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
9public partial class AppJsonContext : JsonSerializerContext { }
Usage
csharp
1// Serialize with context
2var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
3
4// Deserialize with context
5var order = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);
6
7// Configure in ASP.NET Core
8builder.Services.ConfigureHttpJsonOptions(options =>
9{
10 options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
11});
Benefits
- No reflection at runtime - All type info generated at compile time
- AOT compatible - Works with Native AOT publishing
- Faster - No runtime type analysis
- Trim-safe - Linker knows exactly what's needed
Protocol Buffers (Protobuf)
Best for: Actor systems, gRPC, event sourcing, any long-lived wire format.
Setup
bash
1dotnet add package Google.Protobuf
2dotnet add package Grpc.Tools
Define Schema
protobuf
1// orders.proto
2syntax = "proto3";
3
4message Order {
5 string id = 1;
6 string customer_id = 2;
7 repeated OrderItem items = 3;
8 int64 created_at_ticks = 4;
9
10 // Adding new fields is always safe
11 string notes = 5; // Added in v2 - old readers ignore it
12}
13
14message OrderItem {
15 string product_id = 1;
16 int32 quantity = 2;
17 int64 price_cents = 3;
18}
Versioning Rules
protobuf
1// SAFE: Add new fields with new numbers
2message Order {
3 string id = 1;
4 string customer_id = 2;
5 string shipping_address = 5; // NEW - safe
6}
7
8// SAFE: Remove fields (old readers ignore unknown, new readers use default)
9// Just stop using the field, keep the number reserved
10message Order {
11 string id = 1;
12 // customer_id removed, but field 2 is reserved
13 reserved 2;
14}
15
16// UNSAFE: Change field types
17message Order {
18 int32 id = 1; // Was: string - BREAKS!
19}
20
21// UNSAFE: Reuse field numbers
22message Order {
23 reserved 2;
24 string new_field = 2; // Reusing 2 - BREAKS!
25}
MessagePack
Best for: High-performance scenarios, compact payloads, actor messaging.
Setup
bash
1dotnet add package MessagePack
2dotnet add package MessagePack.Annotations
Usage with Contracts
csharp
1[MessagePackObject]
2public sealed class Order
3{
4 [Key(0)]
5 public required string Id { get; init; }
6
7 [Key(1)]
8 public required string CustomerId { get; init; }
9
10 [Key(2)]
11 public required IReadOnlyList<OrderItem> Items { get; init; }
12
13 [Key(3)]
14 public required DateTimeOffset CreatedAt { get; init; }
15
16 // New field - old readers skip unknown keys
17 [Key(4)]
18 public string? Notes { get; init; }
19}
20
21// Serialize
22var bytes = MessagePackSerializer.Serialize(order);
23
24// Deserialize
25var order = MessagePackSerializer.Deserialize<Order>(bytes);
AOT-Compatible Setup
csharp
1// Use source generator for AOT
2[MessagePackObject]
3public partial class Order { } // partial enables source gen
4
5// Configure resolver
6var options = MessagePackSerializerOptions.Standard
7 .WithResolver(CompositeResolver.Create(
8 GeneratedResolver.Instance, // Generated
9 StandardResolver.Instance));
Migrating from Newtonsoft.Json
Common Issues
| Newtonsoft | System.Text.Json | Fix |
|---|
$type in JSON | Not supported by default | Use discriminators or custom converters |
JsonProperty | JsonPropertyName | Different attribute |
DefaultValueHandling | DefaultIgnoreCondition | Different API |
NullValueHandling | DefaultIgnoreCondition | Different API |
| Private setters | Requires [JsonInclude] | Explicit opt-in |
| Polymorphism | [JsonDerivedType] (.NET 7+) | Explicit discriminators |
Migration Pattern
csharp
1// Newtonsoft (reflection-based)
2public class Order
3{
4 [JsonProperty("order_id")]
5 public string Id { get; set; }
6
7 [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
8 public string? Notes { get; set; }
9}
10
11// System.Text.Json (source-gen compatible)
12public sealed record Order(
13 [property: JsonPropertyName("order_id")]
14 string Id,
15
16 string? Notes // Null handling via JsonSerializerOptions
17);
18
19[JsonSerializable(typeof(Order))]
20[JsonSourceGenerationOptions(
21 PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
22 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
23public partial class OrderJsonContext : JsonSerializerContext { }
Polymorphism with Discriminators
csharp
1// .NET 7+ polymorphism
2[JsonDerivedType(typeof(CreditCardPayment), "credit_card")]
3[JsonDerivedType(typeof(BankTransferPayment), "bank_transfer")]
4public abstract record Payment(decimal Amount);
5
6public sealed record CreditCardPayment(decimal Amount, string Last4) : Payment(Amount);
7public sealed record BankTransferPayment(decimal Amount, string AccountNumber) : Payment(Amount);
8
9// Serializes as:
10// { "$type": "credit_card", "amount": 100, "last4": "1234" }
Wire Compatibility Patterns
Tolerant Reader
Old code must safely ignore unknown fields:
csharp
1// Protobuf/MessagePack: Automatic - unknown fields skipped
2// System.Text.Json: Configure to allow
3var options = new JsonSerializerOptions
4{
5 UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip
6};
Introduce Read Before Write
Deploy deserializers before serializers for new formats:
csharp
1// Phase 1: Add deserializer (deployed everywhere)
2public Order Deserialize(byte[] data, string manifest) => manifest switch
3{
4 "Order.V1" => DeserializeV1(data),
5 "Order.V2" => DeserializeV2(data), // NEW - can read V2
6 _ => throw new NotSupportedException()
7};
8
9// Phase 2: Enable serializer (next release, after V1 deployed everywhere)
10public (byte[] data, string manifest) Serialize(Order order) =>
11 _useV2Format
12 ? (SerializeV2(order), "Order.V2")
13 : (SerializeV1(order), "Order.V1");
Never Embed Type Names
csharp
1// BAD: Type name in payload - renaming class breaks wire format
2{
3 "$type": "MyApp.Order, MyApp.Core",
4 "id": "123"
5}
6
7// GOOD: Explicit discriminator - refactoring safe
8{
9 "type": "order",
10 "id": "123"
11}
Approximate throughput (higher is better):
| Format | Serialize | Deserialize | Size |
|---|
| MessagePack | ★★★★★ | ★★★★★ | ★★★★★ |
| Protobuf | ★★★★★ | ★★★★★ | ★★★★★ |
| System.Text.Json (source gen) | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
| System.Text.Json (reflection) | ★★★☆☆ | ★★★☆☆ | ★★★☆☆ |
| Newtonsoft.Json | ★★☆☆☆ | ★★☆☆☆ | ★★★☆☆ |
For hot paths, prefer MessagePack or Protobuf.
Akka.NET Serialization
For Akka.NET actor systems, use schema-based serialization:
hocon
1akka {
2 actor {
3 serializers {
4 messagepack = "Akka.Serialization.MessagePackSerializer, Akka.Serialization.MessagePack"
5 }
6 serialization-bindings {
7 "MyApp.Messages.IMessage, MyApp" = messagepack
8 }
9 }
10}
See Akka.NET Serialization Docs.
Best Practices
DO
csharp
1// Use source generators for System.Text.Json
2[JsonSerializable(typeof(Order))]
3public partial class AppJsonContext : JsonSerializerContext { }
4
5// Use explicit field numbers/keys
6[MessagePackObject]
7public class Order
8{
9 [Key(0)] public string Id { get; init; }
10}
11
12// Use records for immutable message types
13public sealed record OrderCreated(OrderId Id, CustomerId CustomerId);
DON'T
csharp
1// Don't use BinaryFormatter (ever)
2var formatter = new BinaryFormatter(); // Security risk!
3
4// Don't embed type names in wire format
5settings.TypeNameHandling = TypeNameHandling.All; // Breaks on rename!
6
7// Don't use reflection serialization for hot paths
8JsonConvert.SerializeObject(order); // Slow, not AOT-compatible
Resources