Advanced Features

Table of contents

  1. ForMember — Custom Member Mapping
    1. Map from a computed expression
    2. Map from a deeply nested source
  2. Ignore() — Skip a Property
  3. ReverseMap() — Bidirectional Mapping
    1. Real-world use: API request/response symmetry
  4. ForPath — Map to a Nested Destination Property
  5. Nested Object Mapping
    1. Real-world example: Order with nested Customer and Address
  6. Collection Mapping
    1. Batch mapping with MapList
    2. Array mapping
  7. Conditional Mapping
    1. Condition — skip if a value-level predicate fails
    2. PreCondition — skip the source read entirely
    3. Full condition (source + destination)
    4. Real-world example: conditional mapping for API responses
  8. Null Substitution
  9. Before / After Map Hooks
    1. Use case: auditing mapped objects
  10. MaxDepth — Self-Referencing / Circular Types
    1. Real-world example: organizational hierarchy
    2. Real-world example: threaded comments
  11. Inheritance / Include
    1. Real-world example: EF Core TPH (Table Per Hierarchy)
  12. Enum Mapping
    1. Enum to string
  13. Constructor Mapping
    1. Record types with additional properties
    2. Immutable value objects
  14. Patch / Partial Mapping
    1. Real-world example: PATCH endpoint
  15. Inline Validation
    1. Use in a minimal API endpoint
  16. IQueryable Projection (ProjectTo)
    1. Complex query with ProjectTo
    2. ProjectTo with nested maps
    3. Get the raw expression
  17. Open Generic Mapping
    1. Real-world example: paginated API responses
  18. Same-Type Mapping (Cloning)
  19. Configuration Validation
    1. Recommended: validate in a unit test
  20. Common Pitfalls

ForMember — Custom Member Mapping

Override the default convention for any destination property:

using EggMapper;

cfg.CreateMap<Customer, CustomerDto>()
    .ForMember(d => d.FullName,
               opt => opt.MapFrom(s => $"{s.FirstName} {s.LastName}"))
    .ForMember(d => d.City,
               opt => opt.MapFrom(s => s.Address.City));

Map from a computed expression

cfg.CreateMap<Order, OrderDto>()
    .ForMember(d => d.DisplayPrice,
               opt => opt.MapFrom(s => $"{s.Currency} {s.Price:F2}"))
    .ForMember(d => d.TotalWithTax,
               opt => opt.MapFrom(s => s.Total * (1 + s.TaxRate)))
    .ForMember(d => d.LineCount,
               opt => opt.MapFrom(s => s.Lines.Count));

Map from a deeply nested source

cfg.CreateMap<Order, OrderFlatDto>()
    .ForMember(d => d.CustomerName,
               opt => opt.MapFrom(s => s.Customer.FullName))
    .ForMember(d => d.CustomerEmail,
               opt => opt.MapFrom(s => s.Customer.Email))
    .ForMember(d => d.ShippingCity,
               opt => opt.MapFrom(s => s.ShippingAddress.City))
    .ForMember(d => d.ShippingCountry,
               opt => opt.MapFrom(s => s.ShippingAddress.Country));

Ignore() — Skip a Property

Tell EggMapper to leave a destination property at its default value:

cfg.CreateMap<User, UserDto>()
    .ForMember(d => d.PasswordHash, opt => opt.Ignore())
    .ForMember(d => d.InternalId,   opt => opt.Ignore())
    .ForMember(d => d.SecurityStamp, opt => opt.Ignore());

Use Ignore() for sensitive data (passwords, tokens, internal IDs) or computed-only properties that should not come from the source.


ReverseMap() — Bidirectional Mapping

Register the inverse mapping in one call:

cfg.CreateMap<Order, OrderDto>().ReverseMap();
// Registers: Order -> OrderDto  AND  OrderDto -> Order

Real-world use: API request/response symmetry

cfg.CreateMap<Product, ProductDto>().ReverseMap();

// Read: Entity -> DTO
var dto = mapper.Map<Product, ProductDto>(product);

// Write: DTO -> Entity (for create/update endpoints)
var entity = mapper.Map<ProductDto, Product>(dto);

ForPath — Map to a Nested Destination Property

Write to a property deep in the destination object graph:

cfg.CreateMap<OrderFlatDto, Order>()
    .ForPath(d => d.Customer.Name,
             opt => opt.MapFrom(s => s.CustomerName))
    .ForPath(d => d.Customer.Address.City,
             opt => opt.MapFrom(s => s.ShippingCity))
    .ForPath(d => d.Customer.Address.PostalCode,
             opt => opt.MapFrom(s => s.ShippingZip));

