JSON Serialization in .NET Core 3: Tiny Difference, Big Consequences
Recently, I migrated a web API from .NET Core 2.2 to version 3.0 (following the documentation by Microsoft). After that, the API worked fine without changes to the (WPF) client or the controller code on the server – except for one function that looked a bit like this (simplified naming, obviously):
[HttpPost] public IActionResult SetThings([FromBody] Thing[] things) { ... }
The Thing
class has an Items
property of type List<Item>
, the Item
class has a SubItems
property of type List<SubItem>
.
What I didn’t expect was that after the migration, all SubItems
lists were empty, while the Items
lists contained, well, items.
But it worked before! I didn’t change anything!
In fact, I didn’t touch my code, but something else changed: ASP.NET Core no longer uses Json.NET by NewtonSoft. Instead, JSON serialization is done by classes in the new System.Text.Json namespace.
The Repro
Here’s a simple .NET Core 3.0 console application for comparing .NET Core 2.2 and 3.0.
The program creates an object hierarchy, serializes it using the two different serializers, deserializes the resulting JSON and compares the results (data structure classes not shown yet for story-telling purposes):
class Program { private static Item CreateItem() { var item = new Item(); item.SubItems.Add(new SubItem()); item.SubItems.Add(new SubItem()); item.SubItems.Add(new SubItem()); item.SubItems.Add(new SubItem()); return item; } static void Main(string[] args) { var original = new Thing(); original.Items.Add(CreateItem()); original.Items.Add(CreateItem()); var json = System.Text.Json.JsonSerializer.Serialize(original); var json2 = Newtonsoft.Json.JsonConvert.SerializeObject(original); Console.WriteLine($"JSON is equal: {String.Equals(json, json2, StringComparison.Ordinal)}"); Console.WriteLine(); var instance1 = System.Text.Json.JsonSerializer.Deserialize<Thing>(json); var instance2 = Newtonsoft.Json.JsonConvert.DeserializeObject<Thing>(json); Console.WriteLine($".Items.Count: {instance1.Items.Count} (System.Text.Json)"); Console.WriteLine($".Items.Count: {instance2.Items.Count} (Json.NET)"); Console.WriteLine(); Console.WriteLine($".Items[0].SubItems.Count: {instance1.Items[0].SubItems.Count} (System.Text.Json)"); Console.WriteLine($".Items[0].SubItems.Count: {instance2.Items[0].SubItems.Count} (Json.NET)"); } }
The program writes the following output to the console:
JSON is equal: True .Items.Count: 2 (System.Text.Json) .Items.Count: 2 (Json.NET) .Items[0].SubItems.Count: 0 (System.Text.Json) .Items[0].SubItems.Count: 4 (Json.NET)
As described, the sub-items are missing after deserializing with System.Text.Json.
The Cause
Now let’s take a look at the classes for the data structures:
public class Thing { public Thing() { Items=new List<Item>(); } public List<Item> Items { get; set; } } public class Item { public Item() { SubItems = new List<SubItem>(); } public List<SubItem> SubItems { get; } } public class SubItem { }
There’s a small difference between the two list properties:
- The
Items
property of classThing
has a getter and a setter. - The
Subitems
property of classItem
only has a getter.
(I don’t even remember why one list-type property does have a setter and the other does not)
Apparently, Json.NET determines that while it cannot set the SubItems
property directly, it can add items to the list (because the property is not null).
The new deserialization in .NET Core 3.0, on the other hand, does not touch a property it cannot set.
I don’t see this as a case of “right or wrong”. The different behaviors are simply the result of different philosophies:
- Json.NET favors “it just works.”
- System.Text.Json works along the principle “if the property does not have a setter, there is probably a reason for that.”
The Takeways
- Replacing any non-trivial library “A” with another library “B” comes with a risk.
- Details. It’s always the details.
- Consistency in your code increases the chances of consistent behavior when something goes wrong.