JsonEnvelopes .NET Standard Library
Description
JsonEnvelopes is a 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 with their partition keys. 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
- JsonEnvelopes GitHub repo
- JsonEnvelopes NuGet Package
- MediatR GitHub repo
- Microsoft.Azure.ServiceBus NuGet Package
- System.Text.Json NuGet Package
- Azure Functions Overview
- Azure Pipelines Documentation