EggMapper creates intermediate objects (Customer, Address) automatically when using ForPath.


Nested Object Mapping

Declare maps for nested types and EggMapper uses them automatically:

cfg.CreateMap<Address,  AddressDto>();
cfg.CreateMap<Customer, CustomerDto>();
// CustomerDto.Address is mapped via the Address -> AddressDto map

Real-world example: Order with nested Customer and Address

// Entities
public class Order
{
    public int Id { get; set; }
    public Customer Customer { get; set; } = null!;
    public Address ShippingAddress { get; set; } = null!;
    public List<OrderLine> Lines { get; set; } = [];
}

// DTOs
public class OrderDto
{
    public int Id { get; set; }
    public CustomerDto Customer { get; set; } = null!;
    public AddressDto ShippingAddress { get; set; } = null!;
    public List<OrderLineDto> Lines { get; set; } = [];
}

// Configuration — register maps for each level
cfg.CreateMap<Order, OrderDto>();
cfg.CreateMap<Customer, CustomerDto>();
cfg.CreateMap<Address, AddressDto>();
cfg.CreateMap<OrderLine, OrderLineDto>();

// Map — nested objects and collections handled automatically
var dto = mapper.Map<Order, OrderDto>(order);
// dto.Customer.Name, dto.ShippingAddress.City, dto.Lines[0].ProductName all populated

If you forget to register a nested type map, AssertConfigurationIsValid() will catch it. Always call it in your test suite.


Collection Mapping

Supported collection types out of the box:

Source Destination
T[] T[], List<T>, IList<T>, ICollection<T>, IEnumerable<T>, HashSet<T>
List<T> All of the above
IEnumerable<T> All of the above
cfg.CreateMap<Order,    OrderDto>();
cfg.CreateMap<Customer, CustomerDto>();

// CustomerDto.Orders (List<OrderDto>) mapped automatically from Customer.Orders (List<Order>)

Batch mapping with MapList

var orders = await db.Orders.ToListAsync();

// Fully inlined compiled loop — near-manual speed
List<OrderDto> dtos = mapper.MapList<Order, OrderDto>(orders);

Array mapping

cfg.CreateMap<Product, ProductDto>();

Product[] products = GetProducts();
var dtos = mapper.Map<List<ProductDto>>(products);
// Also: mapper.Map<ProductDto[]>(products)

Conditional Mapping

Condition — skip if a value-level predicate fails

cfg.CreateMap<Product, ProductDto>()
    .ForMember(d => d.DiscountPrice,
               opt => opt.Condition(s => s.Discount > 0));
// DiscountPrice only set when there is actually a discount

PreCondition — skip the source read entirely

cfg.CreateMap<Product, ProductDto>()
    .ForMember(d => d.WarehouseCode,
               opt => opt.PreCondition(s => s.IsPhysical));
// WarehouseCode not even read from source for digital products

Full condition (source + destination)

cfg.CreateMap<Product, ProductDto>()
    .ForMember(d => d.Price,
               opt => opt.Condition((src, dst) => src.Price != dst.Price));
// Only update price if it actually changed — useful with Map(src, existingDst)

Real-world example: conditional mapping for API responses

cfg.CreateMap<User, UserProfileDto>()
    .ForMember(d => d.Email,
               opt => opt.Condition(s => s.EmailVerified))
    .ForMember(d => d.PhoneNumber,
               opt => opt.Condition(s => s.PhoneVerified))
    .ForMember(d => d.AdminNotes,
               opt => opt.PreCondition(s => s.Role == UserRole.Admin));

Null Substitution

Provide a fallback value when the source property is null:

cfg.CreateMap<Product, ProductDto>()
    .ForMember(d => d.Description,
               opt => opt.NullSubstitute("No description available"))
    .ForMember(d => d.ImageUrl,
               opt => opt.NullSubstitute("/images/placeholder.png"))
    .ForMember(d => d.Category,
               opt => opt.NullSubstitute("Uncategorized"));

Before / After Map Hooks

Run custom logic immediately before or after the mapping:

cfg.CreateMap<Order, OrderDto>()
    .BeforeMap((src, dst) =>
    {
        // Normalize or validate source data
        src.CustomerName = src.CustomerName?.Trim() ?? "";
    })
    .AfterMap((src, dst) =>
    {
        // Compute derived fields after mapping
        dst.MappedAt = DateTime.UtcNow;
        dst.DisplayId = $"ORD-{dst.Id:D6}";
    });

Use case: auditing mapped objects

cfg.CreateMap<Order, OrderAuditDto>()
    .AfterMap((src, dst) =>
    {
        dst.AuditTimestamp = DateTimeOffset.UtcNow;
        dst.AuditSource = "OrderService";
        dst.ChangeHash = ComputeHash(dst);
    });

Maps that use BeforeMap / AfterMap take the flexible delegate path, which is slightly slower than the context-free path used by simple maps. Only use hooks when you need them.


MaxDepth — Self-Referencing / Circular Types

Prevent infinite recursion on types that reference themselves:

cfg.CreateMap<Category, CategoryDto>()
    .MaxDepth(3);
// Category.Children -> CategoryDto.Children mapped up to depth 3
// Beyond depth 3, Children is null

Real-world example: organizational hierarchy

public class Department
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public Department? Parent { get; set; }
    public List<Department> SubDepartments { get; set; } = [];
}

cfg.CreateMap<Department, DepartmentDto>()
    .MaxDepth(5); // Org chart depth limit

Real-world example: threaded comments

public class Comment
{
    public int Id { get; set; }
    public string Body { get; set; } = "";
    public List<Comment> Replies { get; set; } = [];
}

cfg.CreateMap<Comment, CommentDto>()
    .MaxDepth(10); // Limit nesting depth for threaded discussions

Inheritance / Include

Map a derived type through the base type map:

cfg.CreateMap<Vehicle, VehicleDto>();
cfg.CreateMap<Car, CarDto>().IncludeBase<Vehicle, VehicleDto>();
cfg.CreateMap<Truck, TruckDto>().IncludeBase<Vehicle, VehicleDto>();

Real-world example: EF Core TPH (Table Per Hierarchy)

// EF Core entities using TPH inheritance
public abstract class Payment
{
    public int Id { get; set; }
    public decimal Amount { get; set; }
    public DateTime ProcessedAt { get; set; }
}

public class CreditCardPayment : Payment
{
    public string Last4Digits { get; set; } = "";
    public string CardBrand { get; set; } = "";
}

public class BankTransferPayment : Payment
{
    public string BankName { get; set; } = "";
    public string ReferenceNumber { get; set; } = "";
}

// DTOs
public class PaymentDto
{
    public int Id { get; set; }
    public decimal Amount { get; set; }
    public string Type { get; set; } = "";
}

public class CreditCardPaymentDto : PaymentDto
{
    public string Last4Digits { get; set; } = "";
    public string CardBrand { get; set; } = "";
}

public class BankTransferPaymentDto : PaymentDto
{
    public string BankName { get; set; } = "";
}

// Configuration
cfg.CreateMap<Payment, PaymentDto>()
    .ForMember(d => d.Type, o => o.MapFrom(s => s.GetType().Name));

cfg.CreateMap<CreditCardPayment, CreditCardPaymentDto>()
    .IncludeBase<Payment, PaymentDto>();

cfg.CreateMap<BankTransferPayment, BankTransferPaymentDto>()
    .IncludeBase<Payment, PaymentDto>();

Enum Mapping

Enums are mapped by value (numeric) by default. Properties with identical enum types are copied directly.

public enum OrderStatus { Pending, Processing, Shipped, Delivered }
public enum OrderStatusDto { Pending, Processing, Shipped, Delivered }

cfg.CreateMap<Order, OrderDto>();
// Order.Status (OrderStatus) -> OrderDto.Status (OrderStatusDto) by numeric value

Enum to string

cfg.CreateMap<Order, OrderDto>()
    .ForMember(d => d.StatusText, o => o.MapFrom(s => s.Status.ToString()));

Constructor Mapping

If the destination has a constructor whose parameter names match source property names, EggMapper uses it automatically:

public record OrderDto(int Id, string CustomerName, decimal Total);

cfg.CreateMap<Order, OrderDto>();
// Uses the positional constructor: new OrderDto(src.Id, src.CustomerName, src.Total)

Record types with additional properties

public record ProductDto(int Id, string Name)
{
    public decimal Price { get; init; }
    public string Category { get; init; } = "";
}

cfg.CreateMap<Product, ProductDto>();
// Constructor: new ProductDto(src.Id, src.Name) { Price = src.Price, Category = src.Category }

