Search code examples
c#jsonentity-frameworksystem.text.json

Navigation Properties Order Causes Serialization Failure


Can anybody explain this behavior so that I can manage it in the future?

I have a query (defined as IQueryable) with a number of include statements. I am using entityframework for an SQL Server database.

The primary entity and a few of the included entities have a foreign key to a code table that I used to define Time Period Types (Year, Month, Quarter, etc...) along with data related to each of these.

In the Period entity, I have navigation properties for the entities they provide values to.

After successfully running the query with its includes, I would serialize the results using system.text.json.

I started to get cycle errors with serialization despite setting the reference handler to "preserve".

After investigation, I found that serialization is successful or fails depending on the order of my includes in the code table entity.

So, if my entity includes navigation properties like this:

public IList<entity1>? entity1Collection { get; set; } = new List<entity1>();
public IList<entity2>? entity2Collection { get; set; } = new List<entity3>();
public IList<entity3>? entity3Collection { get; set; } = new List<entity3>();
public IList<entity4>? entity4Collection { get; set; } = new List<entity4>();
public IList<entity5>? entity5Collection { get; set; } = new List<entity5>();

Results: Serialization works.

However, If I swap the order of the above navigations (by cutting pasting), serialization will fail because of the cycle error.

***Note: The numbering scheme of my entities above are just for the purpose of this post and have not meaning.

Since I did not believe that the order mattered, I re-executed this from scratch repeatedly and always achieved identical results -- I am confident that there are no other difference introduced.

Looking for help understanding this:

  1. Is this expected behavior?
  2. If it is, are there rules/guidelines I should be following when defining the order of navigation properties in my entities?

here is the error:

System.Text.Json.JsonException: 'A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path:

And, yes, it is a MaxDepth issue.


Solution

  • System.Text.Json is a single-pass depth-first serializer. When reference preservation is enabled, an object will be serialized the first time it is encountered. Only the "$ref": "N" will be serialized on subsequent encounters. Thus if your serialization graph is sufficiently deep, changing the property order could lead to MaxDepth being exceeded.

    For example, consider the following model, which contains a list of parents and a list of all children. Parents have children, and children have friends:

    public class Model
    {
        public List<Child> AllChildren { get; set; }= new();
        public List<Parent> Parents { get; set; }= new();
    }
    
    public class Parent
    {
        public List<Child> Children { get; set; } = new();
    }
    
    public class Child
    {
        public List<Child> Friends { get; set; } = new ();
    }
    

    If I construct an instance with 2 parents and 2 children, where each child is a friend:

    var child1 = new Child { };
    var child2 = new Child { Friends = { child1 } };
    child1.Friends.Add(child2);
    
    var model = new TModel
    {
        AllChildren = { child1, child2 },
        Parents = { new Parent { Children = { child1 } }, new Parent { Children = { child2 } } },
    };
    

    Then the model will serialize successfully at MaxDepth = 10 and fail at MaxDepth = 9. Demo fiddle #1 here.

    Now imagine I modify Model to serialize the parents first:

    public class Model
    {
        public List<Parent> Parents { get; set; }= new();
        public List<Child> AllChildren { get; set; }= new();
    }
    

    Then serialization will fail at MaxDepth 10, 11 and 12. It will only serialize successfully starting at MaxDepth = 13. Demo fiddle #2 here.

    The cause of the difference is the previously mentioned depth-first serialization. In the first version, the second child is serialized as a friend of the first inside the AllChildren list:

    {
      "$id": "1",
      "AllChildren": {
        "$id": "2",
        "$values": [
          {
            "$id": "3",
            "Friends": {
              "$id": "4",
              "$values": [
                {
                  "$id": "5",
                  "Friends": {
                    "$id": "6", // Here is where the second child is serialized.
                    "$values": [
                      {
                        "$ref": "3"
                      }
                    ]
                  }
                }
              ]
            }
          },
          {
            "$ref": "5"
          }
        ]
      },
      "Parents": {
        "$id": "7",
        "$values": [
          {
            "$id": "8",
            "Children": {
              "$id": "9",
              "$values": [
                {
                  "$ref": "3"
                }
              ]
            }
          },
          {
            "$id": "10",
            "Children": {
              "$id": "11",
              "$values": [
                {
                  "$ref": "5"
                }
              ]
            }
          }
        ]
      }
    }
    


    But in the second version, the second child is serialized inside the Parents list, leading to the increased max depth:

    Testing MaxDepth = 13:
    {
      "$id": "1",
      "Parents": {
        "$id": "2",
        "$values": [
          {
            "$id": "3",
            "Children": {
              "$id": "4",
              "$values": [
                {
                  "$id": "5",
                  "Friends": {
                    "$id": "6",
                    "$values": [
                      {
                        "$id": "7",
                        "Friends": {
                          "$id": "8", // Here is where the second child is serialized.  Notice it's deeper in the graph.
                          "$values": [
                            {
                              "$ref": "5" 
                            }
                          ]
                        }
                      }
                    ]
                  }
                }
              ]
            }
          },
          {
            "$id": "9",
            "Children": {
              "$id": "10",
              "$values": [
                {
                  "$ref": "7"
                }
              ]
            }
          }
        ]
      },
      "AllChildren": {
        "$id": "11",
        "$values": [
          {
            "$ref": "5"
          },
          {
            "$ref": "7"
          }
        ]
      }
    }
    

    To prevent this, you may apply JsonPropertyOrderAttribute to your properties to force children to be serialized before parents:

    public class Model
    {
        [JsonPropertyOrder(2)]
        public List<Parent> Parents { get; set; }= new();
        [JsonPropertyOrder(1)]
        public List<Child> AllChildren { get; set; }= new();
    }
    

    If you do, serialization will no longer depend on C# property order. Demo #3 here.