When working with JSON (JavaScript Object Notation) in .NET applications, developers often encounter challenges related to circular references, where objects circularly reference each other. 

In this article, we’ll explore common scenarios involving circular references in .NET applications and discuss techniques for effectively handling them. We will use System.Text.Json for serialization and deserialization, one of the most popular NuGet libraries.

To download the source code for this article, you can visit our GitHub repository.

Let’s begin!

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Understanding Circular References

Circular references occur when an object refers back to itself through a reference directly or indirectly. In the context of JSON serialization, circular references arise when serializing complex object graphs where objects cyclically reference each other.

As an example of a circular reference, let’s define a class and refer to a property of the same type:

public class Employee
{
    public required string Name { get; set; }
    public required string Surname { get; set; }
    public required string Title { get; set; }
    public Employee? Manager { get; set; }
    public Collection<Employee> DirectReports { get; set; } = new Collection<Employee>();
}

Here, we define the Employee class with a property Employee? Manager that holds a reference to the employee’s direct supervisor. In addition, we also define a Collection<Employee> DirectReports property that keeps a collection of the employee’s direct reports.

These two properties form a circular reference:

Circular reference shown between Employee objects

 

Let’s see in the next sections what it means to return a JSON payload of the Employee class.

Default JSON Serialization Behavior

Firstly, let’s see the default behavior of .NET System.Text.Json when serializing the Employee class.

Let’s create an ASP.NET Core Web API project with Controllers. We will use a Services layer to return the data to the Controller to implement the single responsibility principle.

Let’s now create an EmployeeService class:

public class EmployeeService : IEmployeeService
{
    public IReadOnlyCollection<Employee> GetEmployees()
    {
        var manager = new Employee() { Name = "Kate", Surname = "Wilson", Title = "Development Manager" };
        var engineer = new Employee() 
        { 
           Name = "Adam", Surname = "Smith", Title = "Software Engineer", Manager = manager
        };
        manager.DirectReports.Add(engineer);

        return new[] { manager, engineer}.AsReadOnly();
    }
}

Here, we define the EmployeeService class and implement the GetEmployees() method.

In our method, we create an array of two Employee objects and return it as a ReadOnlyCollection. The manager and engineer employees we define in the collection form a circular dependency.

Let’s now create our EmployeeController:

[Route("api/[controller]")]
[ApiController]
public class EmployeeController(IEmployeeService employeeService) : ControllerBase
{
    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    public IActionResult GetEmployees() => Ok(employeeService.GetEmployees());
}

Here, we use dependency injection to pass our IEmployeeService interface into the EmployeeController class. Also, we define the GetEmployees() method that listens to HttpGet web calls and returns an OkObjectResult that contains the result of the IEmployeeService  GetEmployees() method.

In the Program class, let’s register the dependency between IEmployeeService and EmployeeService:

builder.Services.AddScoped<IEmployeeService, EmployeeService>();

Now, let’s build and run our project, and execute a GET request to our /api/employee endpoint:

Status: 500 Internal Server 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 32. 
Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles.
Path: $.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.Name.
   at System.Text.Json.ThrowHelper.ThrowJsonException_SerializerCycleDetected(Int32 maxDepth)
...

We get a JsonException error 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 32.

Additionally, we see the error path $.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.Name... indicating the properties that caused the error.

Also, the framework suggests a way to resolve the error Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles, so next let’s look at how to resolve it.

Handling Circular References

Now, let’s see what our options are to resolve the issue caused by the circular reference.

The System.Text.Json library allows us to configure certain aspects of C# objects serialization into JSON. We can use it to configure things like formatting, encoding, trailing commas, and also the way that the library will handle circular references. 

Circular references are handled via the ReferenceHandler configuration option which can take two values ReferenceHandler.IgnoreCycles or ReferenceHandler.Preserve.

Let’s try them out starting with the IgnoreCycles option.

ReferenceHandler.IgnoreCycles

In our Program class, let’s add IgnoreCycles:

builder.Services.AddControllers().AddJsonOptions(options =>
   options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);

The .NET framework will try to resolve the error by breaking the circular reference and returning null instead:

[
  {
    "name": "Kate",
    "surname": "Wilson",
    "title": "Development Manager",
    "manager": null,
    "directReports": [
      {
        "name": "Adam",
        "surname": "Smith",
        "title": "Software Engineer",
        "manager": null,
        "directReports": []
      }
    ]
  },
  {
    "name": "Adam",
    "surname": "Smith",
    "title": "Software Engineer",
    "manager": {
      "name": "Kate",
      "surname": "Wilson",
      "title": "Development Manager",
      "manager": null,
      "directReports": [
        null
      ]
    },
    "directReports": []
  }
]

"manager":{...} and "directReports":[...] properties are replaced with null, which breaks the circular references as the objects no longer reference back to themselves.

This works well and we can see the output in a simple format. However, the drawback of this solution is that it is difficult to understand the relationships by looking at the JSON result.

Let’s now try the ReferenceHandler.Preserve option of ReferenceHandler.

ReferenceHandler.Preserve

In our Program class, let’s change the configuration code:

builder.Services.AddControllers().AddJsonOptions(options =>
   options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve);

With this approach, we try to resolve our issue with the Preserve handler. This handler introduces two new fields, $id, and $ref which is used to preserve the relationships of the objects while breaking the circular reference:

{
  "$id": "1",
  "$values": [
    {
      "$id": "2",
      "name": "Kate",
      "surname": "Wilson",
      "title": "Development Manager",
      "manager": null,
      "directReports": {
        "$id": "3",
        "$values": [
          {
            "$id": "4",
            "name": "Adam",
            "surname": "Smith",
            "title": "Software Engineer",
            "manager": {
              "$ref": "2"
            },
            "directReports": {
              "$id": "5",
              "$values": []
            }
          }
        ]
      }
    },
    {
      "$ref": "4"
    }
  ]
}

Each object is now declared once and assigned an $id which is used to reference the same object in other places in the JSON via a $ref field.

This mechanism resolves the circular reference issue and also preserves relationships. However, its downside is that it makes the JSON output a bit more complicated to read and deserialize. 

Conclusion

In this article, we’ve seen how we can configure JSON serialization with System.Text.Json to work with circular references in C# and . NET. We’ve discussed what a circular reference is and observed the default behavior of the System.Text.Json library when working with circular references. Finally, we’ve also discussed two different options to resolve circular references, IgnoreCycles and Preserve.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!