Automatic Mappings with AutoMapper

Introduction

Dispite recent announcements by Jimmy Bogard, I'm still using AutoMapper. Maybe it's because I'm used to it and I think it has great features - like mapping LINQ expressions - anyway, I'm sticking to it, for the time being!

A couple things, though, I feel are missing:

  • The ability to automatically map types
  • Dependency injection (DI) of services into Profile classes

So I set out to make these both work! Fortunately, AutoMapper provides many extension points that we can hook to in order to do what we want. So, I'm going to talk a bit about these topics.

In a nutshell, the problem I am to solve is: automatically map types from one assembly into the types in another one, of course, using only conventions. My approach was, use a custom mapper profile (Profile) to go through all types in the source assembly, find out the corresponding types in the target assembly, and add them to the maps.

AutoMapper and Dependency Injection

First things first.  AutoMapper does support DI: when you call the AddAutoMapper extension method, it registers the IMapper class to the DI, which means, we can inject it anywhere:

public class SomeServiceThatDoesMapping(IMapper mapper)
{
    //use it as: mapper.Map<Target>(source);
}

It also supports inecting services into value resolvers, we'll see it now. What is not supported is the injection of services into Profile classes, and I will cover it in a moment.

Dependency Injection into Value Resolvers

Just for completeness, as long as we're registering AutoMapper using AddAutoMapper, we can later use value resolvers to set property or field values, and these value resolvers can themselves, have services injected into them.

An example:

//source and target types, doesn't really matter their contents
class SourceType { /* ... */ }
class TargetType
{
    public string? Foobar { get; set; }
    /* ... */
}

//some service contract which must be registered to DI
public interface IFoobarService
{
    string? GetValue();
}

//register an implementation of IFoobarService into DI
builder.Services.AddSingleton<IFoobarService, FoobarService>();
//register the value resolver
builder.Services.AddSingleton<FoobarValueResolver>();

//this value resolver takes a source of SourceType and a target of TargetType and can be applied of any property of type string?
//it gets injected an instance of IFoobarService which is obtained from DI
public class FoobarValueResolver(IFoobarService service) : IValueResolver<SourceType, TargetType, string?>
{
    public string? Resolve(SourceType source, TargetType destination, string? destMember, ResolutionContext context)
    {
        return service.GetValue();
    }
}

//we hook the value resolver using a Profile and CreateMap/ForMember/MapFrom<>
public class SourceTypeProfile : Profile
{
    public SourceTypeProfile()
    {
        CreateMap<SourceType, TargetType>()
            .ForMember(target => target.Foobar, config => config.MapFrom<FoobarResolver>());
    }
}

So, here we have some service, IFoobarService, that doesn't really interest us, only that it is being injected into the FoobarValueResolver. IFoobarService and FoobarValueResolver must be registered to DI (any lifetime), and FoobarValueResolver is used explicitly inside the SourceTypeProfile class. Seems simple enough. Anyway, we won't be using value resolvers for this solution.

Dependency Injection into Profiles

Now, out of the box, we can't have this:
public class SourceTypeProfile : Profile
{
    public SourceTypeProfile(IFoobarService service)
    {
        CreateMap<SourceType, TargetType>()
            .ForMember(target => target.Foobar, 
    }
}

When AutoMapper tries to instantiate our Profile-based class, it fails, because it does not offer a public parameterless constructor. That's where we enter, I will propose a solution for this with some caveats.

Since AutoMapper does not have this ability, we'll have to do it ourselves: we scan a set of assemblies for Profile types and we instantiate them ourselves, adding to the map collection and instantiating the Profile type using ActivatorUtilities.CreateInstance:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAutoAutoMapper(this IServiceCollection services, Action<AutoMappingOptions>? configureOptions = null)
    {
        ArgumentNullException.ThrowIfNull(services);

        AutoMappingOptions? options = default;

        if (configureOptions != null)
        {
            options ??= new AutoMappingOptions();
            configureOptions(options);
            services.Configure(configureOptions);
        }

        return services.AddAutoMapper((serviceProvider, config) =>
        {
            var profileTypes = (options?.AdditionalAssemblies ?? [])
                .SelectMany(assembly => GetProfileTypes(assembly))
                .ToList();

            foreach (var profileType in profileTypes)
            {
                var profile = (Profile)ActivatorUtilities.CreateInstance(serviceProvider, profileType);
                config.AddProfile(profile);
            }

            config.AddProfile(ActivatorUtilities.CreateInstance<AutoMappingProfile>(serviceProvider));
        }, Array.Empty<Assembly>());
    }

    private static IEnumerable<Type> GetProfileTypes(Assembly assembly)
    {
        return assembly.GetTypes().Where(t => typeof(Profile).IsAssignableFrom(t) && !t.IsAbstract && t.IsClass && t.IsPublic && !t.ContainsGenericParameters);
    }
}

