AutoMapper Without the Pain - A Few Opinionated Rules

March 17, 2025#Software Development
Article
Author image.

Kevin Lloyd, Senior Consultant

Let’s settle this once and for all: AutoMapper isn’t evil, and the ‘just write the mapping code by hand’ crowd isn’t crazy either. After a decade of watching .NET developers swing between blindly mapping everything and rage-quitting mappers entirely (usually right after discovering their carefully crafted domain model was secretly transformed into a null-property wasteland), I’ve found the sweet spot. A few very opinionated rules let you enjoy all the benefits of AutoMapper without the maintenance nightmares that made you consider chucking the whole thing in the trash and manually mapping everything.

If you’ve been living under a rock, and don’t know about the AutoMapper library, well…you’re in luck. Reading this might save you some grief.

On a recent episode of .NET Rocks! Jimmy Bogard (the author of AutoMapper) joked that this is how he introduces himself at conferences (paraphrasing):

I’m Jimmy Bogard, author of AutoMapper. You’re welcome or I’m sorry.

This shows a clearly documented love/hate relationship with this library.

The Problem with Mappers 🚨

Before I jump into my rules, let’s talk about why mappers can be such a pain:

1. Hidden business logic

We’ve all been there - mapping configurations tucked away in some startup class, far from the code they affect. When something breaks, good luck finding where that weird transformation is happening!

One way to get around this is to co-locate your mapping configurations (AutoMapper Profiles) with your DTOs (Data Transfer Objects). Something along the lines of Vertical Slice Architecture. That way, the mapping lives next to the structures that it’s mapping into. But let’s be honest, how often have we seen this in effect in a code base?

2. Data loss

Ever mapped a DTO back into an Entity Framework object and watched half your navigation properties mysteriously vanish? If not, then I’m happy for you. But, I’ve experienced this more than once, and it’s not fun.

Depending on various factors (which properties are defined on your DTO, which ones are missing, whether properties are ignored or not, ORM configuration, etc.) mapping from a DTO back into an entity used with your ORM, you may, inadvertently, experience data loss due to navigation properties or foreign key properties accidentally getting nulled (or zeroed) out.

3. Complexity creep

What starts as a simple property-to-property mapping inevitably grows tentacles - custom resolvers, conditional logic, format transformations… pretty soon you’re debugging a monster.

My Rules for Simple Mapping 🧩

After banging my head against mapper-related bugs too many times to count, I’ve settled on a few simple principles that keep things manageable:

Rule 1: Only rely on implicit mapping rules

Here’s my first rule in plain English: if the properties don’t match by name or they need fancy transformations, don’t use a mapper. Full stop. Map it manually!

Good: Matching property names, flattening nested objects: Customer.NameCustomerDto.Name Customer.Address.CityCustomerDto.AddressCity // flattening

Avoid: Custom value resolvers, conditional mapping logic, complex type conversions

The implicit mappings in AutoMapper will get you pretty far! Of course, the one-to-one property mappings are handled. But, you also get goodies like nested object flattening and free null checking.

When you need custom logic for transforming data, that’s a big red flag. Don’t hide it in a mapping configuration where future developers (or future you) will never find it. Put it where it belongs - in your domain or application layer.

If you need custom logic in your DTO or ViewModel, there’s a better way. Map the basic fields you need, then add methods to handle the computed stuff. For example, if you need a full name, do this:

public string FullName() => $"{FirstName} {LastName}";

Or use an extension method if that fits better with your style. Just keep that logic visible!

Example: Good vs. Bad Mapping Configuration

Good: Implicit Mapping

// Domain class
public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Address Address { get; set; }
    public DateTime CreatedDate { get; set; }
}

public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string ZipCode { get; set; }
}

// DTO with matching names and flattened structure
public class CustomerDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string AddressStreet { get; set; }
    public string AddressCity { get; set; }
    public string AddressZipCode { get; set; }
    public DateTime CreatedDate { get; set; }
}

