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
Register in Program.cs:
builder.Services.AddAutoMapper(typeof(UserProfile).Assembly);
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
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);
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));
Reverse mapping:
CreateMap<Employee, EmployeeDto>().ReverseMap();
// now both Employee→EmployeeDto and EmployeeDto→Employee work
Transformations:
CreateMap<Product, ProductDto>()
.ForMember(dest => dest.DisplayPrice,
opt => opt.MapFrom(src => $"${src.Price:F2}"));
Nested objects — just define both mappings and AutoMapper wires them:
CreateMap<Address, AddressDto>();
CreateMap<Person, PersonDto>(); // Address→AddressDto applied automatically
Collections:
var dtos = _mapper.Map<List<UserDto>>(users); // maps each element
Advanced Features
Conditional Mapping
CreateMap<Order, OrderDto>()
.ForMember(dest => dest.Discount,
opt => opt.Condition(src => src.IsPremiumCustomer));
Multiple conditions:
.ForMember(dest => dest.Discount, opt => opt.Condition(
(src, dest) => src.IsPremiumCustomer && src.Discount > 0));
Null Substitution
CreateMap<UserProfile, UserProfileDto>()
.ForMember(dest => dest.Bio,
opt => opt.NullSubstitute("No biography provided."));
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>());
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));
Flattening
AutoMapper matches AddressStreet → Address.Street automatically by naming convention:
// Source: Customer { Address { Street, City } }
// Dest: CustomerDto { AddressStreet, AddressCity }
CreateMap<Customer, CustomerDto>(); // zero config needed
AfterMap
CreateMap<Order, OrderDto>()
.AfterMap((src, dest) => dest.TotalWithTax = src.Total * 1.2m);
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
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);
}
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();
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();
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();
}
}
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);
}
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);
}
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
}
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
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)