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.
Let’s begin!
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:
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.