Documentation Index
Fetch the complete documentation index at: https://mintlify.com/ProwlEngine/Prowl.Echo/llms.txt
Use this file to discover all available pages before exploring further.
Prowl.Echo automatically detects when the same object instance appears more than once in the object graph being serialized, including back-references that form cycles. When a cycle is detected, Echo records an integer ID reference instead of recursing infinitely. On deserialization, those IDs are resolved back to the same in-memory instance, restoring the original graph topology exactly.
How It Works
Every SerializationContext carries two dictionaries that are populated as serialization proceeds:
public class SerializationContext
{
public Dictionary<object, int> objectToId = new(ReferenceEqualityComparer.Instance);
public Dictionary<int, object> idToObject = new();
public int nextId = 1;
}
ReferenceEqualityComparer.Instance is used for objectToId, which means two distinct object instances that happen to be equal by value are still tracked separately — only actual reference identity triggers the deduplication path.
During Serialization
When AnyObjectFormat (the fallback handler for reference types) visits a reference-type object, it checks whether that object is already registered in objectToId:
- First encounter: assigns the object a new integer ID, writes it as
"$id" into the compound, and serializes the object’s fields normally.
- Subsequent encounters: writes only a compound containing the previously assigned
"$id" — no fields are repeated.
During Deserialization
When the deserializer reads a compound:
- It looks for a
"$id" tag.
- If found and the ID is already in
idToObject, it returns the previously created instance directly.
- If not yet registered, it creates a new instance, stores it in
idToObject under that ID, then populates its fields.
Simple Circular Reference
The classic example: a parent that holds a child, which holds a back-reference to the parent.
// CircularObject.cs (from the test suite)
public class CircularObject
{
public string Name = "Parent";
public CircularObject? Child;
}
var parent = new CircularObject { Name = "Parent" };
var child = new CircularObject { Name = "Child" };
parent.Child = child;
child.Child = parent; // circular back-reference
EchoObject echo = Serializer.Serialize(parent);
CircularObject? restored = Serializer.Deserialize<CircularObject>(echo);
// Structural verification
Console.WriteLine(restored.Name); // "Parent"
Console.WriteLine(restored.Child.Name); // "Child"
Console.WriteLine(object.ReferenceEquals(
restored, restored.Child.Child)); // True — same instance
What the Serialized Output Looks Like
After the first encounter, the parent gets "$id": 1 and is fully written out. When the child’s Child field points back to the parent, Echo writes a stub compound containing only "$id": 1:
Compound {
"$id": Int(1)
"Name": String("Parent")
"Child": Compound {
"$id": Int(2)
"Name": String("Child")
"Child": Compound {
"$id": Int(1) // <-- reference stub, no fields
}
}
}
Diamond-Shaped References
Multiple paths that converge on the same node are handled correctly. Both the left and right branches resolve to the same bottom instance after deserialization:
var root = new NodeWithMultipleRefs { Name = "Root" };
var left = new NodeWithMultipleRefs { Name = "Left", Parent = root };
var right = new NodeWithMultipleRefs { Name = "Right", Parent = root };
var bottom = new NodeWithMultipleRefs { Name = "Bottom" };
root.Left = left;
root.Right = right;
left.Right = bottom;
right.Left = bottom;
bottom.Parent = root; // closes the cycle
EchoObject echo = Serializer.Serialize(root);
var restored = Serializer.Deserialize<NodeWithMultipleRefs>(echo);
// left.Right and right.Left are the same Bottom instance
Console.WriteLine(object.ReferenceEquals(
restored.Left.Right, restored.Right.Left)); // True
// Parent references both point back to root
Console.WriteLine(object.ReferenceEquals(restored, restored.Left.Parent)); // True
Console.WriteLine(object.ReferenceEquals(restored, restored.Right.Parent)); // True
Circular References inside Collections
Collections of objects that reference each other are also handled transparently:
var first = new NodeWithMultipleRefs { Name = "First" };
var second = new NodeWithMultipleRefs { Name = "Second" };
var third = new NodeWithMultipleRefs { Name = "Third" };
first.Right = second;
second.Right = third;
third.Right = first; // cycle within a List
var list = new List<NodeWithMultipleRefs> { first, second, third };
EchoObject echo = Serializer.Serialize(list);
var restored = Serializer.Deserialize<List<NodeWithMultipleRefs>>(echo);
Console.WriteLine(object.ReferenceEquals(restored[0], restored[2].Right)); // True
Implementing ISerializable with Circular References
When you implement ISerializable manually, you must pass the context through every nested Serializer.Serialize / Serializer.Deserialize call. The identity-tracking dictionaries live on the context, so omitting it breaks cycle detection.
public class NodeWithMultipleRefs : ISerializable
{
public string Name;
public NodeWithMultipleRefs Left;
public NodeWithMultipleRefs Right;
public NodeWithMultipleRefs Parent;
public void Serialize(ref EchoObject compound, SerializationContext ctx)
{
compound.Add("Name", new EchoObject(Name));
// Always pass ctx — this is how cycle detection works
compound.Add("Left", Serializer.Serialize(typeof(NodeWithMultipleRefs), Left, ctx));
compound.Add("Right", Serializer.Serialize(typeof(NodeWithMultipleRefs), Right, ctx));
compound.Add("Parent", Serializer.Serialize(typeof(NodeWithMultipleRefs), Parent, ctx));
}
public void Deserialize(EchoObject value, SerializationContext ctx)
{
Name = value["Name"].StringValue;
Left = Serializer.Deserialize<NodeWithMultipleRefs>(value["Left"], ctx);
Right = Serializer.Deserialize<NodeWithMultipleRefs>(value["Right"], ctx);
Parent = Serializer.Deserialize<NodeWithMultipleRefs>(value["Parent"], ctx);
}
}
Value Types Cannot Be Circular
Circular reference tracking only applies to reference types (classes). Structs are value types — they are copied by value during field assignment and cannot form reference cycles. Attempting to store a struct reference that forms a cycle is a compile-time type error in C#.
// ✅ This works — CircularObject is a class
public class CircularObject { public CircularObject? Child; }
// ❌ This is impossible — structs can't hold references to themselves
public struct BadStruct { public BadStruct Child; } // compile error
Sharing a Context Across Multiple Serialize Calls
Each Serializer.Serialize(object?) overload creates a fresh SerializationContext by default. If you want reference identity to be tracked across separate top-level calls (for example, when serializing a scene graph piece by piece), share a single context instance:
var ctx = new SerializationContext();
// Both calls share the same reference-tracking dictionaries
EchoObject echoA = Serializer.Serialize(objectA, ctx);
EchoObject echoB = Serializer.Serialize(objectB, ctx);
If objectB holds a reference to objectA (or vice-versa) and you used different contexts for each call, the circular link will not be detected and serialization will recurse until a StackOverflowException is thrown.
Deeply Nested Acyclic Graphs
Circular-reference tracking is not just for true cycles. It also prevents redundant serialization of shared sub-objects in a directed acyclic graph (DAG). If two different parent objects reference the same child, that child is serialized once and referenced by ID from both parents.
var sharedChild = new CircularObject { Name = "Shared" };
var parent1 = new CircularObject { Name = "P1", Child = sharedChild };
var parent2 = new CircularObject { Name = "P2", Child = sharedChild };
var pair = new List<CircularObject> { parent1, parent2 };
EchoObject echo = Serializer.Serialize(pair);
var restored = Serializer.Deserialize<List<CircularObject>>(echo);
// The child is the same instance in both parents
Console.WriteLine(object.ReferenceEquals(
restored[0].Child, restored[1].Child)); // True