How to Serialize an Object Graph Without Losing Shared References
A serializer that writes each member's value inline has two problems with real object graphs. An object reached through two members is written twice and comes back as two separate objects, and a cycle never terminates. Sphere10 Framework's binary serializers solve both by tracking the objects seen during one operation and writing a back-reference when an object appears again.
This article serializes a small graph that contains a shared object and a cycle, then shows how the mechanism works. It covers SerializerBuilder.FactoryAssemble, the role of SerializationContext and ReferenceSerializer<T>, and how to control reference handling for each member.
1. Define what must survive
The sample uses one node type with a name and two links of its own type.
public sealed class GraphNode {
public string Name { get; set; }
public GraphNode Primary { get; set; }
public GraphNode Secondary { get; set; }
}
var root = new GraphNode { Name = "root" };
var shared = new GraphNode { Name = "shared", Primary = root };
root.Primary = shared;
root.Secondary = shared;
The root reaches the second node through both of its links, and the second node links back to the root.
After a round trip, Primary and Secondary must be the same instance, and the second node's Primary must be the new root. Value equality cannot establish either fact, so the sample checks them with ReferenceEquals.
Diagram source
%%{init: {"flowchart": {"nodeSpacing": 20, "rankSpacing": 28}, "themeVariables": {"fontSize": "16px"}}}%%
flowchart TB
R(["root"]) -->|"Primary"| S(["shared"])
R -->|"Secondary"| S
S -->|"Primary"| R
2. Serialize the binary graph in one operation
SerializerBuilder.FactoryAssemble<T>() builds a serializer for a type from its public read-write properties and fields, and assembles serializers for the member types it finds along the way.
var serializer = SerializerBuilder.FactoryAssemble<GraphNode>();
var bytes = serializer.SerializeToBytes(root, Endianness.LittleEndian);
var copy = serializer.DeserializeBytes(bytes, Endianness.LittleEndian);
copy is a new graph with the same shape as the original. copy.Primary and copy.Secondary are one object, and copy.Primary.Primary is copy itself.
3. How references are tracked
Every serializer call receives a SerializationContext, which records the objects that have been written or read so far in that operation. The convenience methods SerializeToBytes and DeserializeBytes create one context for the whole call and dispose it afterwards.
Automatic assembly wraps each reference-type member in a ReferenceSerializer<T>. That wrapper writes one prefix byte before the member and uses the context to decide what follows.
-
00means the member is null, and nothing follows. -
01means the object is written in full after the prefix. -
02means the object was already written in this operation, and a variable-length context index follows.
The 21 bytes that the sample produces show all three. Each cell below is one byte, numbered from zero.
Byte 0 opens the root object, and byte 7 opens the object held by its Primary member, each followed by that object's Name. Bytes 16 and 17 are the second node's Primary, a back-reference to context index 0, which is the root. Byte 18 is its null Secondary. Bytes 19 and 20 are the root's Secondary, a back-reference to context index 2, which is the shared node. A string is a reference type too, so each Name carries its own 01 prefix and occupies a context index.
Diagram source
---
config:
packet:
bitsPerRow: 16
bitWidth: 22
showBits: true
---
packet-beta
0: "01"
1-6: "Name: root"
7: "01"
8-15: "Name: shared"
16-17: "02 00"
18: "00"
19-20: "02 02"
On deserialization the same prefix tells the reader whether to return null, build a new object, or return the object it already built at the recorded index. An object is registered with the context as soon as it is activated, before its members are read, which is what allows a member to refer back to its own parent.
A back-reference is a single variable-length integer. A shared object therefore costs its full size once, however many members point to it.
SerializeToBytes calls produce two independent graphs. When writing several items yourself with Serialize(item, writer, context), pass the same context to each call if they should share references.
4. Control reference handling for a member
ReferenceSerializerMode has three flags, and Default enables all of them.
| Flag | Meaning |
SupportNull |
The member may be null. |
SupportContextReferences |
A repeated object is written as a back-reference. |
SupportExternalReferences |
The context may replace an object with a pointer to storage outside the stream, which ObjectSpace uses for objects held in other dimensions. |
The [ReferenceMode] attribute changes the mode of one member during automatic assembly.
[ReferenceMode(Nullable = false, AllowContextReference = false)]
public GraphNode Primary { get; set; }
With context references disabled, a repeated object is written in full each time and is restored as separate copies. A cycle through such a member cannot be written, and the serializer throws InvalidOperationException when it meets one. Writing a null through a member that does not support null also throws.
5. Limits of automatic assembly
-
A type needs a public parameterless constructor. Members marked
[Transient]are skipped. -
The layout follows the type's members, so changing the class changes the format. Bind each member explicitly with
SerializerBuilder.For<T>()when the layout must be fixed independently of the class. -
A member declared as a base type needs its derived types registered, for example with
[KnownSubType], so that the concrete type round-trips. -
Preserving identity says nothing about where the bytes came from. Authenticate untrusted input separately.
6. Get the package and run the sample
Add the package to a .NET 10 project.
dotnet add package Sphere10.Framework --version 3.1.3
The serializers ship in the Sphere10.Framework NuGet package. Download the sample project. It round-trips the graph and checks the shared reference and the cycle with ReferenceEquals. Use the .NET 10 SDK and Sphere10 Framework 3.1.3.
dotnet run -c Release
Shared reference preserved: True
Cycle preserved: True
Name set through Primary, read through Secondary: renamed
Serialized bytes: 21
The third line is produced by renaming the node through Primary and reading the name through Secondary.
Download the complete NuGet sample project. The archive contains the project, its model files and run instructions. Keep the pinned package version when reproducing the example.
The Sphere10 Framework overview describes the rest of the toolkit.
Author: Herman Schoenfeld