Using System.Text.Json alongside Newtonsoft Json.Net

In June 2019, Microsoft introduced System.Text.Json as a feature of .NET (core) 3.0 to the public. The reason they gave for creating this new namespace was that they were unhappy with the old built-in solution for serializing / deserializing JSON. The poor built-in capabilities to work with JSONs –of course- was the reason for James Newton-King to create Json.Net (for many of us just called Newtonsoft, which actually is his company and not the name of the library). Newtonsoft’s Json.Net quickly became the de facto standard for working with JSON in .NET, and Microsoft wanted to change that. So, they made a smart move by hiring James Newton-King and letting him work on a new JSON (de-) serialization solution.

Microsoft knew how important JSONs are in today’s web applications and that they had to improve performance in this area. So, they created new data types (Span, etc.) to work more efficiently with Strings to boost JSON performance. In their initial introduction of System.Text.Json, they claimed a 1.5x-5x speedup compared to Newtonsoft Json.Net. 

Back in 2019, we were already curious about working with System.Text.JSON, but we quickly realized that switching wouldn’t be so easy for us: We’ve created quite some custom Json.Net Converters for our App (Zeiterfassung mit TimeRocket), and we had to prioritize building new features higher than optimizing performance, especially because our users weren’t complaining about issues in that regard.

But now it’s three years later, and priorities have shifted a bit. At the last SoCraTes Day in Zurich, Urs and I had a great discussion with Oliver Nautsch. We told him that it was crazy that our app, despite having to calculate a bunch of stuff, spends the most time serializing and deserializing JSON data and that we knew about System.Text.Json. Oliver’s simple response was: Why don’t you just switch then? The advice was as simple as it was disarming. We always knew we should; we just convinced ourselves that it was too complicated (spoiler alert: it isn’t).

Benchmarking

Before rushing in and just changing things for the sake of it, I wanted to create some benchmarks for a part of our software we thought switching could improve performance: We use a Redis cache to store some user-specific data as JSON. This data gets read on many requests, and we earlier found out that it was often faster to read and transmit the data from Redis to our App Service than deserializing it. So, I thought that was a suitable candidate for comparing System.Text.Json to Json.Net.

Screenshot from Bechmark.Net

First of all, I was impressed by how accurate the predictions of Microsoft were: They promised up to 5x speed improvements, and that’s what we got deserializing our JSON (which is what really matters for us in this use case). But I’m pretty sure you noticed the other comparison: Serializing our JSON is a whooping 75x faster! Yes, it’s not our primary goal to speed up serialization, but that’s some impressive stuff! 

So, yeah, the benchmarks were quite convincing to go further on that road. 

Big bang?

Ok, we want to use System.Text.Json, but we already have a considerable amount of ASP.Net Controllers working fine using Newtonsoft’s Json.Net. So should we refactor everything in one go and hope for the best? The way I’m phrasing this rhetorical question already tells you that we feel uneasy about doing that. So instead, we want to change the (de-)serialization engine Controller by Controller, starting with one that doesn’t get used very often in comparison. This way, we have a plan for how to phase out Newtonsoft Json.Net gradually. 

The problem is: ASP.Net isn’t built with that scenario in mind. In your Startup class, you have something like: 

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddControllers(opt => { ... })
            .AddNewtonsoftJson(options => { ... });
    }
}

Or with System.Text.Json, you have:

.AddJsonOptions(options => { ... });

But either way, the configuration is used application-wide. During my research phase, I found this blog post by Thomas Levesque that explains how to use different configurations for different Controllers using System.Text.Json. Even though I don’t really like the very loose coupling between the configuration and the place it’s being used, it was a great inspiration for my solution. 

How to set a serializer for a specific Controller?

My goal was to have something like this example to tell if a Controller is deviating from the default and uses System.Text.Json: 

[UseSystemTextJson]
public class DefinitionController : Controller
{
    [HttpPost]
    [Route("some/route")]
    public async Task<MyResult> PerformAction(
        [FromBody] MyOperation operationData)
    {
        var result = await this.MyFacade
            .PerformActionInCore(operationData);

        return this.Ok(result);
    }
}

And this is the implementation of my Attribute:

public class UseSystemTextJsonAttribute : ActionFilterAttribute, IControllerModelConvention, IActionModelConvention
{
    // when [UseSystemTextJson] added to Controller (gets called on startup)
    public void Apply(ControllerModel controller)
    {
        // add usage to every endpoint
        foreach (var action in controller.Actions)
        {
            this.Apply(action);
        }
    }

    // when [UseSystemTextJson] added to endpoint (gets called on startup)
    public void Apply(ActionModel action)
    {
        // Set custom model binder to every parameter that uses [FromBody]
        var parameters = action.Parameters.Where(p => p.BindingInfo?.BindingSource == BindingSource.Body);
        foreach (var p in parameters)
        {
            p.BindingInfo!.BinderType = typeof(SystemTextJsonBodyModelBinder);
        }
    }

    // when this.Ok(obj) or just return obj is performed on endpoint
    public override void OnActionExecuted(ActionExecutedContext context)
    {
        var formatter = new SystemTextJsonOutputFormatter(
           MyGlobalJsonSerializerOptions.Default);
        if (context.Result is ObjectResult objectResult)
        {
            objectResult.Formatters
                .RemoveType<NewtonsoftJsonOutputFormatter>(); // remove default (defined in Startup.cs)
            objectResult.Formatters.Add(formatter);
        }
        else
        {
            base.OnActionExecuted(context);
        }
    }
}

