DEV Community

Arash Zand
Arash Zand

Posted on • Originally published at Medium

Mastering AutoMapper in .NET 8+: Best Practices, Pitfalls, and Real-World Examples

AutoMapper eliminates the boilerplate of copying properties between domain models and DTOs. This guide covers everything from setup to advanced techniques, performance, testing, and the common pitfalls that trip up teams in production.


Setup

dotnet add package AutoMapper
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection
Enter fullscreen mode Exit fullscreen mode

Register in Program.cs:

builder.Services.AddAutoMapper(typeof(UserProfile).Assembly);
Enter fullscreen mode Exit fullscreen mode

AutoMapper scans the assembly, finds all classes inheriting from Profile, and registers them automatically.

Always validate at startup in development:

var config = new MapperConfiguration(cfg => cfg.AddProfile<UserProfile>());
config.AssertConfigurationIsValid(); // throws if anything is misconfigured
Enter fullscreen mode Exit fullscreen mode

Basic Mapping

When property names and types match, no configuration is needed:

public class UserProfile : Profile
{
    public UserProfile()
    {
        CreateMap<User, UserDto>(); // convention-based, zero config
    }
}

var dto = _mapper.Map<UserDto>(user);
Enter fullscreen mode Exit fullscreen mode

Different property names:

CreateMap<Customer, CustomerDto>()
    .ForMember(dest => dest.FirstName, opt => opt.MapFrom(src => src.GivenName))
    .ForMember(dest => dest.LastName,  opt => opt.MapFrom(src => src.Surname));
Enter fullscreen mode Exit fullscreen mode

Reverse mapping:

CreateMap<Employee, EmployeeDto>().ReverseMap();
// now both Employee→EmployeeDto and EmployeeDto→Employee work
Enter fullscreen mode Exit fullscreen mode

Transformations:

CreateMap<Product, ProductDto>()
    .ForMember(dest => dest.DisplayPrice,
               opt => opt.MapFrom(src => $"${src.Price:F2}"));
Enter fullscreen mode Exit fullscreen mode

Nested objects — just define both mappings and AutoMapper wires them:

CreateMap<Address, AddressDto>();
CreateMap<Person, PersonDto>(); // Address→AddressDto applied automatically
Enter fullscreen mode Exit fullscreen mode

Collections:

var dtos = _mapper.Map<List<UserDto>>(users); // maps each element
Enter fullscreen mode Exit fullscreen mode

Advanced Features

Conditional Mapping

CreateMap<Order, OrderDto>()
    .ForMember(dest => dest.Discount,
               opt => opt.Condition(src => src.IsPremiumCustomer));
Enter fullscreen mode Exit fullscreen mode

Multiple conditions:

.ForMember(dest => dest.Discount, opt => opt.Condition(
    (src, dest) => src.IsPremiumCustomer && src.Discount > 0));
Enter fullscreen mode Exit fullscreen mode

Null Substitution

CreateMap<UserProfile, UserProfileDto>()
    .ForMember(dest => dest.Bio,
               opt => opt.NullSubstitute("No biography provided."));
Enter fullscreen mode Exit fullscreen mode

Custom Value Resolver

Encapsulates complex logic in a reusable, independently testable class:

public class AgeResolver : IValueResolver<User, UserDto, int>
{
    public int Resolve(User source, UserDto destination,
                       int destMember, ResolutionContext context)
        => (int)((DateTime.UtcNow - source.DateOfBirth).TotalDays / 365.25);
}

CreateMap<User, UserDto>()
    .ForMember(dest => dest.Age, opt => opt.MapFrom<AgeResolver>());
Enter fullscreen mode Exit fullscreen mode

Custom Type Converter

For when you need full control over object creation:

public class StringToDateTimeConverter : ITypeConverter<string, DateTime>
{
    public DateTime Convert(string source, DateTime destination, ResolutionContext context)
        => DateTime.TryParse(source, out var result) ? result : DateTime.MinValue;
}

CreateMap<UserInputDto, User>()
    .ForMember(dest => dest.BirthDate,
               opt => opt.ConvertUsing(new StringToDateTimeConverter(),
                                       src => src.BirthDateString));
Enter fullscreen mode Exit fullscreen mode

Flattening

AutoMapper matches AddressStreetAddress.Street automatically by naming convention:

// Source: Customer { Address { Street, City } }
// Dest:   CustomerDto { AddressStreet, AddressCity }
CreateMap<Customer, CustomerDto>(); // zero config needed
Enter fullscreen mode Exit fullscreen mode

AfterMap

CreateMap<Order, OrderDto>()
    .AfterMap((src, dest) => dest.TotalWithTax = src.Total * 1.2m);
Enter fullscreen mode Exit fullscreen mode

Polymorphic Mapping

CreateMap<Animal, AnimalDto>()
    .Include<Dog, DogDto>()
    .Include<Cat, CatDto>();

CreateMap<Dog, DogDto>();
CreateMap<Cat, CatDto>();
// AutoMapper resolves the correct derived type at runtime
Enter fullscreen mode Exit fullscreen mode

