Let me introduce the problem, we have an API that accepts multiple values in one field, let’s assume we can have a request model and inside there is a field named animal
like:
{
// other properties
"animal":
{
"type":"dog",
// other specific dog properties
}
}
or
{
// other properties
"animal":
{
"type":"cat",
// other specific catproperties
}
}
Now, we have layers, where we receive view model -> we transform it into dto for application layer -> and finally to db model, and vice versa for reads: db model -> dto model -> view model. We have created extension methods to map those to different layer model, eg.
internal static AnimalResponse ToDto(this AnimalReadModel dbModel)
{
// mapping other properties
Animal = MapAnimal(dbModel)
}
private static IAnimal MapAnimal(IAnimalDb dbAnimal)
{
return dbAnimal switch
{
DogDb dog => new DogDto {//mapping dog specific props},
CatDb cat => new CatDto {//mapping cat specific props}
}
}
and we do that for all layers, so they look extremelly similar, but we have to multiply Cat/Dog/Other in all layers, when we want to add new animal we also need to change all those places. This seems like major solid issue we would like to solve.
And as a bonus, we have asynchronous communication between services where we send byte array, and in this solution we have used json converter (system.text.json) for another different model (but also very similar) like:
public class AnimalQueueModelConverter : JsonConverter<IAnimalQueueModel>
{
public override IAnimalQueueModel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var jsonObject = JsonElement.ParseValue(ref reader);
var animal = jsonObject.GetProperty("type").ToString() switch
{
AnimalQueueType.Dog => jsonObject.Deserialize<DogQueueModel>(),
AnimalQueueType.Cat => jsonObject.Deserialize<CatQueueModel>()
// if more will come we need extend this as well
}
}
public override void Write(Utf8JsonWriter writer, IAnimalQueueModel value, JsonSerializerOptions options)
{
switch (value)
{
case DogQueueModel:
// here write this model using writer
case CatQueueModel:
// here write this model using writer
}
}
}
And even tho, in the "reader" class after someone deserialize the byte[] they still need to create this stupid if cat()
, if dog()
, if hippo()
..
So we have so many places to edit when we need to add another animal to our system. Can someone give me a pattern, lib, example what we could use to make this code better to maintain? I was thinking about disctiminators, but not sure how to propagate them. I think someone already had similar problem and there is a better solution to this.
Thanks
2
Answers
@Trinny, hi – following up on my comment in more detail.
Your main "complaint" is to have to add an animal class (that implements the IAnimal interface) and then go to the
AnimalQueueModelConverter
(write
method) and theMapAnimal
method and add the "if"/"switch" options.To not need to do that i gave 2 approachs to solve it. One is to use this:
As you can find here – https://stackoverflow.com/a/49006805/3271820 – use that list on both methods that need to cicle through the animals.
By using this you’ll never need to add anthing else, but the animal class. But to instanciate the animal by it’s type you’ll have to use the System.Activator class (https://learn.microsoft.com/en-us/dotnet/api/system.activator?view=net-7.0). I dislike this approach a bit because of it (and cicling through the types is also not the best job to do).
In the "factory" approach however, you can make 1 "fatory" class, that will have the "if"/"switch" to cicle through. Or better yet, a list of type and delegate that you can just add to: something like this:
Then every time you add an animal class you just add it to that list (when the program starts) and have both the
write
andMapAnimal
methods just cicle through that list (seeking for thewhenDB
orwhenView
as they need to) and calling the action delegate to get a new instance of it. Then i’d add the prop mapping and base writing (parts that you commented) as methods of the IAnimal interface to implement and call for then.Doing any of those would allow you to just add an animal class, implement IAnimal and either do just that or add that class to a list on startup. That would be all you’ll ever need to do when adding a new animal, and never having to find all the places where you have conditions based on animal types.
About the JSON (de)serialization
Starting from version
7.0.0
System.Text.Json
supports polymorphic serialization.(This version also runs on
.NET 6
.)In a nutshell, the JSON payload contains a (configurable) discriminator property.
Below JSON shows an example for a dog and a cat.
The documentation has the following important note about this discriminator.
The
JsonSerializer
takes care of that.Next you define an interface (or base class) that all concrete models implement.
The applied
JsonPolymorphic
attribute defines the discriminator property, heretype
.For each concrete type a
JsonDerivedType
attribute gets applied defining that concrete type and the corresponding value for the discriminator.In below example the
CatQueueModel
gets linked to the typecat
andDogQueueModel
todog
.All concrete types implement that
IAnimalQueueModel
interface.Note that the
type
discriminator property doesn’t need to be a property on the model itself.When deserialing, specify the
IAnimalQueueModel
interface asT
when callingJsonSerializer.Deserialize<T>(...)
.The following example deserializes given
JSON
into aDogQueueModel
instance. It’s thetype
in theJSON
payload that decides over the concrete result type.This polymorphic handling is not limited to the root model matching a
JSON
payload, it’s also applicable for properties on a class.Below example shows a model with an
Animal
property, which will hold aCatQueueModel
instance after deserialization of givenJSON
string.In case an attribute based setup is not practical, then you can configure the same with the contract model.
About the mappings
You might want to take a look at
AutoMapper
(documentation).With
AutoMapper
you define a mapping configuration that specifies which types – classes and interfaces – you want to map to other types, optionally including details about how properties should be handled.For a polymorphic mapping setup, you define the interface mapping (1st one in below example) and a mapping for each concrete type (2nd and 3rd in below code) .
Below example shows how to map classes implementing the
IAnimalQueueModel
interface to classes that implement theIAnimalDto
interface to achieve the mapping of aDogQueueModel
to aDogDto
and aCatQueueModel
to aCatDto
.To apply a mapping, you need an
IMapper
instance – dependency injection is possible.The code below shows how to map an
IAnimalQueueModel
instance being of concrete typeDogQueueModel
to anIAnimalDto
type, which will result in an instance of typeDogDto
.Conclusion
When you introduce a new animal type you "just" have to add a
JsonDerivedType
attribute and set up the necessaryAutoMapper
mapping(s).