Skip to content
Learni
View all tutorials
.NET

How to Master Advanced AutoMapper in .NET in 2026

Lire en français

Introduction

AutoMapper is an essential .NET library for automatically mapping objects from a source type to a destination type, eliminating tons of boilerplate code. In 2026, with .NET 8+, its advanced features like custom Value Resolvers, conditional mappings, LINQ projections, and asynchronous operations deliver top performance in scalable APIs and microservices.

Why this advanced tutorial? Beginners stick to Map(), but pros tackle complex cases: nested objects, derived calculations, runtime validations, and optimized queries. Imagine mapping an Order to OrderDto while dynamically calculating the VAT-inclusive total with variable taxes – that's where AutoMapper shines.

This step-by-step guide takes you from DI configuration to real-world scenarios, with 100% functional, testable code in a console app. By the end, your mappings will be smooth, testable, and high-performing – bookmark-worthy for any .NET architect (128 words).

Prerequisites

  • .NET 8+ SDK installed
  • Advanced C# knowledge (LINQ, DI, async/await)
  • Visual Studio 2022+ or VS Code with C# extension
  • Familiarity with DTOs and SOLID principles
  • Tools: terminal for dotnet CLI

Create the Project and Install AutoMapper

terminal
dotnet new console -o AutoMapperAdvancedDemo
cd AutoMapperAdvancedDemo

dotnet add package AutoMapper
 dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

dotnet add package Microsoft.Extensions.DependencyInjection
 dotnet add package Microsoft.Extensions.Hosting

 dotnet build

These commands create a .NET 8+ console app and install AutoMapper with its DI extension. Microsoft.Extensions.Hosting handles the DI container. The build verifies everything; run it next to test the mappings.

Define Source Entities and DTOs

Before mappings, let's define realistic models: a User with nested Address, and an Order with calculated line items. DTOs expose only what's necessary, following the principle of least privilege. This sets up advanced cases like nested resolutions and conditions.

Entity Models and DTOs

Models.cs
namespace AutoMapperAdvancedDemo;

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public Address Address { get; set; } = new();
    public List<Order> Orders { get; set; } = new();
}

public class Address
{
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string Country { get; set; } = string.Empty;
}

public class Order
{
    public int Id { get; set; }
    public string Product { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Quantity { get; set; }
    public DateTime OrderDate { get; set; }
}

public class UserDto
{
    public int Id { get; set; }
    public string FullName { get; set; } = string.Empty;
    public string AddressLine { get; set; } = string.Empty;
    public decimal TotalOrders { get; set; }
}

public class OrderDto
{
    public int Id { get; set; }
    public string Product { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public bool IsRecent { get; set; }
}

These classes model an e-commerce domain. UserDto.FullName will be concatenated, TotalOrders calculated via resolver, AddressLine nested. OrderDto.IsRecent uses a condition (< 30 days). Everything's ready for advanced mappings.

Create a Basic Profile with DI

Profiles centralize mapping configurations, keeping code DRY and testable. With DI, AutoMapper scans assemblies to load them automatically. We'll start simple before adding complexity.

Basic User Profile

Profiles/UserProfile.cs
using AutoMapper;

namespace AutoMapperAdvancedDemo.Profiles;

public class UserProfile : Profile
{
    public UserProfile()
    {
        CreateMap<User, UserDto>()
            .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name.ToUpper()))
            .ForMember(dest => dest.AddressLine, opt => opt.MapFrom(src => $"{src.Address.Street}, {src.Address.City}"));
    }
}

This Profile maps User to UserDto with two custom mappings: FullName in uppercase (string transformation), AddressLine as nested concatenation. CreateMap is fluent; test it to validate before proceeding.

Advanced Mappings: Conditions and Ignores

For OrderDto, apply conditions (ForMember with predicate) and ignores. Imagine ignoring Price if Quantity=0, or setting IsRecent based on date. This prevents sensitive data leaks.

Order Profile with Conditions

Profiles/OrderProfile.cs
using AutoMapper;

namespace AutoMapperAdvancedDemo.Profiles;

public class OrderProfile : Profile
{
    public OrderProfile()
    {
        CreateMap<Order, OrderDto>()
            .ForMember(dest => dest.Total, opt => opt.MapFrom(src => src.Price * src.Quantity))
            .ForMember(dest => dest.IsRecent, opt => opt.MapFrom(src => src.OrderDate > DateTime.Now.AddDays(-30)))
            .ForPath(dest => dest.Product, opt => opt.Condition(src => !string.IsNullOrEmpty(src.Product)));
    }
}

Here, Total is calculated, IsRecent is a conditional boolean based on date, and Product maps only if non-empty (Condition). ForPath handles nested paths if needed. Great for runtime validation without exceptions.

Custom Value Resolver

IMemberValueResolver excels at reusable complex logic, like summing TotalOrders over a collection. Create one for UserDto.TotalOrders, injectable and independently testable.

Custom TotalOrders Resolver

Resolvers/TotalOrdersResolver.cs
using AutoMapper;
using AutoMapperAdvancedDemo;