Here the new extension method AddAutoAutoMapper (sorry about the name!) does this:

  1. Registers an options instance, which can be provided as a lambda
  2. Find all Profile types on a list of assemblies that inherit from Profile and are not abstract or generic
  3. Instantiates each using ActivatorUtilities.CreateInstance and adds to the list of profiles
  4. Registers a custom profile, AutoMappingProfile to DI

It's the AutoMappingProfile class that will actually take care of the conventional mappings.

The configuration class AutoMappingOptions will be discussed now.

Mapping Types Automatically

We need a way to pass parameters to the AutoMappingProfile class, and we do so by means of the AutoMappingOptions class. This is what it looks like:

public class AutoMappingOptions
{
    public Assembly? SourceAssembly { get; set; }
    public Assembly? TargetAssembly { get; set; }
    public List<Assembly> AdditionalAssemblies { get; set; } = [];
    public bool Reverse { get; set; } = true;
    public Func<Type, Type, bool> TypeFilter { get; set; } = (source, target) => source.Name.Equals(target.Name);
    public Predicate<Type> ShouldMapType { get; set; } = type => true;
}

It should be pretty straightforward, but the meaning of each property is as follows:

  • SourceAssembly: the assembly that contains the source types (not abstract or generic)
  • TargetAssembly: the assembly that contains the target types
  • AdditionalAssemblies: any additional assemblies to scan for Profile types (not abstract or generic)
  • Reverse: whether or not a reverse map should be created for any found types (default is true)
  • TypeFilter: for each source/target pair, confirm that a mapping should be created (default is if the type names match)
  • ShouldMapType: whether or not to map a given type found on the source assembly (default is true)

Now, we can call the extension method:

builder.Services.AddAutoAutoMapper(options =>
{
    options.SourceAssembly = typeof(Source.Data).Assembly;
    options.TargetAssembly = typeof(Target.Data).Assembly;
    options.Reverse = true; //this is the default
    options.TypeFilter = (source, target) => target.Name == source.Name; //this is the default
    options.ShouldMapType = (type) => true; //this is the default
    //options.AdditionalAssemblies.Add(typeof(OtherProfile).Assembly); //if we want to add mappers from a different assembly
});

And AutoMapper will happily generate a map for each type on the source assembly into corresponding types on the target assembly.

Before I forget, here is the AutoMappingProfile:

public class AutoMappingProfile : Profile
{
    public AutoMappingProfile(IOptions<AutoMappingOptions> options, ILogger<AutoMappingProfile> logger)
    {
        if (options?.Value?.SourceAssembly != null && options?.Value?.TargetAssembly != null)
        {
            var sourceTypes = GetTypes(options.Value.SourceAssembly);
            var targetTypes = GetTypes(options.Value.TargetAssembly);

            var shouldMapType = options.Value.ShouldMapType ?? (type => true);

            foreach (var sourceType in sourceTypes)
            {
                if (!shouldMapType(sourceType))
                {
                    continue;
                }

                var matchedTargetTypes = FindMatchingTypes(sourceType, targetTypes, options.Value.TypeFilter);

                foreach (var matchedTargetType in matchedTargetTypes)
                {
                    if (sourceType == matchedTargetType)
                    {
                        continue;
                    }

                    var expression = CreateMap(sourceType, matchedTargetType);

                    logger.LogInformation("Created map from {SourceType} to {TargetType}, reverse: {Reverse}", sourceType.FullName, matchedTargetType.FullName, options.Value.Reverse);

                    if (options.Value.Reverse)
                    {
                        expression.ReverseMap();
                    }
                }
            }
        }
    }

As you can see, it takes two parameters that are obtained from DI: the AutoMappingOptions and a logger instance. If any option values are missing, it offers some sensible defaults.

Caveats

One problem with this approach is: AutoMapper knows out of the box how to automatically scan assemblies for Profile types; however, if we let it do it, we will run into the issue of AutoMapper not being able to instantiate classes without a public parameterless constructor, like our AutoMappingProfile. But, I think we can live without it, just pass any additional assemblies on the AutoMappingOptions.AdditionalAssemblies property and pretty much the same functionality will be available, with the additional support of Dependency Injection.

Of course, the automatically created mappings only use the default conventions. If we want to, we can register explicit mapping profiles that know how to handle more complex cases.

Conclusion

I understand many alternatives exist for this problem, but this was one I came up with, and which seems to work well for my use case. I can think of some other features, and I may be adding them in the future. I also may want to make this available as a Nuget package; in the meantime, you can find the code here: https://github.com/rjperes/DynamicAutoMapper.

As always, looking forward to hearing your thoughts, so keep them coming!

GitHub: https://github.com/rjperes/DynamicAutoMapper

Nuget: TBD



Comments

Popular posts from this blog

OpenTelemetry with ASP.NET Core

C# Magical Syntax

ASP.NET Core Middleware