DI-Injected Resolvers

public class PremiumDiscountResolver : IValueResolver<Order, OrderDto, decimal>
{
    private readonly IDiscountService _discountService;

    public PremiumDiscountResolver(IDiscountService discountService)
        => _discountService = discountService;

    public decimal Resolve(Order source, OrderDto destination,
                           decimal destMember, ResolutionContext context)
        => _discountService.CalculateDiscount(source);
}
Enter fullscreen mode Exit fullscreen mode

Register services normally — AutoMapper resolves them from DI automatically.


Performance

AutoMapper compiles mapping plans once at startup and caches them. Subsequent calls for the same type pair are fast. The main cost is the cold start.

Precompile at startup to shift that cost away from user requests:

var config = new MapperConfiguration(cfg => cfg.AddProfile<UserProfile>());
config.CompileMappings(); // compile all mappings now
var mapper = config.CreateMapper();
Enter fullscreen mode Exit fullscreen mode

Use ProjectTo for EF Core — this is the most important performance tip:

// ❌ Loads full entities into memory, then maps
var users = _dbContext.Users.ToList();
var dtos = _mapper.Map<List<UserDto>>(users);

// ✅ Translates mapping to SQL, only fetches DTO fields
var dtos = _dbContext.Users
    .ProjectTo<UserDto>(_mapper.ConfigurationProvider)
    .ToList();
Enter fullscreen mode Exit fullscreen mode

Relative performance (indicative, 100k mappings):

Method Relative speed
Manual mapping Fastest
Mapster (code-gen) Very close to manual
AutoMapper Slightly slower

AutoMapper trades some raw speed for flexibility. For most enterprise workloads this is irrelevant. For batch processing millions of records, profile first.


Testing

Never skip mapping tests. Silent property mismatches are the hardest bugs to find.

Configuration validation:

public class MappingTests
{
    private readonly IMapper _mapper;

    public MappingTests()
    {
        var config = new MapperConfiguration(cfg =>
        {
            cfg.AddProfile<UserProfile>();
            cfg.AddProfile<OrderProfile>();
        });
        config.AssertConfigurationIsValid(); // fail fast if anything is wrong
        _mapper = config.CreateMapper();
    }
}
Enter fullscreen mode Exit fullscreen mode

Property mapping:

[Fact]
public void User_ShouldMapFullName()
{
    var user = new User { FirstName = "John", LastName = "Doe" };
    var dto = _mapper.Map<UserDto>(user);
    Assert.Equal("John Doe", dto.FullName);
}
Enter fullscreen mode Exit fullscreen mode

Conditional mapping:

[Fact]
public void Order_DiscountNotMappedForNonPremium()
{
    var order = new Order { Id = 1, Discount = 50, IsPremiumCustomer = false };
    var dto = _mapper.Map<OrderDto>(order);
    Assert.Equal(0, dto.Discount);
}
Enter fullscreen mode Exit fullscreen mode

Custom resolver in isolation:

[Fact]
public void AgeResolver_CalculatesCorrectAge()
{
    var resolver = new AgeResolver();
    var user = new User { DateOfBirth = new DateTime(2000, 1, 1) };
    var age = resolver.Resolve(user, null, 0, null);
    Assert.Equal(25, age); // assuming 2025
}
Enter fullscreen mode Exit fullscreen mode

Add mapping tests to your CI pipeline — they catch regressions before deployment.


Common Pitfalls

1. Using Map on IQueryable — loads everything into memory. Use ProjectTo instead.

2. Skipping AssertConfigurationIsValid — new properties added to entities silently go unmapped until a user hits the endpoint.

3. One giant profile — breaks the Single Responsibility Principle and makes navigation painful. One profile per domain area.

4. Overusing AfterMap — it's a code smell if you're doing it everywhere. Prefer inline expressions or resolvers.

5. AutoMapper in simple projects — if you have 3 DTOs, just write the manual mapping. AutoMapper adds a dependency without payoff.

6. Not testing custom resolvers — they contain business logic and deserve their own unit tests.


Recommended Project Structure

Application/
├── DTOs/
│   ├── UserDto.cs
│   └── OrderDto.cs
├── Profiles/
│   ├── UserProfile.cs
│   ├── OrderProfile.cs
│   └── ProductProfile.cs
└── Resolvers/
    ├── AgeResolver.cs
    └── PremiumDiscountResolver.cs
Enter fullscreen mode Exit fullscreen mode

One profile per domain. Resolvers alongside profiles. Tests in a dedicated Tests/ project.


When to Use AutoMapper

✅ Multiple DTOs with repeated mapping patterns

✅ Nested objects, flattening, or polymorphic types

✅ Complex transformations that should be centralized and tested

✅ EF Core projections with ProjectTo

❌ 2-3 simple one-to-one mappings — just write them manually

❌ Performance-critical batch processing of millions of records

❌ Mapping logic so complex it's clearer as explicit code

Alternatives: Mapster (faster, code-gen), Mapperly (source generator, compile-time safe), manual mapping.


Originally published on Medium.

Top comments (0)