Let's imagine that we have a cool domain with some special types.
For instance, in our very simple example, we have a strongly-typed ID to get rid of primitive obsession.
public record struct CoolId(int Value);
public class Cool
{
public CoolId CoolId { get; set; }
public string Title { get; set; }
}
Nice! OK, now let's create an API for this.
Amazing how easy ASP.NET Core is.
var builder = WebApplication.CreateBuilder();
builder.Services.Configure<JsonOptions>(o =>
{
o.SerializerOptions.WriteIndented = true;
});
var app = builder.Build();
app.MapGet("/cool", () =>
new Cool { CoolId = new CoolId(123), Title = "Very Cool!" });
app.Run();
The concern is that CoolId
is not correctly serialized and it should be simplified.
{
"coolId": {
"value": 123
},
"title": "Very Cool!"
}
So, the first reaction is to create a JsonConverter
for it.
Hmm, why not.
public class CoolIdJsonConverter : JsonConverter<CoolId>
{
public override CoolId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new CoolId(reader.GetInt32());
public override void Write(Utf8JsonWriter writer, CoolId value, JsonSerializerOptions options) =>
writer.WriteNumberValue(value.Value);
}
builder.Services.Configure<JsonOptions>(o =>
{
o.SerializerOptions.WriteIndented = true;
o.SerializerOptions.Converters.Add(new CoolIdJsonConverter());
});
And voila!
{
"coolId": 123,
"title": "Very Cool!"
}
Drawbacks
The big issue with this approach and custom serializers in general is that the entire logic is hidden in the wilds of the serializer library.
This leads to the following problems:
- Very bad model discovery. It's pretty hard to read and guess the model from the custom serializer. Especially in big payloads when the construction depends on many logics.
- Highly coupled with the serializer library.
- Not compatible with OpenAPI/Swagger generation.
- Logic duplication if we want to provide other formats such as Xml or Yaml.
- Main blocking point when we want to switch to another library. For instance Json.Net to System.Text.Json.
- Not convenient to test raw strings.
- Reflection hell.
- We usually end up creating a client library (NuGet) for the API which is a bad practice and the opposite of the OpenAPI and Restful philosophy.
Mapping > Custom Serialization
With a simple mapping we can fix all the previous drawbacks.
public class CoolModel
{
public int CoolId { get; set; }
public string Title { get; set; }
}
public static class CoolModelMapper
{
public static Cool ToDomain(this CoolModel model) => new Cool
{
CoolId = new CoolId(model.CoolId),
Title = model.Title,
};
public static CoolModel ToModel(this Cool domain) => new CoolModel
{
CoolId = domain.CoolId.Value,
Title = domain.Title,
};
}
What is handy with this pattern is the possibility to make a composition of ToModel
or ToDomain
. For instance, we can replace domain.CoolId.Value
with domain.CoolId.ToModel()
and so forth.
app.MapGet("/", () =>
new Cool { CoolId = new CoolId(123), Title = "Very Cool!" }
.ToModel());
Conclusion
JSON is simple, and so should its model.
The examples were in C# but it's also true for F# or any other languages.
Top comments (0)