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 class Thing has a getter and a setter.
  • The Subitems property of class Item 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

  1. Replacing any non-trivial library “A” with another library “B” comes with a risk.
  2. Details. It’s always the details.
  3. Consistency in your code increases the chances of consistent behavior when something goes wrong.

6 Comments

  • There is another big difference. If your object to serialize has a property of type Item, but you set it to ExtendedItem (which derives from Item), then all the extra properties of ExtendedItem are missing in the JSON.

  • @Saithis: Thank you for the comment! I just tried that out in code - oh, wow!
    Interesting: If I set the property type to "object", then all properties of ExtendedItem are serialized. On the other hand, if that wasn't the case, the serialization would be next to useless for preparing and returning a dynamic tree of objects from a web API, intended to be used by a JavaScript client (not exactly an uncommon scenario). Still, I don't like being forced to use properties of type "object" for that purpose.
    Fortunately, using Json.NET is still an option in ASP.NET Core 3.0 (as described in the Microsoft migration docs).

  • @WeigeltRo: Yes, if the serializer encounters the type object, then it uses GetType() to get the real type and serilaizes that. It is also planned to support always serializing the real type, see here: https://github.com/dotnet/corefx/issues/38650

    For now we can cast to object, if the base object is the problem. But if a property of the object to serialize is the problem, then we have to either change it to type object, or use a JsonConverter that gets the real type for this base type while serializing.

  • Hawe you tried whether the list would be initialized correctly if you would extend Item class constructor to accept List<SubItem> subItems?

  • @Chris: Out of curiosity, I added a second constructor, but it didn't change anything - as expected for the new deserializer and the way it works (at least at this time in its development).

  • Another problem is that it can serialize dynamic objects

Comments have been disabled for this content.