JsonEnvelopes .NET Standard Library

Description

JsonEnvelopes is simple .NET Standard library which utilizes a concrete implementation of JsonCovnverter<T> (System.Text.Json) to serialize and deserialize objects in a way that allows message receivers to be agnostic with respect to message type.

Messaging in .NET

Any software developer working in the Enterprise space is likely acquainted with messaging. Modern Enterprise applications typically rely heavily on sending and receiving serialized messages. In .NET the serialization and sending of messages is often trivialized by packages such as System.Text.Json and Microsoft.Azure.ServiceBus respectively. The more interesting decisions come in designing a receiving strategy for those messages.

With .NET the primary challenge in receiving messages is the need to resolve the Type of the message in advance of its deserialization. There are many strategies to solve this issue and they often depend on the function of the receiver. In the following examples we'll consider a CastFireball command being received as a serialized message.

Web API

In a Web API style application messages are received by controllers. In the following example a SpellsController resolves the body of an HTTP POST made to {root}/spells/cast/fireball as an instance of CastFireball. The route (and the HTTP method, i.e. POST) informs the service what Type to expect and thus how to deserialize the received message.

[ApiController]
[Route("[controller]")]
public class SpellsController : ControllerBase  
{
    [HttpPost]
    [Route("cast/fireball")]
    public async Task<ActionResult> ReceiveCastFireball([FromBody]CastFireball spell) 
    {
        await HandleCastFireballAsync(spell);
...


Queue per Type Queue Readers

With this strategy a message's Type is resolved by the name of its queue. In the following example an Azure Function listens on an Azure Service Bus queue named cast-fireball and handles incoming messages as CastFireball.

public async Task Run([ServiceBusTrigger("cast-fireball")]string message)  
{
    var spell = JsonSerializer.Deserialize<CastFireball>(message);
    await HandleCastFireballAsync(spell);
...


Partition Key per Type Queue Readers

If your bus supports partition keys another option might be to use the message's Type as its partition key. In the following example an Azure Function listens on an Azure Service Bus queue named spells and resolves the Type of the incoming messages using their partition key. It should be noted that the Message class used in this example also provides the property ContentType which could alternately be used for this purpose but libraries for some bus implementations may not expose such a property.

public async Task Run([ServiceBusTrigger("spells")]Message message)  
{
    var json = Encoding.UTF8.GetString(message.Body);
    switch (message.PartitionKey)
    {
        case "CastFireball":
            var spell = JsonSerializer.Deserialize<CastFireball>(json);
            await HandleCastFireballAsync(spell);
...

With each of these message receiving strategies (and many others) the code to deserialize a received message and "handle" it (e.g. calling HandleCastFireballAsync) will become highly repetitive as the number of message types increases. A preferable solution would be to perform message deserialization generically and use standard Dependency Injection (DI) or a library such as MediatR to handle the deserialized object. Facilitating this is exactly the purpose of JsonEnvelopes.

JsonEnvelopes

An envelope can be simply thought of as a content wrapper with a label. In JsonEnvelopes this idea is expressed as Envelope<TContent> where the content is an instance of TContent and the label is TContent's type name.

In the following example an instance of CastFireBall is wrapped in an instance of Envelope<TContent> which is then serialized, ready to be sent. Note that the call to Serialize<T> specifies T as Envelope not Envelope<CastFireBall>.

var command = new CastFireBall();  
var envelope = new Envelope<CastFireBall>(command);  
string json = JsonSerializer.Serialize<Envelope>(envelope);  

Any json string created this way can be deserialized as follows.

var envelope = JsonSerializer.Deserialize<Envelope>(json);  

Again note the use of the type Envelope. Calling Serialize<Envelope> and Deserilaize<Envelope> triggers the use of a custom JsonConverter. With this in hand we can now leverage JsonEnvelopes to deserialize and handle objects in a more generic manner. In the following example we'll consider using standard Dependency Injection. The use of a library like MediatR can simplify the code even further. Complete examples of each technique can be found in the JsonEnvelopes.Example project.

We first define two simple interfaces for handling commands.

public interface ICommandHandler  
{
    Task<bool> HandleAsync(object command);
}

public interface ICommandHandler<TCommand> : ICommandHandler  
{
    Task<bool> HandleAsync(TCommand command);
}

Next we define an implementation of ICommandHandler<CastFireball>.

public class CastFireballHandler : ICommandHandler<CastFireball>  
{
    public Task<bool> HandleAsync(CastFireball command)
    {
        // Handling code
    }

    public Task<bool> HandleAsync(object command) =>
        HandleAsync((CastFireball)command);
}

At application startup (typically in the ConfigureServices method of Startup.cs) we wire-up Dependency Injection for ICommandHandler<CastFireball> in the standard way. The following line would be repeated for each implementation of ICommandHandler<TCommand>, i.e. generally once per command type.

services.AddSingleton<ICommandHandler<CastFireball>, CastFireballHandler>();  

Finally, we'll implement our previous Web API and Queue Reader examples using JsonEnvelopes.

Web API

In the following example we first get the Type specified by the envelope's ContentType string property and use it to get the Type of the specific ICommandHandler<TCommand> to be used. Next, we use the injected IServiceProvider to get an instance of that interface (as specified at application start-up) which we cast as ICommandHandler. Finally, we use the handler to handle the command.

[ApiController]
[Route("[controller]")]
public class CommandsController : ControllerBase  
{
    private readonly IServiceProvider _serviceProvider;

    public CommandsController(IServiceProvider provider) =>
        _serviceProvider = provider;

    [HttpPost]
    public async Task<ActionResult> ReceiveCommand([FromBody]Envelope envelope) 
    {
        var contentType = Type.GetType(envelope.ContentType);
        var handlerType = typeof(ICommandHandler<>).MakeGenericType(contentType));
        var handler = _serviceProvider.GetService(handlerType) as ICommandHandler;
        await handler.HandleAsync(commandEnvelope.GetContent());
...

This code is obviously slightly more complicated than the previous Web API example. However, note that the new ReceiveCommand method can be used for any command. We no longer need a method for each command type. Additionally, rather than our Web API specifying a route per command type, e.g. {root}/spells/cast/fireball, all commands can be sent to the same route, i.e. {root}/commands.

Queue Reader

As before we see an example of an Azure Function listening on an Azure Service Bus queue. However, in this case we listen to the commands queue and handle all commands generically in much the same way as the Web API example.

public async Task Run([ServiceBusTrigger("commands")]string message)  
{
    var envelope = JsonSerializer.Deserialize<Envelope>(message);
    var contentType = Type.GetType(envelope.ContentType);
    var handlerType = typeof(ICommandHandler<>).MakeGenericType(contentType));
    var handler = _serviceProvider.GetService(handlerType) as ICommandHandler;
    await handler.HandleAsync(commandEnvelope.GetContent());
...

This code is also slightly more complicated than the previous Queue Reader examples but again one implementation can be used to handle all command types. We no longer have need of strategies like Queue Per Type or Partition Key Per Type.

Wrap Up

I originally created JsonEnvelopes months ago when I found myself needing to solve the previously described issues for perhaps the tenth time in the last five years. Rather than adding yet another new implementation to the project I'd just started I decided to create the stand-alone project JsonEnvelopes first. I created the GitHub repo, added code and setup an Azure DevOps Pipeline (azure-pipelines.yml) to push the JsonEnvelopes package to nuget.org. I'm happy with the results of both the code and the CI/CD pipeline that resulted from this exploration.

References