skip to Main Content

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


  1. @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 the MapAnimal 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:

    private static IList<Type> loadAllImplementingTypes(Type[] interfaces)
    {
        IList<Type> implementingTypes = new List<Type>();
    
        // find all types
        foreach (var interfaceType in interfaces)
            foreach (var currentAsm in AppDomain.CurrentDomain.GetAssemblies())
                try
                {
                    foreach (var currentType in currentAsm.GetTypes())
                        if (interfaceType.IsAssignableFrom(currentType) && currentType.IsClass && !currentType.IsAbstract)
                            implementingTypes.Add(currentType);
                }
                catch { }
    
        return implementingTypes;
    }
    

    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:

    public static class AnimalFactory
    {
        public delegate TAnimal constructNew<TAnimal>() where TAnimal : IAnimal;
    
        public class Binding
        {
            public IAnimalDb whenDB;
            public IAnimalQueueModel whenView;
            public AnimalFactory.constructNew action;
        }
    
        public static List<AnimalFactory.Binding> animals = new List<AnimalFactory.Binding>();
    }
    

    Then every time you add an animal class you just add it to that list (when the program starts) and have both the write and MapAnimal methods just cicle through that list (seeking for the whenDB or whenView 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.

    Login or Signup to reply.
  2. 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.

    { "type" : "dog", "name" : "Max", "color": "gold" }
    { "type" : "cat", "name" : "Felix", "favoriteFood": "fish" }
    

    The documentation has the following important note about this discriminator.

    The type discriminator must be placed at the start of the JSON object

    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, here type.

    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 type cat and DogQueueModel to dog.

    [JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
    [JsonDerivedType(typeof(CatQueueModel), "cat")]
    [JsonDerivedType(typeof(DogQueueModel), "dog")]
    public interface IAnimalQueueModel
    { 
        public string Name { get; set; }
    }
    

    All concrete types implement that IAnimalQueueModel interface.
    Note that the type discriminator property doesn’t need to be a property on the model itself.

    public class CatQueueModel : IAnimalQueueModel
    {
        public string Name { get; set; }
    
        public string FavoriteFood { get; set; }
    }
    
    public class DogQueueModel : IAnimalQueueModel
    {
        public string Name { get; set; }
    
        public string Color { get; set; } 
    }
    

    When deserialing, specify the IAnimalQueueModel interface as T when calling JsonSerializer.Deserialize<T>(...).

    The following example deserializes given JSON into a DogQueueModel instance. It’s the type in the JSON payload that decides over the concrete result type.

    var options = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };
    
    var dog = JsonSerializer.Deserialize<IAnimalQueueModel>(
        @"{ ""type"":""dog"", ""name"": ""Max"", ""color"": ""gold"" }",
        options);
    Console.WriteLine($"{dog.Name} ({dog.GetType().Name})"); // Max (DogQueueModel)
    

    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 a CatQueueModel instance after deserialization of given JSON string.

    public class Data
    {
        public IAnimalQueueModel Animal { get; set; }
    
        public string Code { get; set; }
    }
    
    var data = JsonSerializer.Deserialize<Data>(
        @"{
            ""code"": ""foo"",
            ""animal"": { ""type"":""cat"", ""name"": ""Felix"", ""favoriteFood"": ""fish"" }
        }", 
        options
        );
    

    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.

    public interface IAnimalDto
    {
        public string Name { get; set; }
    }
    
    public class CatDto : IAnimalDto
    { 
        public string Name { get; set; }
        
        public string FavoriteFood { get; set; }
    }
    
    public class DogDto : IAnimalDto
    {
        public string Name { get; set; }
        
        public string Color { get; set; }
    }
    

    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 the IAnimalDto interface to achieve the mapping of a DogQueueModel to a DogDto and a CatQueueModel to a CatDto.

    var config = new MapperConfiguration(
        cfg => 
        {
            cfg.CreateMap<IAnimalQueueModel, IAnimalDto>();
    
            cfg.CreateMap<DogQueueModel, DogDto>()
               .IncludeBase<IAnimalQueueModel, IAnimalDto>();
    
            cfg.CreateMap<CatQueueModel, CatDto>()
               .IncludeBase<IAnimalQueueModel, IAnimalDto>();
       });
    

    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 type DogQueueModel to an IAnimalDto type, which will result in an instance of type DogDto.

    var mapper = config.CreateMapper();
    
    IAnimalQueueModel dog = ... /* The DogQueueModel from above */
    var dogDto = mapper.Map<IAnimalDto>(dog);
    Console.WriteLine($"{dog.Name} ({dogDto.GetType().Name})"); // Max (DogDto)
    

    Conclusion

    When you introduce a new animal type you "just" have to add a JsonDerivedType attribute and set up the necessary AutoMapper mapping(s).

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search