// AutoMapper: Simple configuration with flattening
public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<Customer, CustomerDto>();
    }
}

Bad: Business Logic in Mappers

// Bad: Hiding business logic in mapper configuration
public class ComplexMappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<Customer, CustomerDto>()
            .ForMember(dest => dest.Name, opt => 
                opt.MapFrom(src => src.Name.ToUpperCase()))
            .ForMember(dest => dest.IsPreferred, opt => 
                opt.MapFrom(src => CalculatePreferredStatus(src)));
    }
    
    private bool CalculatePreferredStatus(Customer customer)
    {
        // Complex business logic hidden in the mapper
        return customer.Orders.Count > 10 && 
               customer.TotalSpent > 1000 && 
               (DateTime.Now - customer.LastOrderDate).TotalDays < 90;
    }
}

Look at that “bad” example. Yikes! Important business logic is buried in the mapper configuration. When requirements change for determining preferred status, who’s going to know to look there? This code belongs in the domain model or a service where anyone can find it.

Better approach for derived properties:

// DTO with explicit methods for derived properties
public class CustomerDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int OrderCount { get; set; }
    public decimal TotalSpent { get; set; }
    public DateTime? LastOrderDate { get; set; }
    
    // Business logic lives in the DTO, not in the mapper
    public bool IsPreferred => 
        OrderCount > 10 && 
        TotalSpent > 1000 && 
        LastOrderDate.HasValue && 
        (DateTime.Now - LastOrderDate.Value).TotalDays < 90;
        
    public string AgeGroup() => DateOfBirth.CalculateAgeGroup();
}

// Extension method approach
public static class DateTimeExtensions
{
    public static string CalculateAgeGroup(this DateTime dob)
    {
        var age = DateTime.Now.Year - dob.Year;
        if (age < 18) return "Minor";
        if (age < 65) return "Adult";
        return "Senior";
    }
}

Rule 2: Only map outwards from your domain

Here’s rule #2: Map TO DTOs, never FROM DTOs into your domain objects.

Good: Domain → DTO, Entity → ViewModel

Avoid: DTO → Domain, ViewModel → Entity

This one-way street approach prevents so many headaches. When you need to create or update domain objects, do it explicitly through constructors or methods that make the intent clear and enforce your business rules properly.

Now I know AutoMapper supports Reverse Mapping, but trust me, just don’t. You’ll thank me later. The bugs aren’t worth it.

Entity Framework Example: The Dangers of Inward Mapping

Want to see how mapping into domain objects can blow up in your face? Here’s a real-world example with Entity Framework:

// Domain entity tracked by EF Core
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int CategoryId { get; set; }  // Foreign key
    public Category Category { get; set; }  // Navigation property
    public ICollection<ProductTag> Tags { get; set; }
}

// DTO from API request
public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public List<ProductTagDto> Tags { get; set; }
}

// Problematic code - mapping inward to domain entity
public async Task UpdateProduct(ProductDto dto)
{
    var product = await _dbContext.Products
        .Include(p => p.Tags)
        .Include(p => p.Category)
        .FirstOrDefaultAsync(p => p.Id == dto.Id);
        
    if (product == null)
        throw new NotFoundException("Product not found");
    
    // DANGER: This will clear CategoryId!
    _mapper.Map(dto, product);
    
    await _dbContext.SaveChangesAsync();  // Potential data loss
}

Imagine now, we pull a Product from the database and send a ProductDto to the UI in an edit form. That form is only responsible for setting Name and Price (the other properties are not bound). That means when they get posted back, they come in as their defaults.

In this example, mapping the DTO to the entity will:

  1. Set CategoryId to 0 (default value), breaking the relationship
  2. Possibly clear the Tags collection, orphaning related records (if it comes back as an empty list)

I’ve seen this happen in production, and it’s not pretty. Now I know what you’re going to say: “You shouldn’t be using the same DTO for read and update. You should have a dedicated UpdateProductDto with only the fields we’re trying to change.” And I would wholeheartedly agree, however we’ve all seen this rule get bent.

