AutoMapper Madness - My Mapping Flowchart

August 20, 2024#Software Development
Article
Author image.

Scott DePouw, Senior Consultant

This is part of my blog post series on AutoMapper.

The series returns! Part 5 of my AutoMapper series is brought to you today thanks to a discussion that bubbled up in the devBetter Discord server. In this series I’ve already discussed how and when to use AutoMapper, but what do you do when your mappings go beyond AutoMapper? Can you still use AutoMapper? What are some good ways of handling complex mappings? This part of the series aims to answer those questions.

Setup

I won’t go in depth on initializing or configuring AutoMapper, as previous posts in the series cover it. We do need an example to work with, though, so let’s have a Person record representing a person within our backing data store.

public record Person(int Id, string FirstName, string LastName, string FavoriteFood);

In the scenarios that follow we’ll be needing various versions of PersonDTO. Each variant will feature a different situation to address, and you’ll see how I go about creating these DTOs.

Scenario 1: The Perfect Fit

Let’s start with the simplest kind of DTO: One that is practically identical to our entity.

public record PersonDTO(string FirstName, string LastName, string FavoriteFood);

This is the ideal case for AutoMapper: Every property shares the exact same name and type from the source Person record, and the conventional automatic mappings are done for us. The only difference is that we don’t send the data store’s ID (ideally something never shared publicly) along with the DTO. There’s nothing for us to do here! I wanted to start us off with the “no-op” scenario first, as our jumping-off point.

Scenario 2: Properties Calculated from Other Properties

Let’s envision a slightly more complex PersonDTO:

public record PersonDTO(string FirstName, string LastName, string FavoriteFood)
{
  public string FullName { get; set; } = "";
}

The recipient of the DTO wants a FullName property. You may be tempted to add this calculation to the AutoMapper profile:

CreateMap<Person, PersonDTO>().ForMember(dto => dto.FullName, opt.MapFrom(person => $"{person.FirstName} {person.LastName}");

This would work and the DTO would have a properly hydrated FullName property… but even this trivial complexity shouldn’t be squirreled away in AutoMapper. Any other construction of a PersonDTO will have to duplicate this logic. It also pulls AutoMapper (however slightly) away from a purely conventional approach. Ideally, you only supply AutoMapper profiles with special rules for simple matters, like mapping differently named properties to each other, or transforming the type.

Any property on the DTO that can be calculated from other properties the DTO has, can just go on the DTO itself. These are trivial to unit test, and now your DTO will always have a proper FullName even when not mapping directly from Person via AutoMapper.

public record PersonDTO(string FirstName, string LastName, string FavoriteFood)
{
  public string FullName => $"{FirstName} {LastName}";
}

And within AutoMapper’s profile, ignore FullName:

CreateMap<Person, PersonDTO>().ForMember(dto => dto.FullName, opt.Ignore());

Alternatively, if you wish to keep all logic in your entities or have other places you want a person’s full name, you can have FullName live on Person, and the DTO can receive it as a settable property (no need to ignore it in AutoMapper).

public record Person(string FirstName, string LastName, string FavoriteFood)
{
  public string FullName => $"{FirstName} {LastName}";
}

public record PersonDTO(string FirstName, string LastName, string FavoriteFood, string FullName);

In either case, you don’t have to pollute AutoMapper with these sorts of calculations, as the DTO or entity can take care of them themselves.

Scenario 3: Combining Data from Other Available Objects

Let’s have a DTO that has extra info not directly available on Person. The DTO gets AverageScore, which earlier hypothetical code can get via a List<int> Scores data set that was provided or retrieved somewhere else.

public record PersonDTO(string FirstName, string LastName, string FavoriteFood)
{
  public double AverageScore { get; set; }
}

// Other code
int personId = 42;
List<int> scores = GetScoresById(personId)
Person person = GetById(personId);
PersonDTO dto = mapper.Map<PersonDTO>(person);
dto.AverageScore = ... // Calculate average score
return dto;

Let’s hope you’re not tempted to go fetch those scores within AutoMapper! You absolutely should never ever have external dependencies in an AutoMapper profile. That’s a massive code smell!

So, what do we do here? We want to use AutoMapper to do the conventional things but need to calculate an average score in a consistent manner. If other code has a Person and a list of scores, right now every instance would have to individually calculate.

In scenarios where you have all your input data to create a DTO or some other form of output, I create a Factory class to get the job done.

public interface IPersonFactory
{
  PersonDTO Create(Person person, List<int> scores);
}

This is not dissimilar to the Factory Method Pattern, but I take things a step further. For me, a “Factory” class is one consisting only of pure functions, never reaching out to other sources internally, to create a new object instance. This scenario provides a great example where we can make one, so that any code in our application needing a PersonDTO with an average score has a single line to call. Let’s implement IPersonFactory!

public class PersonFactory(IMapper mapper) : IPersonFactory
{
  public PersonDTO Create(Person person, List<int> scores)
  {
    PersonDTO dto = mapper.Map<PersonDTO>(person); // Create a DTO with the conventional mappings accounted for
    dto.AverageScore = scores.Count > 0 ? scores.Average() : 0; // The only spot in our application where we calculate this
    return dto;
  }
}

Now any code that requires this PersonDTO need only depend on, and call, the factory class:

// Other code
int personId = 42;
List<int> scores = GetScoresById(personId)
Person person = GetById(personId);
return personFactory.Create(person, scores);

Note that while this other code now takes on a dependency on IPersonFactory, it no longer needs IMapper. It’s a wash since the number of dependencies hasn’t changed, but we’ll see this concept taken to its fullest extent in our next and final scenario!

Scenario 4: Combining Data from Objects Fetched from Elsewhere

Our other code was burying the lede somewhat. Where exactly is it getting scores from? What data store has the Person record in the first place? What if we needed even more data beyond that? Let’s push our PersonDTO to the limit by adding a final property that we must fetch from a third data source.

public record PersonDTO(string FirstName, string LastName, string FavoriteFood)
{
  public double AverageScore { get; set; }
  public int NumberOfFilesStored { get; set; }
}

In this scenario, in order to make a PersonDTO we require data from three sources:

  • The Person record from the database
  • AverageScore, calculated from scores obtained from a web API
  • NumberOfFilesStored, counted from files in the Person’s directory on disk

Unfortunately, I have seen a scenario where all of this data-gathering and calculation occurred within AutoMapper. AutoMapper is now a full-blown service doing all kinds of things, when AutoMapper should not be given more than its single responsibility! In this application it made other developers loathe AutoMapper, because it would throw bizarre exceptions and was unwieldy and incredibly difficult to test and rely on. Remember that the sole purpose of AutoMapper is to conventionally create one kind of object from another. Overloading it changes its purpose, adds additional responsibilities, and overall makes it not fun to work with.

So, what do we do? We want consuming code to only rely on a single means of getting a fully hydrated PersonDTO instance. In the beginning, that only needed a call to AutoMapper. Then, it evolved to needing a pure function from a Factory class. Now, there are multiple dependencies needed to gather all the data, which then needs to be calculated and brought together.

Let’s make a single class to orchestrate all of this.

public interface IPersonCreator
{
  PersonDTO Create(int personId);
}

Our IPersonCreator can be a dependency taken on by any consuming code, to trivially create a PersonDTO. Note that this was not called a “Factory” because it will not be a pure function.

With our interface, any consuming code can easily get a PersonDTO instance, without caring about how it’s put together.

public class SomeAPIEndpoint(IPersonCreator personCreator)
{
  public PersonDTO GetById(int personId) => personCreator.Create(personId);
}

What normally would have taken a whole slew of dependencies and calculations is now reduced to one line. And the code that does orchestrate all this stuff is now in a single, easily testable, location:

public class PersonCreator(IRepository<Person> personRepository, IScoresAPI scoresAPI, IFileSystem fileSystem, IPersonFactory personFactory)
  : IPersonCreator
{
  public PersonDTO Create(int personId)
  {
    // Orchestrate our dependencies, calling them in the correct order and passing data from one to the other
    Person person = personRepository.GetById(personId);
    List<int> scores = scoresAPI.GetScoresForPerson(personId);
    int filesOnDiskCount = fileSystem.GetPersonDirectory(personId).Files.Count;
    return personFactory.Create(person, scores, filesOnDiskCount);
  }
}

// Modify IPersonFactory to take the file count in
public interface IPersonFactory
{
  PersonDTO Create(Person person, List<int> scores, int filesOnDiskCount);
}

public class PersonFactory(IMapper mapper) : IPersonFactory
{
  public PersonDTO Create(Person person, List<int> scores, int filesOnDiskCount)
  {
    PersonDTO dto = mapper.Map<PersonDTO>(person);
    dto.AverageScore = scores.Count > 0 ? scores.Average() : 0;
    dto.NumberOfFilesStored = filesOnDiskCount;
    return dto;
  }
}

You may, if you wish, inline the PersonFactory into the PersonCreator. This largely depends on how complex each piece of the puzzle is, but generally I still like to keep it separate like this:

  • PersonCreator orchestrates
  • PersonFactory calculates

In the End, AutoMapper Still Useful

Getting back on track, you’ve noticed that in every scenario that we still use AutoMapper! You don’t have to abandon the tool just because you may have some properties that require more complex calculations to run or other data to fetch. Imagine a Person with 30 properties that are one-to-one mappings with the DTO and just a couple other one-offs that require more. Without AutoMapper you’re back to manually mapping all the things, which is what AutoMapper helps you bypass. Like any other dependency, you can compose various pieces of code and dependencies together, so that each individual piece is easy to test and maintain. In fact, let’s add it to our list of classes and responsibilities:

  • PersonCreator orchestrates
  • PersonFactory calculates
  • AutoMapper conventionally maps

You might not instantly know from the beginning “I’m going to have a creator and a factory and use AutoMapper.” That’s okay! It’s entirely likely your first AutoMapper use-case is the first, easiest scenario. Then as more functionality and features are added to your application, you’ll grow into the other classes, sensing the divisions of responsibility and branching your code out from there. Any application being developed is continually evolving. Ideally, these classes emerge as you write (and if you’re unit testing along the way, these seams become self-evident, as large classes doing too much are much harder to test).

AutoMapper still has its place, and it (or any other mapping tool) can be used alongside other portions of code to create a single unified experience for any consuming code in your application.


Copyright © 2024 NimblePros - All Rights Reserved