namespace AutoMapperAdvancedDemo.Resolvers;

public class TotalOrdersResolver : IValueResolver<User, UserDto, decimal>
{
    public decimal Resolve(User source, UserDto destination, decimal destMember, ResolutionContext context)
    {
        return source.Orders.Sum(o => o.Price * o.Quantity * 1.2m); // Total including 20% VAT
    }
}

This resolver sums order totals with dynamic VAT (20%). Signature: source, dest, currentValue, context (for nested resolutions). Reusable across any User mapping.

Integrate the Resolver into UserProfile

Profiles/UserProfile.cs
using AutoMapper;
using AutoMapperAdvancedDemo.Resolvers;

namespace AutoMapperAdvancedDemo.Profiles;

public class UserProfile : Profile
{
    public UserProfile()
    {
        CreateMap<User, UserDto>()
            .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name.ToUpper()))
            .ForMember(dest => dest.AddressLine, opt => opt.MapFrom(src => $"{src.Address.Street}, {src.Address.City}"))
            .ForMember(dest => dest.TotalOrders, opt => opt.MapFrom<TotalOrdersResolver>());
    }
}

Adds the resolver via MapFrom(). AutoMapper injects it via DI if registered. Full updated Profile; now TotalOrders calculates automatically.

Configure DI and Run Mappings

In .NET 8+, use Host.CreateApplicationBuilder for DI. Scan Profiles and Resolvers. Test in console to validate everything.

DI Configuration and Main Program

Program.cs
using AutoMapper;
using AutoMapperAdvancedDemo;
using AutoMapperAdvancedDemo.Profiles;
using AutoMapperAdvancedDemo.Resolvers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace AutoMapperAdvancedDemo;

class Program
{
    static async Task Main(string[] args)
    {
        var builder = Host.CreateApplicationBuilder(args);

        builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
        builder.Services.AddSingleton<TotalOrdersResolver>(); // Explicit for custom resolver

        var host = builder.Build();

        var mapper = host.Services.GetRequiredService<IMapper>();

        // Test data
        var user = new User
        {
            Id = 1,
            Name = "John Doe",
            Address = new Address { Street = "123 Example St", City = "New York", Country = "USA" },
            Orders = new List<Order>
            {
                new() { Id = 1, Product = "Laptop", Price = 1000m, Quantity = 1, OrderDate = DateTime.Now.AddDays(-10) },
                new() { Id = 2, Product = "Mouse", Price = 20m, Quantity = 2, OrderDate = DateTime.Now.AddDays(-5) }
            }
        };

        var userDto = mapper.Map<UserDto>(user);
        var orderDto = mapper.Map<OrderDto>(user.Orders[0]);

        Console.WriteLine($"UserDto: Id={userDto.Id}, FullName={userDto.FullName}, AddressLine={userDto.AddressLine}, TotalOrders={userDto.TotalOrders}");
        Console.WriteLine($"OrderDto: Id={orderDto.Id}, Product={orderDto.Product}, Total={orderDto.Total}, IsRecent={orderDto.IsRecent}");
    }
}

DI with AddAutoMapper(assemblies) scans Profiles. Singleton for custom resolver. Main creates test data, maps, and outputs results. Run dotnet run: output validates all mappings (TotalOrders=1440 including VAT).

Advanced LINQ Projections

ProjectTo optimizes EF Core queries: maps directly to SQL without loading entities. Ideal for high-performance APIs.

Projection Example (Usable Snippet in Repo)

ProjectionExample.cs
using AutoMapper;
using AutoMapper.QueryableExtensions;

// Assuming a UsersDbContext DbContext with DbSet<User>

var query = context.Users
    .ProjectTo<UserDto>(mapper.ConfigurationProvider)
    .Where(u => u.TotalOrders > 1000)
    .ToList();

// Async
// var queryAsync = await context.Users.ProjectTo<UserDto>(mapper.ConfigurationProvider).ToListAsync();

Integrate into an EF repo: ProjectTo translates mappings to SQL (e.g., SUM on Orders via resolver if configured). Saves 80% performance vs Load+Map. Add to Program for testing.

Best Practices

  • Always use Profiles: Centralized, versionable, testable with MapperConfiguration.AssertConfigurationIsValid().
  • Inject IMapper as a service: Avoid statics for easy unit test mocking.
  • Prefer ProjectTo for queries: Avoids N+1 queries and memory overloads.
  • Validate configs at startup: Call mapper.ConfigurationProvider.AssertConfigurationIsValid() in dev.
  • Async resolvers for I/O: Implement IAsyncValueResolver with ResolveAsync.

Common Errors to Avoid

  • Forget DI scan: Profiles not found → Use AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()).
  • Unregistered resolver: NullReference; register as Singleton/Scoped.
  • Circular mappings: StackOverflow; use DisableConstructorMapping() or PreserveReferences().
  • Ignore conditions: Data leaks; test with edge cases (null, empty).
  • Performance pitfalls: Avoid Map() on large collections; prioritize ProjectTo.

Next Steps