Here’s a much safer approach:

// Better: Explicit property updates
public async Task UpdateProductAsync(ProductDto dto)
{
    var product = await _dbContext.Products
        .FirstOrDefaultAsync(p => p.Id == dto.Id);
        
    if (product == null)
        throw new NotFoundException("Product not found");
    
    // Explicitly update only what should change
    product.Name = dto.Name;
    product.Price = dto.Price;
  
    // OR: Use domain methods that enforce business rules
    product.UpdateDetails(dto.Name, dto.Price);
    
    await _dbContext.SaveChangesAsync();
}

Alternatives to AutoMapper 🔄

One interesting thing that happens when I decide to stick to these basic rules is that I’m no longer bound to AutoMapper as my only choice. I’m not tied to any of the fancy features, because I don’t use them. So I’ve experimented with different mappers. All of them share the same rules for their implicit mappings.

Mapster

Mapster is another popular choice. You can set it up to use an IMapper interface, but its basic usage is with an extension method on object.

Mapping to a new object

Mapster creates the destination object and maps values to it.

var destObject = sourceObject.Adapt<Destination>();

Mapping to an existing object

You create the object, Mapster maps to the object.

sourceObject.Adapt(destObject);

One very cool thing about Mapster is that (if you’re using all the defaults) you don’t even need to define any configurations. Now some people hate that because they like their mappings explicit, but I don’t mind.

Mapperly

If Mapster gives you bad flashbacks to music sharing in the early 2000s, the Mapperly is a great alternative. This newer kid on the block uses source generation, which is fantastic for our rules:

  • You can see exactly what mapping code is being created
  • No runtime reflection means better performance
  • It’s harder to accidentally create bad mappings
// Mapperly mapper definition
[Mapper]
public partial class CustomerMapper
{
    // Simple outward mapping - generated at compile time
    public partial CustomerDto MapToDto(Customer customer);
    
    // You can see exactly what code is generated
    // The generated method will look like:
    /*
    public partial CustomerDto MapToDto(Customer customer)
    {
        return new CustomerDto()
        {
            Id = customer.Id,
            Name = customer.Name,
            Email = customer.Email,
            // etc.
        };
    }
    */
}

// Usage
var customerDto = _customerMapper.MapToDto(customer);

You haven’t experienced joy until you’re in a debug session and F12 into your mapping logic and see exactly what’s going on. Or when you use your IDE to find all references of properties on your entity or DTO and you’re dropped into some mapping code. Trust me, it’s an amazing feeling.

Features

Both of these mappers support the basic features that we should rely on based on our rules: one-to-one properties and flattening, with null checks.

And if you’re like me, and you’ve gotten to love the ProjectTo<> feature of AutoMapper, you’re in luck. Both Mapster and Mapperly have their version of this. This feature allows your mappings to be directly applied to an IQueryable as a Select statement. This would be the equivalent of something like this:

_dbContext.Customers.Select(x => new CustomerDto
{
    Id = x.Id,
    Name = x.Name,
    AddressStreet = x.Address.Street,
    AddressCity = x.Address.City,
    AddressZipCode = x.Address.ZipCode,
    CreatedDate = x.CreatedDate,
})

I also have to mention, they do support a lot (not all) of the features of AutoMapper. Things like custom mappings, etc. But those would be violations of our rules.

According to their own benchmarks, both of these mappers boast better performance than AutoMapper. But performance has never been my deciding factor for mappers.

Conclusion 🎯

If you’ve been burned by AutoMapper in the past or if you’ve sworn off mappers entirely, I encourage you to try this approach. By limiting mappers to what they do best—simple, outward data transformations—you can enjoy their benefits without the maintenance nightmares.

These two simple rules have helped me maintain clean, debuggable codebases while still benefiting from the tedium-reducing power of object mappers.

Resources 📚

Official Documentation

From Jimmy.

GitHub Repositories


Copyright © 2025 NimblePros - All Rights Reserved