Immutable value objects

public class Money
{
    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public decimal Amount { get; }
    public string Currency { get; }
}

cfg.CreateMap<PriceEntity, Money>();
// Calls: new Money(src.Amount, src.Currency)

EggMapper scores constructors by how many parameter names match source property names (case-insensitive) and picks the highest-scoring one.


Patch / Partial Mapping

mapper.Patch<TSource, TDestination>(source, destination) copies only the set properties from source onto an existing destination object:

  • Reference types (string, classes) — copied only when the source value is non-null
  • Nullable<T> — copied only when .HasValue is true
  • Non-nullable value types (int, bool, etc.) — always copied (no sentinel for “not set”)
cfg.CreateMap<UpdateOrderRequest, Order>();

var existing = db.Orders.Find(id)!;
mapper.Patch(request, existing);   // only non-null fields overwrite existing
db.SaveChanges();

No extra configuration needed — every type map automatically gets a patch delegate compiled at startup.

Real-world example: PATCH endpoint

public class UpdateProductRequest
{
    public string? Name { get; set; }
    public decimal? Price { get; set; }
    public string? Description { get; set; }
    public int? CategoryId { get; set; }
}

// Only the fields the client sends are non-null
// PATCH /products/42  { "name": "New Name", "price": 29.99 }

app.MapPatch("/products/{id}", async (int id, UpdateProductRequest req, AppDbContext db, IMapper mapper) =>
{
    var product = await db.Products.FindAsync(id);
    if (product is null) return Results.NotFound();

    mapper.Patch(req, product);
    // product.Name = "New Name", product.Price = 29.99
    // product.Description and product.CategoryId unchanged

    await db.SaveChangesAsync();
    return Results.NoContent();
});

Inline Validation

Add post-mapping validation rules directly to a type map with .Validate(). All rules run after mapping completes; a MappingValidationException is thrown that contains every violation (not just the first):

cfg.CreateMap<CreateOrderRequest, Order>()
    .Validate(d => d.CustomerName, n => !string.IsNullOrWhiteSpace(n), "Customer name is required")
    .Validate(d => d.Total, t => t > 0, "Order total must be positive")
    .Validate(d => d.Lines, l => l.Count > 0, "Order must have at least one line");
try
{
    var order = mapper.Map<CreateOrderRequest, Order>(request);
}
catch (MappingValidationException ex)
{
    // ex.Errors — IReadOnlyList<string> with all violations
    foreach (var err in ex.Errors)
        Console.WriteLine(err);
}

Use in a minimal API endpoint

app.MapPost("/orders", (CreateOrderRequest req, IMapper mapper, AppDbContext db) =>
{
    try
    {
        var order = mapper.Map<CreateOrderRequest, Order>(req);
        db.Orders.Add(order);
        db.SaveChanges();
        return Results.Created($"/orders/{order.Id}", order);
    }
    catch (MappingValidationException ex)
    {
        return Results.ValidationProblem(
            new Dictionary<string, string[]>
            {
                ["mapping"] = [.. ex.Errors]
            });
    }
});

Maps without .Validate() calls use the zero-overhead context-free path. There is no performance penalty for the common case.


IQueryable Projection (ProjectTo)

ProjectTo<TSource, TDest>(config) builds a pure Expression<Func<TSource, TDest>> from the registered type map and passes it directly to IQueryable.Select(). The expression is never compiled by EggMapper, so LINQ providers (EF Core, etc.) can translate it to SQL.

cfg.CreateMap<Order, OrderDto>();

// EF Core — translated to SQL SELECT
var dtos = await dbContext.Orders
    .Where(o => o.IsActive)
    .ProjectTo<Order, OrderDto>(config)
    .ToListAsync();

Supports:

  • Flat DTOs (MemberInitExpression)
  • Records and parameterized constructors (NewExpression with member associations)
  • Nested registered maps (recursive projection)
  • Flattened properties (AddressStreet -> src.Address.Street)
  • Custom MapFrom expressions inlined into the projection tree

Complex query with ProjectTo

// Paginated order list with filtering
var page = await dbContext.Orders
    .Where(o => o.CustomerId == customerId)
    .Where(o => o.Status != OrderStatus.Cancelled)
    .OrderByDescending(o => o.CreatedAt)
    .ProjectTo<Order, OrderSummaryDto>(config)
    .Skip(pageSize * pageIndex)
    .Take(pageSize)
    .ToListAsync();

