AutoMapper Madness - Nuances in Self-Mapping

September 13, 2022#Software Development
Article
Author image.

Scott DePouw, Senior Consultant

This is the third part of my blog post series on AutoMapper. First part here! Second part here!

AutoMapper Allows Self-Mappings Without Map Creation

Typically in AutoMapper, if you don’t set up a mapping via CreateMap<Foo, Bar>() in your Profile class, AutoMapper will throw an exception when you try to call _mapper.Map<Bar>(myFoo), complaining that you didn’t create a mapping between those two types. However, let’s say you wanted to get a new instance of the same type. AutoMapper allows this without any mappings created! You can call Foo newFoo = _mapper.Map<Foo>(oldFoo) and AutoMapper will happily return a shiny new Foo object for you.

But is it really a new instance?

Nope!

You’re actually getting the same instance back, as AutoMapper is copying the references over to your new object!

Let’s say we have the following classes:

namespace MyApp.Core;

public class ExampleClass
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ExampleSubClass Sub { get; set; }
}

public class ExampleSubClass
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Without configuring AutoMapper, the following unit test would pass:

using AutoMapper;
using MyApp.Core;
using MyApp.Core.AutoMapper;
using Xunit;

namespace MyApp.UnitTests.AutoMapper;

public class MapExampleClassToItselfShould
{
    private readonly IMapper _mapper = AutoMapperConfig.Initialize();
    
    [Fact]
    public void CopyByReferenceForUnmappedSelfMapping()
    {
        ExampleClass foo = new ExampleClass
        {
            Id = 7,
            Name = "Foo",
            Sub = new ExampleSubClass { Id = 17, Name = "SubFoo" }
        };

        ExampleClass bar = _mapper.Map<ExampleClass>(foo);
        
        // The class itself, as well as all its reference properties, are the same referene.
        Assert.Same(foo, bar);
        Assert.Same(foo.Name, bar.Name);
        Assert.Same(foo.Sub, bar.Sub);

        // Changing value type properties in bar reflect back to foo.
        bar.Id = 5;
        bar.Sub.Id = 15;

        Assert.Equal(5, foo.Id);
        Assert.Equal(15, foo.Sub.Id);
    }
}

XUnit’s Assert.Same() will fail if the two objects given are two different instances. (Its counterpart, Assert.NotSame(), does the opposite.) As you can see, both foo and bar in this example are the same instance. This applies recursively for all mapped properties (strings, ExampleSubClass Sub, etc.), as all reference types AutoMapper creates are just referencing the original object. How lazy of AutoMapper!

Unexpected Behavior Can Lead to Bugs

Since AutoMapper usually won’t do un-mapped mapping calls, this behavior can lead to subtle bugs in your code. You may expect AutoMapper to fail for any undeclared mappings. You may assume that you’re getting a new instance and thus can make changes without impacting the original object. (Consider having an EntityFramework object from your database, and subtly making changes to the original, and calling SaveChanges(): If you’re lucky, you’ll get an error (can’t insert object with duplicate Id, re-adding already-tracked entity, etc.) If not so lucky, you could end up just updating the existing record instead of making a new one!)

There are likely other scenarios where this behavior can cause bugs. So, why does AutoMapper break apparent convention here and allow it? Simple: The creator himself Jimmy Bogard calls AutoMapper “A lousy solution for cloning”. Why? Because it’s the “most obvious and performant behavior” is to simply assign same-type objects to each other. As such…

Aside: Need Deep Copies? Don’t Use AutoMapper!

This is out of scope for this post, but AutoMapper is not optimal for deep copying. A pattern of serializing/deserializing your object is the most effective way to perform a deep copy. BinaryFormatter was a go-to for a while, but it is obsolete. You can use JSON (de)serialization to accomplish a similar goal. Whatever you attempt, isolate your methodology behind an interface (e.g. IDeepCopier) to make your code’s intentions clear, and make sure all those unit tests against your DeepCopier implementation pass!

Force AutoMapper to Make a Copy

If you don’t have a super complex object and want to use AutoMapper anyway, you can make it behave the way you expect it to! There’s no trickery involved here: Just call CreateMap() referencing the same type for both the source and destination:

CreateMap<ExampleClass, ExampleClass>();