The Attribute has two responsibilities: 
First, switch out the default OutputFormatter (from Newtonsoft Json.Net) with a System.Text.Json OutputFormatter. This is being done every time a controller endpoint returns an ObjectResult, which is the case when: 

  • return this.Ok(obj)
  • return obj 
  • return new ObjectResult(obj) 

Second, set a custom ModelBinder (SystemTextJsonBodyModelBinder)on all parameters with a [FromBody] Attribute. 

The SystemTextJsonBodyModelBinder handles reading the body content of the HTTP request and interpreting that content as a JSON, deserializing it, and putting the result into a ModelContext so that the endpoint method can be called with that resolved model:

// a new instance of this class is created for every request that contains a [FromBody] parameter
public class SystemTextJsonBodyModelBinder : IModelBinder
{
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        try
        {
            var body = await ReadBody(bindingContext.HttpContext.Request);

            var serializerOptions = MyGlobalJsonSerializerOptions.Default;
            var deserialized =
                JsonSerializer.Deserialize(body, bindingContext.ModelType, serializerOptions);
            bindingContext.Result = ModelBindingResult.Success(deserialized!);
        }
        catch (Exception ex)
        {
            bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex, bindingContext.ModelMetadata);
        }
    }

    private static async Task<string> ReadBody(HttpRequest request)
    {
        request.EnableBuffering();

        using var reader = new StreamReader(
            request.Body,
            encoding: Encoding.UTF8,
            detectEncodingFromByteOrderMarks: true,
            bufferSize: 1024,
            leaveOpen: true);
        var body = await reader.ReadToEndAsync();

        request.Body.Seek(0, SeekOrigin.Begin);
        return body;
    }
}

That’s all it takes to change the serializer for a Controller. But I wanted to go one step further: 

Advanced solution

I wanted to be able to define a custom JsonSerializerOptions on every Controller to overrule the default behavior and optimize the serialization speed even further. The idea is simple: If I define a JsonSerializerOptions on every Controller, I don’t have to set every custom Converter I use in the whole app in the same global JsonSerializerOptions instance. On the contrary, I only have to define the Converters used in that specific Controller. And this means that System.Text.Json does only have to loop through a limited number of converters. The other benefit is readability: If you want to understand how a JSON gets (de-)serialized, you only need to reason about a handful of converters. 

Usage:

[UseSystemTextJson]
public class DefinitionController : Controller
{
    ...

    [CustomJsonOptions]
    public static JsonSerializerOptions JsonSerializerOptions { get; } = new(MyGlobalJsonSerializerOptions.Default)
    {
        Converters =
        {
            new SomeCustomConverter(),
        }
    };
}

The [CustomJsonOptions] attribute is only implemented as a marker attribute (I don’t know if this term exists, but it’s the same idea as marker interfaces). 

But with that, I can use reflection to get the configuration and use it for my serializer: 

public static class OptionsGetter
{
    // find the property with a [CustomJsonOptions] attribute on the calling controller and return its value
    public static JsonSerializerOptions GenerateJsonSerializerOptions(ActionDescriptor actionDescriptor)
    {
        try
        {
            var controllerDescriptor = actionDescriptor as ControllerActionDescriptor;
            var controllerTypeInfo = controllerDescriptor.ControllerTypeInfo;
            var optionsPropertyInfo = controllerTypeInfo.GetProperties()
                .FirstOrDefault(p => p.GetCustomAttribute<CustomJsonOptionsAttribute>() != null);

            // if no property with a [CustomJsonOptions] attribute is found, return the default options
            if (optionsPropertyInfo == null)
            {
                return MyGlobalJsonSerializerOptions.Default;
            }

            return (JsonSerializerOptions)optionsPropertyInfo.GetValue(null, null);
        }
        catch
        {
            // if something goes wrong, return the default options
            return MyGlobalJsonSerializerOptions.Default;
        }
    }
}

You can even go another step further and memoize the option for your Controller, so you don’t have to use reflection on every HTTP request: 

public static class OptionsGetter
{
    private static readonly Dictionary<string, JsonSerializerOptions> MemoizedOptions = new();

    // the actionDescriptor stands for the calling controller -> make sure the options are only obtained once per controller
    public static JsonSerializerOptions GetJsonSerializerOptions(ActionDescriptor actionDescriptor)
    {
        if (MemoizedOptions.ContainsKey(actionDescriptor.Id))
        {
            return MemoizedOptions[actionDescriptor.Id];
        }

        return MemoizedOptions[actionDescriptor.Id] = GenerateJsonSerializerOptions(actionDescriptor);
    }

    public static JsonSerializerOptions GenerateJsonSerializerOptions(ActionDescriptor actionDescriptor)
    {
        ...
    }
}

That’s all I’ve got on this topic. If you would have done something differently or if I have missed a concept, feel free to drop a comment and point me in the right direction. I’d love to have some constructive feedback. 

About the author

Domenic Helfenstein

2 comments

  • I don’t see the source code for the MyGlobalJsonSerializerOptions class mentioned in your post. What does that look like?

  • I didn’t include MyGlobalJsonSerializerOptions because these options will look different for every project / team. For starters you can just use the default options provided by System.Text.Json: new JsonSerializerOptions(JsonSerializerDefaults.Web)

    This gives you a custom JsonSerializerOptions instance which you can change at your will, but it inherits all settings from JsonSerializerDefaults.Web which is STJ’s default for the web.

Recent Posts