ProjectTo with nested maps

cfg.CreateMap<Order, OrderDetailDto>();
cfg.CreateMap<Customer, CustomerBriefDto>();
cfg.CreateMap<Address, AddressBriefDto>();

// EF Core generates a single SQL query with JOINs
var detail = await dbContext.Orders
    .Where(o => o.Id == orderId)
    .ProjectTo<Order, OrderDetailDto>(config)
    .FirstOrDefaultAsync();
// detail.Customer and detail.ShippingAddress are populated from SQL, not in memory

Get the raw expression

Expression<Func<Order, OrderDto>> expr = config.BuildProjection<Order, OrderDto>();

// Compose with other expressions
var combined = dbContext.Orders
    .Where(o => o.IsActive)
    .Select(expr);

ProjectTo eliminates N+1 queries. Instead of loading entities and mapping in memory, the entire projection becomes a single SQL query. Always prefer ProjectTo when reading data you do not need to modify.


Open Generic Mapping

Map generic wrapper types without registering every closed variant:

// Generic wrapper types
public class Result<T>
{
    public bool Success { get; set; }
    public T? Data { get; set; }
    public string? Error { get; set; }
}

public class ResultDto<T>
{
    public bool Success { get; set; }
    public T? Data { get; set; }
    public string? Error { get; set; }
}

// Register the open generic map once
cfg.CreateMap(typeof(Result<>), typeof(ResultDto<>));

// Works for any T
var orderResult = new Result<Order> { Success = true, Data = order };
var dto = mapper.Map<Result<Order>, ResultDto<OrderDto>>(orderResult);
// dto.Success == true, dto.Data is mapped via Order -> OrderDto

Real-world example: paginated API responses

public class PagedResult<T>
{
    public List<T> Items { get; set; } = [];
    public int TotalCount { get; set; }
    public int PageIndex { get; set; }
    public int PageSize { get; set; }
}

public class PagedResultDto<T>
{
    public List<T> Items { get; set; } = [];
    public int TotalCount { get; set; }
    public int PageIndex { get; set; }
    public int PageSize { get; set; }
}

cfg.CreateMap(typeof(PagedResult<>), typeof(PagedResultDto<>));
cfg.CreateMap<Product, ProductDto>();

var pagedProducts = new PagedResult<Product> { Items = products, TotalCount = 150 };
var dto = mapper.Map<PagedResult<Product>, PagedResultDto<ProductDto>>(pagedProducts);

Same-Type Mapping (Cloning)

Map an object to the same type without any configuration:

// No CreateMap<Customer, Customer>() needed
var copy = mapper.Map<Customer, Customer>(customer);

This is useful for creating snapshots before mutation, or for detaching EF Core tracked entities.

// Create a detached copy for comparison
var before = mapper.Map<Order, Order>(order);

// Mutate the tracked entity
order.Status = OrderStatus.Shipped;
order.ShippedAt = DateTime.UtcNow;

// Compare
if (before.Status != order.Status)
    await PublishOrderStatusChanged(order);

Configuration Validation

Validate at startup (or in tests) that every destination property is covered:

config.AssertConfigurationIsValid();
// Throws if any destination property is unmapped and not ignored
[Fact]
public void AllMappings_ShouldBeValid()
{
    var config = new MapperConfiguration(cfg =>
        cfg.AddProfiles(typeof(OrderProfile).Assembly));

    config.AssertConfigurationIsValid();
}

Common Pitfalls

Missing nested type maps are the most common source of bugs. If OrderDto.Customer is a CustomerDto, you need CreateMap<Customer, CustomerDto>() in addition to CreateMap<Order, OrderDto>().

  • Missing nested type maps — Register maps for every nested type in your object graph. Use AssertConfigurationIsValid() to catch these.
  • Circular references without MaxDepth — Self-referencing types (trees, graphs) will cause a stack overflow without MaxDepth().
  • Using BeforeMap/AfterMap unnecessarily — These hooks force the flexible delegate path. For simple computed properties, use MapFrom instead.
  • Forgetting to register Ignore() for validationAssertConfigurationIsValid() will fail on unmapped destination properties. Use Ignore() for properties you intentionally leave unmapped.
  • Registering maps after constructionMapperConfiguration is immutable after construction. All maps must be registered in the constructor callback.

Back to top

EggMapper — MIT licensed © Eggspot. Fastest .NET runtime object mapper.