For any sub-types that you also want to fully map, those need declared as well:

CreateMap<ExampleSubClass, ExampleSubClass>();

If we do that within our AutoMapper Profile class, then the unit test needs changes in order to keep passing!

[Fact]
public void SelfMappingWithExplicitSelfMap()
{
    ExampleClass foo = new ExampleClass
    {
        Id = 7,
        Name = "Foo",
        Sub = new ExampleSubClass { Id = 17, Name = "SubFoo" }
    };

    ExampleClass bar = _mapper.Map<ExampleClass>(foo);

    // These references are no longer the same instance.
    Assert.NotSame(foo, bar);
    Assert.NotSame(foo.Sub, bar.Sub);

    // Strings are still the same reference since it's an unmapped reference type.
    Assert.Same(foo.Name, bar.Name);

    // Changing value/explicitly-mapped properties no longer reflects back to foo!
    bar.Id = 5;
    bar.Sub.Id = 15;
    Assert.Equal(7, foo.Id);
    Assert.Equal(17, foo.Sub.Id);
}

I’ve had success with this simple solution, since in my scenarios deep copying wasn’t the goal. I just needed a new instance of a flat object. Of course, I wouldn’t want to provide some implementation without unit tests proving that AutoMapper’s giving me a new instance! How’d I go about doing that?

Another Base Class for AutoMapper Unit Testing

They’re starting to pile up now, but this is another example of abstracting common testing behavior for AutoMapper into a base class. We want to define the type we’re self-mapping, and assure that when making calls to AutoMapper we’re getting a fresh instance and not the same one back. We’ll lean on Assert.NotSame(), and otherwise copy the general idea from the base class we made in my previous post.

using AutoMapper;
using MyApp.Core.AutoMapper;
using Xunit;

namespace MyApp.UnitTests.AutoMapper;

public abstract class AutoMapperSelfMappingTestBase<TSelfMappingType>
    : AutoMapperTestBase<TSelfMappingType, TSelfMappingType>
    where TSelfMappingType : new()
{
    [Fact]
    public void CreateNewInstance()
    {
        TSelfMappingType originalInstance = new();

        TSelfMappingType newInstance = _mapper.Map<TSelfMappingType>(originalInstance);

        Assert.NotSame(originalInstance, newInstance);
    }
}

// Has two unit tests out of the box:
// - MapExampleClassToItselfShould.MapWithoutThrowing
// - MapExampleClassToItselfShould.CreateNewInstance
public class MapExampleClassToItselfShould : AutoMapperSelfMappingTestBase<ExampleClass>
{ }

MapExampleClassToItselfShould is now configured to run the same MapWithoutThrowing test we defined in AutoMapperTestBase previously. Inserting another base class in between, AutoMapperSelfMappingTestBase, unlocks another test: Verify that a call to AutoMapper will give us a new instance. Prior to CreateMap<ExampleClass, ExampleClass>() being added to our Profile, MapWithoutThrowing will pass, but CreateNewInstance will fail. After adding the explicit mapping, both tests pass!

But what about ExampleSubClass Sub { get; set; }? In this particular scenario, we want to verify this sub class is being mapped as well. I don’t need to extend MapExampleClassToItselfShould with another test, however: As it’s another mapping I want to self-map, it would get its own class. I would then repeat this process for any other properties I cared about. (Reminder: If you want a true deep copy and would otherwise have to make a bunch of self-mappings: Don’t use AutoMapper!)

public class MapExampleSubClassToItselfShould : AutoMapperSelfMappingTestBase<ExampleSubClass>
{ }

In the scenarios where I care about self-mappings creating a new instance, I write these test classes ASAP, so that my test suite instantly fails until I make the code do what I want it to do. Since AutoMapper has this little quirk of not failing on a Map() call for self-mappings (unlike non-self-mappings), this gives me a form of automation to not let potential bugs seep in!

Wrap-Up

That’ll do it for my series of AutoMapper blog posts! In this third and final part, we explored a potential gotcha when using AutoMapper for self-mappings, its impacts, and how to make it behave in a more expected fashion. We learned why AutoMapper does this, why you shouldn’t use AutoMapper for deep copy scenarios, and how to unit test our code to assure we’re getting new instances when expected.


Copyright © 2024 NimblePros - All Rights Reserved