Part VI: The Mapper Pattern
"AutoMapper made mapping easy. Source generators make it honest."
Object mapping is one of the most tedious tasks in application development. You have a domain entity with twelve properties. You have a DTO with ten of them. You have a persistence entity with eleven, two of which have different names. You have an API response model with eight. Every time data crosses a boundary -- domain to application, application to persistence, persistence to API -- someone has to write the code that copies values from one shape to another. Property by property. Field by field. Over and over.
This is not intellectually challenging work. It is not architecturally significant work. It is plumbing. But it is plumbing that matters, because a single missed property can mean a null value propagating through three layers before surfacing as a bug in production. A single type mismatch can mean a silent truncation -- a decimal amount shoved into an int property that your test never exercises with values above 2,147,483,647. A single copy-paste error can mean that FirstName gets the value of LastName and no one notices until a customer complains.
The .NET ecosystem has addressed this problem with runtime mapping libraries. AutoMapper is the most popular. Mapster is the most performant of the reflection-based alternatives. Both solve the tedium. Both introduce a different category of problems: configuration errors that surface at runtime, performance costs that scale with object complexity, and debugging experiences that involve stepping through expression trees and generic delegates rather than through the actual mapping code.
FrenchExDev's Mapper pattern takes a different approach entirely. You annotate your types with attributes -- [MapFrom], [MapTo], [MapProperty], [IgnoreMapping], [OneWay] -- and a Roslyn incremental source generator produces the mapping code at compile time. The generated code is plain C#: constructor calls, property assignments, collection iterations. You can read it. You can step through it in the debugger. You can set a breakpoint on line 7 of the generated file and see exactly which property is being assigned. There is no reflection at runtime. There is no expression tree compilation at startup. There is no configuration lambda in Program.cs. There is an attribute on a class and a generated file in obj/.
This chapter covers the complete FrenchExDev.Net.Mapper package: the IMapper<in TSource, out TTarget> interface with its variance annotations, the five mapping attributes and what each one tells the source generator, the property matching rules that govern automatic mapping, the generated code and how to inspect it, three-layer mapping in a DDD architecture, testing with MapperAssertions, DI registration via [Injectable], edge cases from nullable properties to collection types, and an honest comparison with AutoMapper.
The Problem with Runtime Mapping
Before looking at the API, it is worth understanding exactly what goes wrong with runtime mapping libraries. These are not theoretical concerns. They are production incidents, startup delays, and debugging sessions that every team using AutoMapper has experienced at least once.
Problem 1: Reflection Cost
AutoMapper uses reflection to discover properties on source and target types. At startup, it compiles expression trees into delegates. For a simple UserEntity to UserDto mapping, this involves:
- Reflecting on
UserEntityto discover all public readable properties. - Reflecting on
UserDtoto discover all public writable properties. - Matching properties by name and type.
- Building an expression tree that reads each source property and assigns each target property.
- Compiling the expression tree into an
Action<UserEntity, UserDto>delegate. - Caching the compiled delegate for reuse.
Steps 1 through 5 happen once, at startup, for every configured mapping. In a large application with 200 entity-to-DTO mappings, this adds measurable latency to application startup. In a serverless environment where cold starts matter, it adds measurable latency to every cold invocation.
The per-mapping cost after startup is negligible -- compiled delegates are fast. But the startup cost is real, and it scales linearly with the number of mappings. A small microservice with 15 mappings barely notices. An enterprise monolith with 500 mappings notices.
Problem 2: Configuration Errors at Runtime
Consider this AutoMapper profile:
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<UserEntity, UserDto>();
}
}public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<UserEntity, UserDto>();
}
}This compiles. This passes static analysis. This passes code review. And it works perfectly -- until someone adds a property FullName to UserDto that does not exist on UserEntity. Now the mapping silently leaves FullName as null. Or, if you have enabled AssertConfigurationIsValid(), it throws at startup -- not at compile time, not during code review, but when the application starts. In a CI pipeline that runs unit tests but does not start the application, this bug ships to production.
The fundamental issue is that the mapping configuration is validated at runtime, not at compile time. The C# compiler knows about both types. The C# compiler knows their properties. The C# compiler could verify that every target property has a source. But AutoMapper's configuration is a runtime API -- CreateMap<TSource, TTarget>() -- and runtime APIs cannot participate in compile-time checking.
Problem 3: The Debugging Black Box
When a mapping produces an incorrect value, the debugging experience with AutoMapper is:
- Set a breakpoint in the calling code.
- Step into
_mapper.Map<UserDto>(entity). - Arrive inside AutoMapper's generic dispatch infrastructure.
- Step through
TypeMap,ResolutionContext,MappingEngine. - Eventually reach the compiled delegate.
- Discover that the delegate is an opaque
Action<object, object>with no source code. - Give up and add
Console.WriteLinestatements to the mapping configuration.
This is not AutoMapper's fault. It is a fundamental limitation of any system that generates mapping logic at runtime via expression trees. The generated code does not have source files. It does not have line numbers. It does not appear in the Solution Explorer. It is invisible.
Source-generated mapping code, by contrast, is a .cs file in obj/Debug/net10.0/generated/. You can open it. You can read it. You can set a breakpoint on any line. You can step through it exactly as you would step through any other C# code. When a property maps incorrectly, you see the assignment. When a property is missing, the compiler tells you -- because the generated code will not compile if the source property does not exist.
Problem 4: Configuration Drift
AutoMapper profiles are code that describes mappings between types. The types themselves are defined elsewhere. As the codebase evolves, properties are added, renamed, and removed. Every change to a mapped type requires a corresponding change to the AutoMapper profile -- but nothing enforces this. The compiler does not warn you when UserEntity.DateOfBirth is renamed to UserEntity.BirthDate and the AutoMapper profile still references the old name via ForMember(d => d.DateOfBirth, ...). The profile compiles. The mapping silently breaks.
Attribute-based mapping eliminates this drift because the mapping metadata lives on the types themselves. When you rename a property on the target type, the [MapProperty("OldSourceName")] annotation is right there on the property. When you add a property to the target type, the source generator immediately tries to find a matching source property -- and if it cannot, compilation fails.
IMapper<TSource, TTarget>
The core interface is one method:
namespace FrenchExDev.Net.Mapper;
public interface IMapper<in TSource, out TTarget>
{
TTarget Map(TSource source);
}namespace FrenchExDev.Net.Mapper;
public interface IMapper<in TSource, out TTarget>
{
TTarget Map(TSource source);
}That is the entire abstraction. One generic interface. One method. No Map<TTarget>(object source) overload that downcasts at runtime. No IMapper non-generic base interface that erases type information. No ProjectTo<T>() for IQueryable translation. No ConfigurationProvider for runtime introspection. One interface. One method.
Why Variance Matters
The in keyword on TSource makes the interface contravariant in the source type. The out keyword on TTarget makes it covariant in the target type. These are not decorative. They enable substitution patterns that are impossible with invariant generics.
Contravariance on TSource (in) means that an IMapper<Animal, AnimalDto> can be used where an IMapper<Dog, AnimalDto> is expected. If a mapper knows how to map any Animal to an AnimalDto, it certainly knows how to map a Dog (which is an Animal) to an AnimalDto. The type parameter goes "in" -- it is consumed, not produced -- so a wider type is safely substitutable for a narrower type.
public class Animal { public string Name { get; init; } }
public class Dog : Animal { public string Breed { get; init; } }
public class AnimalDto { public string Name { get; init; } }
// This mapper handles any Animal
IMapper<Animal, AnimalDto> animalMapper = new AnimalToAnimalDtoMapper();
// Contravariance: can be assigned to IMapper<Dog, AnimalDto>
IMapper<Dog, AnimalDto> dogMapper = animalMapper;
// Works: Dog is an Animal, and the mapper knows how to map Animals
AnimalDto dto = dogMapper.Map(new Dog { Name = "Rex", Breed = "Shepherd" });public class Animal { public string Name { get; init; } }
public class Dog : Animal { public string Breed { get; init; } }
public class AnimalDto { public string Name { get; init; } }
// This mapper handles any Animal
IMapper<Animal, AnimalDto> animalMapper = new AnimalToAnimalDtoMapper();
// Contravariance: can be assigned to IMapper<Dog, AnimalDto>
IMapper<Dog, AnimalDto> dogMapper = animalMapper;
// Works: Dog is an Animal, and the mapper knows how to map Animals
AnimalDto dto = dogMapper.Map(new Dog { Name = "Rex", Breed = "Shepherd" });Covariance on TTarget (out) means that an IMapper<UserEntity, AdminUserDto> can be used where an IMapper<UserEntity, UserDto> is expected, provided AdminUserDto derives from UserDto. If a mapper produces an AdminUserDto, and AdminUserDto is a UserDto, then the mapper satisfies any consumer expecting a UserDto. The type parameter goes "out" -- it is produced, not consumed -- so a narrower type is safely substitutable for a wider type.
public class UserDto { public string Name { get; init; } }
public class AdminUserDto : UserDto { public string Role { get; init; } }
// This mapper produces AdminUserDto
IMapper<UserEntity, AdminUserDto> adminMapper = new UserEntityToAdminUserDtoMapper();
// Covariance: can be assigned to IMapper<UserEntity, UserDto>
IMapper<UserEntity, UserDto> userMapper = adminMapper;
// Works: AdminUserDto is a UserDto
UserDto dto = userMapper.Map(entity);public class UserDto { public string Name { get; init; } }
public class AdminUserDto : UserDto { public string Role { get; init; } }
// This mapper produces AdminUserDto
IMapper<UserEntity, AdminUserDto> adminMapper = new UserEntityToAdminUserDtoMapper();
// Covariance: can be assigned to IMapper<UserEntity, UserDto>
IMapper<UserEntity, UserDto> userMapper = adminMapper;
// Works: AdminUserDto is a UserDto
UserDto dto = userMapper.Map(entity);Both together enable powerful composition. A method that accepts IMapper<Dog, UserDto> can receive an IMapper<Animal, AdminUserDto> -- contravariant in source (wider Animal for narrower Dog), covariant in target (narrower AdminUserDto for wider UserDto). This is exactly the same variance pattern that Func<in T, out TResult> uses in the BCL. It is not exotic. It is standard generic variance. But most mapping libraries do not support it because their interfaces are invariant.
Why Not a Non-Generic Base Interface?
AutoMapper's IMapper interface has a non-generic Map(object source, Type sourceType, Type destinationType) method. This allows you to map without knowing the types at compile time. It is useful for middleware, serialization pipelines, and other scenarios where the types are determined dynamically.
FrenchExDev deliberately does not provide this. A non-generic Map(object) method defeats the purpose of compile-time safety. If you need dynamic dispatch, you can use the mediator pattern or a service locator. The mapper interface exists to be called with known types at known call sites, and the source generator exists to verify that those types have compatible properties. Removing the type parameters removes the verification.
The Attribute DSL
Five attributes control the source generator. Each attribute tells the generator something specific about how to produce the mapping code.
Class Diagram
[MapFrom(typeof(Source))]
Definition:
namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)]
public sealed class MapFromAttribute : Attribute
{
public Type SourceType { get; }
public MapFromAttribute(Type sourceType) => SourceType = sourceType;
}namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)]
public sealed class MapFromAttribute : Attribute
{
public Type SourceType { get; }
public MapFromAttribute(Type sourceType) => SourceType = sourceType;
}Usage: Place [MapFrom] on the target type. The attribute says: "I am the destination. Generate a mapper that converts from the specified source type to me."
[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
public string Email { get; init; }
}[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
public string Email { get; init; }
}What the generator does: When the incremental generator encounters [MapFrom(typeof(UserEntity))] on UserDto, it:
- Resolves the
UserEntitytype from the semantic model. - Enumerates all public, readable properties on
UserEntity. - Enumerates all public, writable properties on
UserDto. - Matches properties by name (case-sensitive) and type (exact match or implicit conversion).
- Respects
[MapProperty]overrides and[IgnoreMapping]exclusions. - Emits a class
UserEntityToUserDtoMapperthat implementsIMapper<UserEntity, UserDto>. - Unless
[OneWay]is present onUserDto, also emitsUserDtoToUserEntityMapperimplementingIMapper<UserDto, UserEntity>.
AllowMultiple: The attribute allows multiple instances. A single DTO can declare multiple sources:
[MapFrom(typeof(UserEntity))]
[MapFrom(typeof(ExternalUserPayload))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
public string Email { get; init; }
}[MapFrom(typeof(UserEntity))]
[MapFrom(typeof(ExternalUserPayload))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
public string Email { get; init; }
}This generates two separate mappers: UserEntityToUserDtoMapper and ExternalUserPayloadToUserDtoMapper. Each mapper is an independent class with its own property matching. A property that exists on UserEntity but not on ExternalUserPayload will be mapped in the first mapper and ignored (or generate a compilation error) in the second.
[MapTo(typeof(Target))]
Definition:
namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)]
public sealed class MapToAttribute : Attribute
{
public Type TargetType { get; }
public MapToAttribute(Type targetType) => TargetType = targetType;
}namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)]
public sealed class MapToAttribute : Attribute
{
public Type TargetType { get; }
public MapToAttribute(Type targetType) => TargetType = targetType;
}Usage: Place [MapTo] on the source type. The attribute says: "I am the source. Generate a mapper that converts from me to the specified target type."
[MapTo(typeof(UserDto))]
public class UserEntity
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
}[MapTo(typeof(UserDto))]
public class UserEntity
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
}Semantic equivalence: [MapTo(typeof(UserDto))] on UserEntity and [MapFrom(typeof(UserEntity))] on UserDto produce the same generated mapper. The difference is where the annotation lives. Use [MapFrom] when you own the target (the DTO) and want it to declare its sources. Use [MapTo] when you own the source (the entity) and want it to declare its destinations.
In practice, [MapFrom] is more common in application-layer DTOs: the DTO knows where its data comes from. [MapTo] is more common in domain-layer entities: the entity knows where its data goes. The choice is a matter of ownership and readability, not behavior.
[MapProperty("SourcePropertyName")]
Definition:
namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public sealed class MapPropertyAttribute : Attribute
{
public string SourcePropertyName { get; }
public MapPropertyAttribute(string sourcePropertyName) =>
SourcePropertyName = sourcePropertyName;
}namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public sealed class MapPropertyAttribute : Attribute
{
public string SourcePropertyName { get; }
public MapPropertyAttribute(string sourcePropertyName) =>
SourcePropertyName = sourcePropertyName;
}Usage: Place [MapProperty] on a target property whose name differs from the source property:
[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
[MapProperty("FullName")]
public string DisplayName { get; init; }
public string Email { get; init; }
}[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
[MapProperty("FullName")]
public string DisplayName { get; init; }
public string Email { get; init; }
}Here, UserEntity.FullName is mapped to UserDto.DisplayName. Without the [MapProperty] attribute, the generator would look for UserEntity.DisplayName -- which does not exist -- and the generated code would not compile (or the property would be left unmapped, depending on whether it has a default value).
Why a string? The source property name is a string rather than a nameof() expression because the source type might not be in the same project or even the same assembly. The nameof() operator requires compile-time visibility of the member, which is not always available when the target DTO is defined in an application layer project and the source entity is in a domain layer project referenced only via interface. The string is validated at generation time: if the source type does not have a property with the specified name, the generator emits a diagnostic error.
[IgnoreMapping]
Definition:
namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public sealed class IgnoreMappingAttribute : Attribute;namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public sealed class IgnoreMappingAttribute : Attribute;Usage: Place [IgnoreMapping] on a target property that should not be mapped from the source:
[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
public string Email { get; init; }
[IgnoreMapping]
public string GravatarUrl { get; init; }
}[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
public string Email { get; init; }
[IgnoreMapping]
public string GravatarUrl { get; init; }
}The GravatarUrl property is excluded from the generated mapper. The generator does not look for a matching source property, does not emit an assignment, and does not emit a diagnostic. The property will have its default value (null for a reference type, default for a value type) after mapping.
Common use cases:
- Computed properties that are derived from other properties after mapping.
- Properties populated by a different service (like
GravatarUrlfrom a URL builder). - Navigation properties on persistence entities that should not be mapped.
- Properties that exist for serialization compatibility but are not populated from the source.
[OneWay]
Definition:
namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class OneWayAttribute : Attribute;namespace FrenchExDev.Net.Mapper.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class OneWayAttribute : Attribute;Usage: Place [OneWay] on the target class to suppress generation of the reverse mapper:
[MapFrom(typeof(OrderEntity))]
[OneWay]
public class OrderSummaryDto
{
public Guid OrderId { get; init; }
public string CustomerName { get; init; }
public decimal TotalAmount { get; init; }
}[MapFrom(typeof(OrderEntity))]
[OneWay]
public class OrderSummaryDto
{
public Guid OrderId { get; init; }
public string CustomerName { get; init; }
public decimal TotalAmount { get; init; }
}Without [OneWay], the generator would produce both OrderEntityToOrderSummaryDtoMapper and OrderSummaryDtoToOrderEntityMapper. The reverse mapper does not make sense here: OrderSummaryDto is a projection -- it contains a subset of OrderEntity's properties. Mapping from the summary back to the entity would lose data. [OneWay] tells the generator not to produce the reverse mapper.
When to use it:
- Read models and projections that are inherently lossy.
- API response DTOs that are subsets of internal models.
- View models that aggregate data from multiple sources.
- Event payloads that capture a snapshot, not a reversible transformation.
When not to use it:
- CRUD DTOs where the same shape is used for both input and output.
- Persistence entities where bidirectional mapping is required.
- Integration DTOs where the external system sends the same shape back.
The Source Generation Pipeline
The mapper source generator is a Roslyn incremental source generator that runs during compilation. It follows the same pipeline architecture used by the [Injectable] source generator described in Part I.
Stage 1: Syntax Predicate
The generator registers a syntax predicate that filters for class and struct declarations decorated with [MapFrom] or [MapTo]. This predicate runs on every keystroke in an IDE and must be fast, so it checks only for the attribute name as a string -- no semantic analysis, no type resolution.
// Conceptual predicate (simplified)
static bool IsCandidateNode(SyntaxNode node)
{
if (node is not TypeDeclarationSyntax typeDecl)
return false;
return typeDecl.AttributeLists
.SelectMany(al => al.Attributes)
.Any(a =>
{
var name = a.Name.ToString();
return name is "MapFrom" or "MapFromAttribute"
or "MapTo" or "MapToAttribute";
});
}// Conceptual predicate (simplified)
static bool IsCandidateNode(SyntaxNode node)
{
if (node is not TypeDeclarationSyntax typeDecl)
return false;
return typeDecl.AttributeLists
.SelectMany(al => al.Attributes)
.Any(a =>
{
var name = a.Name.ToString();
return name is "MapFrom" or "MapFromAttribute"
or "MapTo" or "MapToAttribute";
});
}This is deliberately coarse. It catches any class or struct with an attribute whose name matches. False positives (e.g., a user-defined MapFromAttribute in a different namespace) are filtered out in Stage 2.
Stage 2: Semantic Transform
For each candidate node that passes the syntax predicate, the generator performs semantic analysis:
- Resolve the attribute's constructor argument to a
INamedTypeSymbol. - Verify the attribute is from the
FrenchExDev.Net.Mapper.Attributesnamespace. - Collect the source type and target type as semantic symbols.
- Enumerate source properties: public, readable (has a getter), instance (not static).
- Enumerate target properties: public, writable (has a setter or is
init), instance. - For each target property:
- Check for
[IgnoreMapping]-- skip if present. - Check for
[MapProperty("name")]-- use the specified source property name. - Otherwise, find a source property with the same name.
- Verify type compatibility: exact match, implicit conversion, or nested mapper.
- Check for
- Collect all matched property pairs into a generation model.
Stage 3: Code Emission
The generator emits a .g.cs file for each mapper. The generated file contains:
- A
partial classin the same namespace as the target type (or a dedicatedGenerated.Mappersnamespace). - The class implements
IMapper<TSource, TTarget>. - The class is decorated with
[Injectable(Lifetime.Transient)]for automatic DI registration. - The
Mapmethod creates a new target instance and assigns each matched property. - If any target property's type requires a nested mapper (e.g., mapping
AddressEntitytoAddressDto), the generated class takesIMapper<AddressEntity, AddressDto>as a constructor dependency.
Stage 4: Reverse Mapper (unless [OneWay])
Unless the target type is decorated with [OneWay], the generator repeats Stages 2 and 3 with source and target swapped, producing a reverse mapper.
What Gets Generated
The best way to understand the mapper is to see what the generator actually produces. Here is a complete before-and-after example.
Before: Source Code You Write
The domain entity:
namespace MyApp.Domain;
public class UserEntity
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string EmailAddress { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
}namespace MyApp.Domain;
public class UserEntity
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string EmailAddress { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
}The application DTO:
using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Application.Dtos;
[MapFrom(typeof(Domain.UserEntity))]
[OneWay]
public class UserDto
{
public int Id { get; init; }
public string FirstName { get; init; }
public string LastName { get; init; }
[MapProperty("EmailAddress")]
public string Email { get; init; }
[IgnoreMapping]
public string FullName => $"{FirstName} {LastName}";
public bool IsActive { get; init; }
}using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Application.Dtos;
[MapFrom(typeof(Domain.UserEntity))]
[OneWay]
public class UserDto
{
public int Id { get; init; }
public string FirstName { get; init; }
public string LastName { get; init; }
[MapProperty("EmailAddress")]
public string Email { get; init; }
[IgnoreMapping]
public string FullName => $"{FirstName} {LastName}";
public bool IsActive { get; init; }
}After: Generated Code
The source generator produces the following file at obj/Debug/net10.0/generated/FrenchExDev.Net.Mapper.SourceGenerator/UserEntityToUserDtoMapper.g.cs:
// <auto-generated/>
// Generated by FrenchExDev.Net.Mapper.SourceGenerator
// Source: MyApp.Domain.UserEntity
// Target: MyApp.Application.Dtos.UserDto
#nullable enable
using FrenchExDev.Net.Mapper;
using FrenchExDev.Net.Injectable.Attributes;
namespace MyApp.Application.Dtos.Generated.Mappers;
/// <summary>
/// Maps <see cref="MyApp.Domain.UserEntity"/> to <see cref="MyApp.Application.Dtos.UserDto"/>.
/// </summary>
[Injectable(Lifetime.Transient)]
public sealed class UserEntityToUserDtoMapper : IMapper<MyApp.Domain.UserEntity, MyApp.Application.Dtos.UserDto>
{
/// <inheritdoc/>
public MyApp.Application.Dtos.UserDto Map(MyApp.Domain.UserEntity source)
{
return new MyApp.Application.Dtos.UserDto
{
Id = source.Id,
FirstName = source.FirstName,
LastName = source.LastName,
Email = source.EmailAddress, // [MapProperty("EmailAddress")]
IsActive = source.IsActive,
};
// FullName: ignored ([IgnoreMapping])
// CreatedAt: no matching target property
}
}// <auto-generated/>
// Generated by FrenchExDev.Net.Mapper.SourceGenerator
// Source: MyApp.Domain.UserEntity
// Target: MyApp.Application.Dtos.UserDto
#nullable enable
using FrenchExDev.Net.Mapper;
using FrenchExDev.Net.Injectable.Attributes;
namespace MyApp.Application.Dtos.Generated.Mappers;
/// <summary>
/// Maps <see cref="MyApp.Domain.UserEntity"/> to <see cref="MyApp.Application.Dtos.UserDto"/>.
/// </summary>
[Injectable(Lifetime.Transient)]
public sealed class UserEntityToUserDtoMapper : IMapper<MyApp.Domain.UserEntity, MyApp.Application.Dtos.UserDto>
{
/// <inheritdoc/>
public MyApp.Application.Dtos.UserDto Map(MyApp.Domain.UserEntity source)
{
return new MyApp.Application.Dtos.UserDto
{
Id = source.Id,
FirstName = source.FirstName,
LastName = source.LastName,
Email = source.EmailAddress, // [MapProperty("EmailAddress")]
IsActive = source.IsActive,
};
// FullName: ignored ([IgnoreMapping])
// CreatedAt: no matching target property
}
}Let us walk through every decision the generator made:
Id-- Same name, same type (int). Automatic match.FirstName-- Same name, same type (string). Automatic match.LastName-- Same name, same type (string). Automatic match.Email-- Different name on the target. The[MapProperty("EmailAddress")]attribute tells the generator to read fromsource.EmailAddress. The generator verified thatUserEntityhas a property namedEmailAddressof typestring.FullName-- Decorated with[IgnoreMapping]. Skipped entirely. (It is also a computed property with no setter, so even without[IgnoreMapping]the generator would skip it -- it has noinitorsetaccessor.)IsActive-- Same name, same type (bool). Automatic match.CreatedAt-- Present on the source but not on the target. No action needed. The generator maps from source to target; source properties without a target counterpart are simply not read.[OneWay]-- Present onUserDto. No reverse mapper is generated. Without this attribute, the generator would also produceUserDtoToUserEntityMapper.[Injectable(Lifetime.Transient)]-- The generated mapper is automatically decorated for DI registration. Whenservices.AddMyAppInjectables()is called, this mapper is registered asIMapper<UserEntity, UserDto>with transient lifetime.
The Generated Code is Real Code
This point deserves emphasis. The generated file is not pseudocode. It is not an intermediate representation. It is a .cs file that the C# compiler compiles alongside your hand-written code. It participates in all the same compile-time checks:
- If you rename
UserEntity.EmailAddresstoUserEntity.Email, the generated codesource.EmailAddresswill not compile. You get a CS0117 error at build time, not a runtimeMissingMemberExceptionat 2 AM. - If you change
UserEntity.IdfrominttoGuid, the generated codeId = source.Idwill produce a CS0029 error (cannot implicitly convertGuidtoint). You fix the type mismatch before the code ships. - If you add a new required property to
UserDto(via a constructor parameter), the generated object initializer will not satisfy the constructor, and you get a CS7036 error.
Every property mismatch, type incompatibility, and missing member surfaces as a compiler error. Not a runtime error. Not a test failure. A compiler error, in your IDE, with a red squiggle, before you save the file.
Property Matching Rules
The source generator follows a deterministic set of rules to match source properties to target properties. Understanding these rules is essential for predicting what the generator will do -- and for knowing when you need to intervene with [MapProperty] or [IgnoreMapping].
Rule 1: Same Name, Same Type
The simplest case. If the target property has the same name as a source property, and the types are identical, the generator emits a direct assignment:
// Source
public class OrderEntity
{
public Guid OrderId { get; set; }
public decimal Amount { get; set; }
}
// Target
[MapFrom(typeof(OrderEntity))]
public class OrderDto
{
public Guid OrderId { get; init; } // Exact match
public decimal Amount { get; init; } // Exact match
}
// Generated
public OrderDto Map(OrderEntity source)
{
return new OrderDto
{
OrderId = source.OrderId,
Amount = source.Amount,
};
}// Source
public class OrderEntity
{
public Guid OrderId { get; set; }
public decimal Amount { get; set; }
}
// Target
[MapFrom(typeof(OrderEntity))]
public class OrderDto
{
public Guid OrderId { get; init; } // Exact match
public decimal Amount { get; init; } // Exact match
}
// Generated
public OrderDto Map(OrderEntity source)
{
return new OrderDto
{
OrderId = source.OrderId,
Amount = source.Amount,
};
}Rule 2: Same Name, Implicit Conversion
If the names match but the types differ, the generator checks for an implicit conversion. C# defines implicit conversions between numeric types (e.g., int to long, float to double) and allows user-defined implicit conversion operators. If an implicit conversion exists, the generator emits the assignment and lets the compiler insert the conversion:
// Source
public class ProductEntity
{
public int Quantity { get; set; } // int
public float Price { get; set; } // float
}
// Target
[MapFrom(typeof(ProductEntity))]
public class ProductDto
{
public long Quantity { get; init; } // long (int -> long: implicit)
public double Price { get; init; } // double (float -> double: implicit)
}
// Generated
public ProductDto Map(ProductEntity source)
{
return new ProductDto
{
Quantity = source.Quantity, // int -> long: implicit widening
Price = source.Price, // float -> double: implicit widening
};
}// Source
public class ProductEntity
{
public int Quantity { get; set; } // int
public float Price { get; set; } // float
}
// Target
[MapFrom(typeof(ProductEntity))]
public class ProductDto
{
public long Quantity { get; init; } // long (int -> long: implicit)
public double Price { get; init; } // double (float -> double: implicit)
}
// Generated
public ProductDto Map(ProductEntity source)
{
return new ProductDto
{
Quantity = source.Quantity, // int -> long: implicit widening
Price = source.Price, // float -> double: implicit widening
};
}If the conversion is narrowing (e.g., long to int) or requires an explicit cast, the generator does not emit the assignment. This is a deliberate safety choice. A narrowing conversion can lose data, and the generator will not silently truncate your values. You must either change the types to match, or write a manual mapper for that specific property.
Rule 3: Different Name, [MapProperty] Override
When the target property name does not match any source property name, and the target property has a [MapProperty("SourceName")] attribute, the generator uses the specified source property:
// Source
public class CustomerEntity
{
public string FullName { get; set; }
public string PhoneNumber { get; set; }
}
// Target
[MapFrom(typeof(CustomerEntity))]
public class CustomerDto
{
[MapProperty("FullName")]
public string DisplayName { get; init; }
[MapProperty("PhoneNumber")]
public string ContactNumber { get; init; }
}
// Generated
public CustomerDto Map(CustomerEntity source)
{
return new CustomerDto
{
DisplayName = source.FullName,
ContactNumber = source.PhoneNumber,
};
}// Source
public class CustomerEntity
{
public string FullName { get; set; }
public string PhoneNumber { get; set; }
}
// Target
[MapFrom(typeof(CustomerEntity))]
public class CustomerDto
{
[MapProperty("FullName")]
public string DisplayName { get; init; }
[MapProperty("PhoneNumber")]
public string ContactNumber { get; init; }
}
// Generated
public CustomerDto Map(CustomerEntity source)
{
return new CustomerDto
{
DisplayName = source.FullName,
ContactNumber = source.PhoneNumber,
};
}Rule 4: Nested Objects
When a target property's type is a complex object (not a primitive, string, or enum), and a mapper exists for the nested types, the generator injects the nested mapper as a constructor dependency:
// Source
public class OrderEntity
{
public Guid OrderId { get; set; }
public AddressEntity ShippingAddress { get; set; }
}
public class AddressEntity
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
}
// Target
[MapFrom(typeof(OrderEntity))]
public class OrderDto
{
public Guid OrderId { get; init; }
public AddressDto ShippingAddress { get; init; }
}
[MapFrom(typeof(AddressEntity))]
public class AddressDto
{
public string Street { get; init; }
public string City { get; init; }
public string PostalCode { get; init; }
}
// Generated: OrderEntityToOrderDtoMapper
[Injectable(Lifetime.Transient)]
public sealed class OrderEntityToOrderDtoMapper : IMapper<OrderEntity, OrderDto>
{
private readonly IMapper<AddressEntity, AddressDto> _addressMapper;
public OrderEntityToOrderDtoMapper(IMapper<AddressEntity, AddressDto> addressMapper)
{
_addressMapper = addressMapper;
}
public OrderDto Map(OrderEntity source)
{
return new OrderDto
{
OrderId = source.OrderId,
ShippingAddress = source.ShippingAddress is not null
? _addressMapper.Map(source.ShippingAddress)
: default,
};
}
}// Source
public class OrderEntity
{
public Guid OrderId { get; set; }
public AddressEntity ShippingAddress { get; set; }
}
public class AddressEntity
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
}
// Target
[MapFrom(typeof(OrderEntity))]
public class OrderDto
{
public Guid OrderId { get; init; }
public AddressDto ShippingAddress { get; init; }
}
[MapFrom(typeof(AddressEntity))]
public class AddressDto
{
public string Street { get; init; }
public string City { get; init; }
public string PostalCode { get; init; }
}
// Generated: OrderEntityToOrderDtoMapper
[Injectable(Lifetime.Transient)]
public sealed class OrderEntityToOrderDtoMapper : IMapper<OrderEntity, OrderDto>
{
private readonly IMapper<AddressEntity, AddressDto> _addressMapper;
public OrderEntityToOrderDtoMapper(IMapper<AddressEntity, AddressDto> addressMapper)
{
_addressMapper = addressMapper;
}
public OrderDto Map(OrderEntity source)
{
return new OrderDto
{
OrderId = source.OrderId,
ShippingAddress = source.ShippingAddress is not null
? _addressMapper.Map(source.ShippingAddress)
: default,
};
}
}The key points:
- The nested
IMapper<AddressEntity, AddressDto>is injected via the constructor, not created inline. This allows the nested mapper to be independently tested and replaced. - The null check (
source.ShippingAddress is not null) prevents aNullReferenceExceptionwhen the source property is null. - The
AddressEntityToAddressDtoMapperis generated separately from the[MapFrom(typeof(AddressEntity))]onAddressDto. Mappers compose. They do not inline.
Rule 5: Collections
When a target property is a collection type (List<T>, IReadOnlyList<T>, IEnumerable<T>, T[]), and the element types have a registered mapper, the generator maps element-by-element:
// Source
public class InvoiceEntity
{
public Guid InvoiceId { get; set; }
public List<LineItemEntity> Items { get; set; }
}
// Target
[MapFrom(typeof(InvoiceEntity))]
public class InvoiceDto
{
public Guid InvoiceId { get; init; }
public IReadOnlyList<LineItemDto> Items { get; init; }
}
// Generated
[Injectable(Lifetime.Transient)]
public sealed class InvoiceEntityToInvoiceDtoMapper : IMapper<InvoiceEntity, InvoiceDto>
{
private readonly IMapper<LineItemEntity, LineItemDto> _lineItemMapper;
public InvoiceEntityToInvoiceDtoMapper(
IMapper<LineItemEntity, LineItemDto> lineItemMapper)
{
_lineItemMapper = lineItemMapper;
}
public InvoiceDto Map(InvoiceEntity source)
{
return new InvoiceDto
{
InvoiceId = source.InvoiceId,
Items = source.Items is not null
? source.Items.Select(item => _lineItemMapper.Map(item)).ToList()
: Array.Empty<LineItemDto>(),
};
}
}// Source
public class InvoiceEntity
{
public Guid InvoiceId { get; set; }
public List<LineItemEntity> Items { get; set; }
}
// Target
[MapFrom(typeof(InvoiceEntity))]
public class InvoiceDto
{
public Guid InvoiceId { get; init; }
public IReadOnlyList<LineItemDto> Items { get; init; }
}
// Generated
[Injectable(Lifetime.Transient)]
public sealed class InvoiceEntityToInvoiceDtoMapper : IMapper<InvoiceEntity, InvoiceDto>
{
private readonly IMapper<LineItemEntity, LineItemDto> _lineItemMapper;
public InvoiceEntityToInvoiceDtoMapper(
IMapper<LineItemEntity, LineItemDto> lineItemMapper)
{
_lineItemMapper = lineItemMapper;
}
public InvoiceDto Map(InvoiceEntity source)
{
return new InvoiceDto
{
InvoiceId = source.InvoiceId,
Items = source.Items is not null
? source.Items.Select(item => _lineItemMapper.Map(item)).ToList()
: Array.Empty<LineItemDto>(),
};
}
}The generator handles the common collection type conversions:
| Source Type | Target Type | Generated Code |
|---|---|---|
List<T> |
IReadOnlyList<U> |
.Select(x => mapper.Map(x)).ToList() |
T[] |
List<U> |
.Select(x => mapper.Map(x)).ToList() |
IEnumerable<T> |
IReadOnlyList<U> |
.Select(x => mapper.Map(x)).ToList() |
List<T> |
U[] |
.Select(x => mapper.Map(x)).ToArray() |
ICollection<T> |
IReadOnlyCollection<U> |
.Select(x => mapper.Map(x)).ToList() |
Rule 6: Computed and Derived Properties
Properties that have no setter or init accessor on the target type are skipped. Properties that have no matching source property (and no [MapProperty] override) are also skipped -- but only if they have a default value or are nullable. If a non-nullable target property with no default has no source match, the generator emits a diagnostic warning.
[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
// Computed: no setter, no init. Skipped automatically.
public string Initials => Name?.Substring(0, 1) ?? "";
// Nullable: no source match required. Left as null.
public string? MiddleName { get; init; }
}[MapFrom(typeof(UserEntity))]
public class UserDto
{
public int Id { get; init; }
public string Name { get; init; }
// Computed: no setter, no init. Skipped automatically.
public string Initials => Name?.Substring(0, 1) ?? "";
// Nullable: no source match required. Left as null.
public string? MiddleName { get; init; }
}Real-World Example: DDD Layer Mapping
In a DDD architecture, data flows through three layers: Domain, Application, and Persistence. Each layer has its own shape. The domain model uses value objects and aggregates. The application layer uses DTOs. The persistence layer uses entities shaped for the database. Mapping between these layers is the most common use case for the Mapper pattern.
The Domain Model
The domain model is pure C# -- no mapping attributes, no framework dependencies. It uses value objects, guards, and domain logic:
namespace MyApp.Domain;
public class Order
{
public Guid Id { get; }
public CustomerId CustomerId { get; }
public Money TotalAmount { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderLine> Lines { get; }
public DateTimeOffset CreatedAt { get; }
public Order(
Guid id,
CustomerId customerId,
Money totalAmount,
OrderStatus status,
IReadOnlyList<OrderLine> lines,
DateTimeOffset createdAt)
{
Id = id;
CustomerId = customerId;
TotalAmount = totalAmount;
Status = status;
Lines = lines;
CreatedAt = createdAt;
}
}
public record CustomerId(string Value);
public record Money(decimal Amount, string Currency);
public class OrderLine
{
public Guid ProductId { get; }
public string ProductName { get; }
public int Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(Guid productId, string productName, int quantity, Money unitPrice)
{
ProductId = productId;
ProductName = productName;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
public enum OrderStatus { Pending, Confirmed, Shipped, Delivered, Cancelled }namespace MyApp.Domain;
public class Order
{
public Guid Id { get; }
public CustomerId CustomerId { get; }
public Money TotalAmount { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderLine> Lines { get; }
public DateTimeOffset CreatedAt { get; }
public Order(
Guid id,
CustomerId customerId,
Money totalAmount,
OrderStatus status,
IReadOnlyList<OrderLine> lines,
DateTimeOffset createdAt)
{
Id = id;
CustomerId = customerId;
TotalAmount = totalAmount;
Status = status;
Lines = lines;
CreatedAt = createdAt;
}
}
public record CustomerId(string Value);
public record Money(decimal Amount, string Currency);
public class OrderLine
{
public Guid ProductId { get; }
public string ProductName { get; }
public int Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(Guid productId, string productName, int quantity, Money unitPrice)
{
ProductId = productId;
ProductName = productName;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
public enum OrderStatus { Pending, Confirmed, Shipped, Delivered, Cancelled }The Application DTO
The DTO flattens value objects and uses primitive types suitable for serialization:
using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Application.Dtos;
[MapFrom(typeof(Domain.Order))]
[OneWay]
public class OrderDto
{
public Guid Id { get; init; }
[MapProperty("CustomerId")]
public string CustomerIdValue { get; init; }
[MapProperty("TotalAmount")]
public decimal TotalAmountValue { get; init; }
[IgnoreMapping]
public string TotalAmountCurrency { get; init; }
public OrderStatus Status { get; init; }
public IReadOnlyList<OrderLineDto> Lines { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
[MapFrom(typeof(Domain.OrderLine))]
[OneWay]
public class OrderLineDto
{
public Guid ProductId { get; init; }
public string ProductName { get; init; }
public int Quantity { get; init; }
[MapProperty("UnitPrice")]
public decimal UnitPriceAmount { get; init; }
[IgnoreMapping]
public string UnitPriceCurrency { get; init; }
}using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Application.Dtos;
[MapFrom(typeof(Domain.Order))]
[OneWay]
public class OrderDto
{
public Guid Id { get; init; }
[MapProperty("CustomerId")]
public string CustomerIdValue { get; init; }
[MapProperty("TotalAmount")]
public decimal TotalAmountValue { get; init; }
[IgnoreMapping]
public string TotalAmountCurrency { get; init; }
public OrderStatus Status { get; init; }
public IReadOnlyList<OrderLineDto> Lines { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
[MapFrom(typeof(Domain.OrderLine))]
[OneWay]
public class OrderLineDto
{
public Guid ProductId { get; init; }
public string ProductName { get; init; }
public int Quantity { get; init; }
[MapProperty("UnitPrice")]
public decimal UnitPriceAmount { get; init; }
[IgnoreMapping]
public string UnitPriceCurrency { get; init; }
}Notice the complexity here: CustomerId is a value object with a Value property. TotalAmount is a Money record with Amount and Currency properties. The DTO flattens these into primitives. Some of this flattening requires custom mapping logic that goes beyond simple property copying.
When the Generator Is Not Enough
The [MapProperty("CustomerId")] attribute on CustomerIdValue tells the generator to map from source.CustomerId. But source.CustomerId is a CustomerId record, not a string. The generator detects the type mismatch: the target property is string, the source property is CustomerId. There is no implicit conversion from CustomerId to string.
This is where you write a manual mapper for the complex cases and let the generator handle the simple ones:
using FrenchExDev.Net.Injectable.Attributes;
namespace MyApp.Application.Mappers;
[Injectable(Lifetime.Transient)]
public sealed class OrderToOrderDtoMapper : IMapper<Domain.Order, Dtos.OrderDto>
{
private readonly IMapper<Domain.OrderLine, Dtos.OrderLineDto> _lineMapper;
public OrderToOrderDtoMapper(
IMapper<Domain.OrderLine, Dtos.OrderLineDto> lineMapper)
{
_lineMapper = lineMapper;
}
public Dtos.OrderDto Map(Domain.Order source)
{
return new Dtos.OrderDto
{
Id = source.Id,
CustomerIdValue = source.CustomerId.Value,
TotalAmountValue = source.TotalAmount.Amount,
TotalAmountCurrency = source.TotalAmount.Currency,
Status = source.Status,
Lines = source.Lines.Select(l => _lineMapper.Map(l)).ToList(),
CreatedAt = source.CreatedAt,
};
}
}using FrenchExDev.Net.Injectable.Attributes;
namespace MyApp.Application.Mappers;
[Injectable(Lifetime.Transient)]
public sealed class OrderToOrderDtoMapper : IMapper<Domain.Order, Dtos.OrderDto>
{
private readonly IMapper<Domain.OrderLine, Dtos.OrderLineDto> _lineMapper;
public OrderToOrderDtoMapper(
IMapper<Domain.OrderLine, Dtos.OrderLineDto> lineMapper)
{
_lineMapper = lineMapper;
}
public Dtos.OrderDto Map(Domain.Order source)
{
return new Dtos.OrderDto
{
Id = source.Id,
CustomerIdValue = source.CustomerId.Value,
TotalAmountValue = source.TotalAmount.Amount,
TotalAmountCurrency = source.TotalAmount.Currency,
Status = source.Status,
Lines = source.Lines.Select(l => _lineMapper.Map(l)).ToList(),
CreatedAt = source.CreatedAt,
};
}
}This manual mapper coexists with generated mappers. The OrderLineDto mapper is still generated (because OrderLine properties map straightforwardly). The OrderDto mapper is manual because value-object flattening requires human judgment. The DI container sees both: one registered by [Injectable] on the manual class, one registered by [Injectable] on the generated class.
The design philosophy: Generate what can be generated. Write what requires judgment. Do not force everything through the generator, and do not write everything by hand. The attribute DSL handles the 80% case. Manual mappers handle the 20%.
The Persistence Entity
using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Persistence.Entities;
[MapFrom(typeof(Application.Dtos.OrderDto))]
public class OrderEntity
{
public Guid Id { get; set; }
public string CustomerId { get; set; }
public decimal TotalAmount { get; set; }
public string TotalAmountCurrency { get; set; }
public int Status { get; set; }
public List<OrderLineEntity> Lines { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Persistence.Entities;
[MapFrom(typeof(Application.Dtos.OrderDto))]
public class OrderEntity
{
public Guid Id { get; set; }
public string CustomerId { get; set; }
public decimal TotalAmount { get; set; }
public string TotalAmountCurrency { get; set; }
public int Status { get; set; }
public List<OrderLineEntity> Lines { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}The persistence entity maps straightforwardly from the DTO. Property names and types align. The generator handles this entirely -- no manual mapper needed.
The API Response
using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Api.Responses;
[MapFrom(typeof(Application.Dtos.OrderDto))]
[OneWay]
public class OrderResponse
{
public Guid Id { get; init; }
[MapProperty("CustomerIdValue")]
public string CustomerId { get; init; }
[MapProperty("TotalAmountValue")]
public decimal Amount { get; init; }
[MapProperty("TotalAmountCurrency")]
public string Currency { get; init; }
public string Status { get; init; }
public int LineCount { get; init; }
[IgnoreMapping]
public IReadOnlyList<string> Links { get; init; }
}using FrenchExDev.Net.Mapper.Attributes;
namespace MyApp.Api.Responses;
[MapFrom(typeof(Application.Dtos.OrderDto))]
[OneWay]
public class OrderResponse
{
public Guid Id { get; init; }
[MapProperty("CustomerIdValue")]
public string CustomerId { get; init; }
[MapProperty("TotalAmountValue")]
public decimal Amount { get; init; }
[MapProperty("TotalAmountCurrency")]
public string Currency { get; init; }
public string Status { get; init; }
public int LineCount { get; init; }
[IgnoreMapping]
public IReadOnlyList<string> Links { get; init; }
}Again, mostly generated. The [MapProperty] attributes handle the renaming. The [IgnoreMapping] on Links excludes the HATEOAS links that are populated by the API controller. The Status property requires a ToString() conversion from the enum, and LineCount requires a .Count call on the collection -- these would need a manual mapper or a post-processing step.
The Composition
In a single request, data flows through four mappers:
- Database query returns
OrderEntity. IMapper<OrderEntity, OrderDto>converts to application DTO. (Generated.)- Business logic processes the DTO.
IMapper<OrderDto, OrderResponse>converts to API response. (Generated, with manual override for computed properties.)
Each mapper is injected into the class that needs it. Each mapper is independently testable. Each mapper is a single class with a single Map method. There are no mapping profiles. There are no configuration lambdas. There are no CreateMap calls.
Testing with MapperAssertions
The FrenchExDev.Net.Mapper.Testing package provides two assertion methods and one exception type.
MapperAssertionException
namespace FrenchExDev.Net.Mapper.Testing;
public sealed class MapperAssertionException : Exception
{
public MapperAssertionException(string message) : base(message) { }
}namespace FrenchExDev.Net.Mapper.Testing;
public sealed class MapperAssertionException : Exception
{
public MapperAssertionException(string message) : base(message) { }
}A dedicated exception type, not a generic AssertionException, so you can distinguish mapper assertion failures from other test failures in your test runner output.
ShouldMapTo: Full Object Equality
ShouldMapTo maps a source instance and asserts that the result equals the expected target. It uses Equals for comparison, which means your target type must implement value equality (e.g., be a record, or override Equals):
using FrenchExDev.Net.Mapper.Testing;
public class UserEntityToUserDtoMapperTests
{
private readonly IMapper<UserEntity, UserDto> _sut;
public UserEntityToUserDtoMapperTests()
{
// The generated mapper has no dependencies for simple mappings
_sut = new UserEntityToUserDtoMapper();
}
[Fact]
public void Maps_all_properties_correctly()
{
var source = new UserEntity
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
EmailAddress = "jean@example.com",
IsActive = true,
CreatedAt = new DateTime(2024, 1, 15),
};
var expected = new UserDto
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
Email = "jean@example.com",
IsActive = true,
};
_sut.ShouldMapTo(source, expected);
}
[Fact]
public void Throws_when_mapping_is_incorrect()
{
var source = new UserEntity
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
EmailAddress = "jean@example.com",
IsActive = true,
};
var wrongExpected = new UserDto
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
Email = "wrong@example.com", // Does not match source
IsActive = true,
};
Assert.Throws<MapperAssertionException>(
() => _sut.ShouldMapTo(source, wrongExpected));
}
}using FrenchExDev.Net.Mapper.Testing;
public class UserEntityToUserDtoMapperTests
{
private readonly IMapper<UserEntity, UserDto> _sut;
public UserEntityToUserDtoMapperTests()
{
// The generated mapper has no dependencies for simple mappings
_sut = new UserEntityToUserDtoMapper();
}
[Fact]
public void Maps_all_properties_correctly()
{
var source = new UserEntity
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
EmailAddress = "jean@example.com",
IsActive = true,
CreatedAt = new DateTime(2024, 1, 15),
};
var expected = new UserDto
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
Email = "jean@example.com",
IsActive = true,
};
_sut.ShouldMapTo(source, expected);
}
[Fact]
public void Throws_when_mapping_is_incorrect()
{
var source = new UserEntity
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
EmailAddress = "jean@example.com",
IsActive = true,
};
var wrongExpected = new UserDto
{
Id = 42,
FirstName = "Jean",
LastName = "Dupont",
Email = "wrong@example.com", // Does not match source
IsActive = true,
};
Assert.Throws<MapperAssertionException>(
() => _sut.ShouldMapTo(source, wrongExpected));
}
}ShouldMapTo is an extension method on IMapper<TSource, TTarget>. It calls mapper.Map(source) and compares the result to expected using Equals. If they differ, it throws MapperAssertionException with a message that includes both the expected and actual values.
ShouldMapProperty: Single Property Assertion
When you want to test one property at a time -- common when the full object has many properties and you want focused, descriptive test names -- use ShouldMapProperty:
[Fact]
public void Maps_EmailAddress_to_Email()
{
var source = new UserEntity
{
Id = 1,
FirstName = "Marie",
LastName = "Curie",
EmailAddress = "marie@science.org",
IsActive = true,
};
_sut.ShouldMapProperty(source, dto => dto.Email, "marie@science.org");
}
[Fact]
public void Maps_FirstName()
{
var source = new UserEntity
{
Id = 1,
FirstName = "Marie",
LastName = "Curie",
EmailAddress = "marie@science.org",
IsActive = true,
};
_sut.ShouldMapProperty(source, dto => dto.FirstName, "Marie");
}
[Fact]
public void Maps_IsActive()
{
var source = new UserEntity
{
Id = 1,
FirstName = "Marie",
LastName = "Curie",
EmailAddress = "marie@science.org",
IsActive = false,
};
_sut.ShouldMapProperty(source, dto => dto.IsActive, false);
}[Fact]
public void Maps_EmailAddress_to_Email()
{
var source = new UserEntity
{
Id = 1,
FirstName = "Marie",
LastName = "Curie",
EmailAddress = "marie@science.org",
IsActive = true,
};
_sut.ShouldMapProperty(source, dto => dto.Email, "marie@science.org");
}
[Fact]
public void Maps_FirstName()
{
var source = new UserEntity
{
Id = 1,
FirstName = "Marie",
LastName = "Curie",
EmailAddress = "marie@science.org",
IsActive = true,
};
_sut.ShouldMapProperty(source, dto => dto.FirstName, "Marie");
}
[Fact]
public void Maps_IsActive()
{
var source = new UserEntity
{
Id = 1,
FirstName = "Marie",
LastName = "Curie",
EmailAddress = "marie@science.org",
IsActive = false,
};
_sut.ShouldMapProperty(source, dto => dto.IsActive, false);
}The third parameter is a Func<TTarget, TProp> selector that picks the property to compare. The fourth parameter is the expected value. This style produces test names like Maps_EmailAddress_to_Email that tell you exactly which mapping is broken when the test fails.
Testing Nested Mappers
When a mapper depends on nested mappers (injected via the constructor), you test it by injecting the real nested mapper or a stub:
public class OrderEntityToOrderDtoMapperTests
{
[Fact]
public void Maps_with_nested_line_mapper()
{
// Arrange: use the real generated line mapper
var lineMapper = new LineItemEntityToLineItemDtoMapper();
var sut = new OrderEntityToOrderDtoMapper(lineMapper);
var source = new OrderEntity
{
OrderId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Lines = new List<LineItemEntity>
{
new() { ProductId = Guid.NewGuid(), Name = "Widget", Quantity = 3 },
new() { ProductId = Guid.NewGuid(), Name = "Gadget", Quantity = 1 },
},
};
// Act
var result = sut.Map(source);
// Assert
Assert.Equal(2, result.Lines.Count);
Assert.Equal("Widget", result.Lines[0].Name);
Assert.Equal(3, result.Lines[0].Quantity);
}
[Fact]
public void Handles_null_collection()
{
var lineMapper = new LineItemEntityToLineItemDtoMapper();
var sut = new OrderEntityToOrderDtoMapper(lineMapper);
var source = new OrderEntity
{
OrderId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Lines = null,
};
var result = sut.Map(source);
Assert.NotNull(result.Lines);
Assert.Empty(result.Lines);
}
}public class OrderEntityToOrderDtoMapperTests
{
[Fact]
public void Maps_with_nested_line_mapper()
{
// Arrange: use the real generated line mapper
var lineMapper = new LineItemEntityToLineItemDtoMapper();
var sut = new OrderEntityToOrderDtoMapper(lineMapper);
var source = new OrderEntity
{
OrderId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Lines = new List<LineItemEntity>
{
new() { ProductId = Guid.NewGuid(), Name = "Widget", Quantity = 3 },
new() { ProductId = Guid.NewGuid(), Name = "Gadget", Quantity = 1 },
},
};
// Act
var result = sut.Map(source);
// Assert
Assert.Equal(2, result.Lines.Count);
Assert.Equal("Widget", result.Lines[0].Name);
Assert.Equal(3, result.Lines[0].Quantity);
}
[Fact]
public void Handles_null_collection()
{
var lineMapper = new LineItemEntityToLineItemDtoMapper();
var sut = new OrderEntityToOrderDtoMapper(lineMapper);
var source = new OrderEntity
{
OrderId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Lines = null,
};
var result = sut.Map(source);
Assert.NotNull(result.Lines);
Assert.Empty(result.Lines);
}
}The test instantiates the generated mapper directly, passing the nested mapper through the constructor. No DI container needed. No service provider. No mocking framework. The generated mapper is a plain class with a plain constructor.
Testing Manual Mappers
Manual mappers are tested identically. They implement the same IMapper<TSource, TTarget> interface and can use the same ShouldMapTo and ShouldMapProperty assertions:
public class OrderToOrderDtoManualMapperTests
{
[Fact]
public void Flattens_CustomerId_value_object()
{
var lineMapper = new OrderLineToOrderLineDtoMapper();
var sut = new OrderToOrderDtoMapper(lineMapper);
var source = new Order(
id: Guid.NewGuid(),
customerId: new CustomerId("CUST-001"),
totalAmount: new Money(99.99m, "EUR"),
status: OrderStatus.Confirmed,
lines: Array.Empty<OrderLine>(),
createdAt: DateTimeOffset.UtcNow);
sut.ShouldMapProperty(source, dto => dto.CustomerIdValue, "CUST-001");
}
[Fact]
public void Flattens_Money_value_object()
{
var lineMapper = new OrderLineToOrderLineDtoMapper();
var sut = new OrderToOrderDtoMapper(lineMapper);
var source = new Order(
id: Guid.NewGuid(),
customerId: new CustomerId("CUST-001"),
totalAmount: new Money(250.00m, "USD"),
status: OrderStatus.Pending,
lines: Array.Empty<OrderLine>(),
createdAt: DateTimeOffset.UtcNow);
sut.ShouldMapProperty(source, dto => dto.TotalAmountValue, 250.00m);
sut.ShouldMapProperty(source, dto => dto.TotalAmountCurrency, "USD");
}
}public class OrderToOrderDtoManualMapperTests
{
[Fact]
public void Flattens_CustomerId_value_object()
{
var lineMapper = new OrderLineToOrderLineDtoMapper();
var sut = new OrderToOrderDtoMapper(lineMapper);
var source = new Order(
id: Guid.NewGuid(),
customerId: new CustomerId("CUST-001"),
totalAmount: new Money(99.99m, "EUR"),
status: OrderStatus.Confirmed,
lines: Array.Empty<OrderLine>(),
createdAt: DateTimeOffset.UtcNow);
sut.ShouldMapProperty(source, dto => dto.CustomerIdValue, "CUST-001");
}
[Fact]
public void Flattens_Money_value_object()
{
var lineMapper = new OrderLineToOrderLineDtoMapper();
var sut = new OrderToOrderDtoMapper(lineMapper);
var source = new Order(
id: Guid.NewGuid(),
customerId: new CustomerId("CUST-001"),
totalAmount: new Money(250.00m, "USD"),
status: OrderStatus.Pending,
lines: Array.Empty<OrderLine>(),
createdAt: DateTimeOffset.UtcNow);
sut.ShouldMapProperty(source, dto => dto.TotalAmountValue, 250.00m);
sut.ShouldMapProperty(source, dto => dto.TotalAmountCurrency, "USD");
}
}Comparison: AutoMapper vs FrenchExDev.Mapper
The following table compares AutoMapper (the de facto standard runtime mapper for .NET) with FrenchExDev's source-generated mapper. This is not a general comparison of the two libraries' feature sets -- AutoMapper has vastly more features, including projection to IQueryable, conditional mapping, value resolvers, type converters, and global configuration. The comparison focuses specifically on the mapping use case that both libraries share: converting an object of type A to an object of type B.
| Dimension | AutoMapper | FrenchExDev.Mapper |
|---|---|---|
| Configuration | CreateMap<A, B>() in a Profile class |
[MapFrom(typeof(A))] on class B |
| Configuration location | Separate profile class(es) | On the types themselves |
| Validation timing | Runtime (AssertConfigurationIsValid()) |
Compile time (compiler error) |
| Property mismatch detection | Runtime assertion or silent null | Compile error (CS0117, CS0029) |
| Runtime cost (per map) | Compiled delegate (~5ns overhead) | Direct property assignment (~0ns overhead) |
| Startup cost | Reflection + expression compilation (tens of ms for large models) | Zero (code exists at compile time) |
| Debugging | Step through AutoMapper internals | Step through generated .g.cs file |
| Generated code visibility | None (runtime expression trees) | Full source in obj/generated/ |
| DI registration | services.AddAutoMapper(assemblies) |
Automatic via [Injectable] |
| Nested object mapping | Automatic (if configured) | Constructor-injected IMapper<TNested> |
| Collection mapping | Automatic | Automatic (element-by-element with injected mapper) |
| Reverse mapping | CreateMap<A, B>().ReverseMap() |
Automatic (unless [OneWay]) |
| IQueryable projection | ProjectTo<T>() |
Not supported (use raw LINQ) |
| Custom value resolvers | IValueResolver<TSource, TDest, TMember> |
Write a manual mapper |
| Conditional mapping | .Condition(src => ...) |
Write a manual mapper |
| Package size | ~500 KB + transitive deps | ~15 KB (attributes only; generated code is inline) |
| Dependencies | Microsoft.Extensions.DI, System.Linq.Expressions | None (attributes target netstandard2.0) |
| Learning curve | Moderate (profiles, resolvers, converters, conventions) | Minimal (5 attributes, 1 interface) |
When AutoMapper is the Better Choice
AutoMapper is the better choice when:
- You need
ProjectTo<T>()to translate mappings into SQL via EF Core. This is AutoMapper's killer feature and FrenchExDev.Mapper does not attempt to replicate it. - You need conditional mapping where a property is mapped only when a runtime condition is true.
- You need global conventions like "always map
CreatedDatetoCreated" applied across hundreds of types. - You are working in a codebase that already uses AutoMapper extensively and the migration cost outweighs the benefits.
When FrenchExDev.Mapper is the Better Choice
FrenchExDev.Mapper is the better choice when:
- You value compile-time safety over runtime flexibility.
- You want to debug mapping code by reading actual C# source files.
- You are in a serverless or microservice context where startup latency matters.
- Your mappings are straightforward property-to-property with occasional renaming.
- You want zero runtime dependencies for the mapping infrastructure.
- You are already using FrenchExDev's
[Injectable]source generator and want consistent DI registration.
Nullable Properties
The generator respects nullability annotations. When a source property is nullable and the target property is non-nullable, the generator emits a null-coalescing expression:
// Source
public class ContactEntity
{
public string? MiddleName { get; set; } // nullable
public int? Age { get; set; } // nullable
}
// Target
[MapFrom(typeof(ContactEntity))]
public class ContactDto
{
public string MiddleName { get; init; } // non-nullable
public int Age { get; init; } // non-nullable
}
// Generated
public ContactDto Map(ContactEntity source)
{
return new ContactDto
{
MiddleName = source.MiddleName ?? string.Empty,
Age = source.Age ?? default,
};
}// Source
public class ContactEntity
{
public string? MiddleName { get; set; } // nullable
public int? Age { get; set; } // nullable
}
// Target
[MapFrom(typeof(ContactEntity))]
public class ContactDto
{
public string MiddleName { get; init; } // non-nullable
public int Age { get; init; } // non-nullable
}
// Generated
public ContactDto Map(ContactEntity source)
{
return new ContactDto
{
MiddleName = source.MiddleName ?? string.Empty,
Age = source.Age ?? default,
};
}When the source is non-nullable and the target is nullable, no special handling is needed -- the assignment works directly.
When both sides are nullable, the assignment is direct:
// Source: string? -> Target: string?
MiddleName = source.MiddleName,// Source: string? -> Target: string?
MiddleName = source.MiddleName,Enum Mappings
Enums with the same underlying values map directly:
// Source
public enum OrderStatus { Pending = 0, Confirmed = 1, Shipped = 2 }
// Target
public enum OrderStatusDto { Pending = 0, Confirmed = 1, Shipped = 2 }
// Generated
Status = (OrderStatusDto)source.Status,// Source
public enum OrderStatus { Pending = 0, Confirmed = 1, Shipped = 2 }
// Target
public enum OrderStatusDto { Pending = 0, Confirmed = 1, Shipped = 2 }
// Generated
Status = (OrderStatusDto)source.Status,The generator emits an explicit cast when the enum types differ by name but share the same underlying type. If the underlying types differ (e.g., int vs byte), the generator still emits the cast, and the compiler verifies type safety.
If you need to map between enums with different member names or values, you must write a manual mapper or a conversion method.
Enum to String and String to Enum
When the source property is an enum and the target property is a string, the generator emits a .ToString() call:
// Source: OrderStatus Status
// Target: string Status
Status = source.Status.ToString(),// Source: OrderStatus Status
// Target: string Status
Status = source.Status.ToString(),When the source is a string and the target is an enum, the generator emits an Enum.Parse:
// Source: string Status
// Target: OrderStatus Status
Status = Enum.Parse<OrderStatus>(source.Status),// Source: string Status
// Target: OrderStatus Status
Status = Enum.Parse<OrderStatus>(source.Status),This is a deliberate convenience. If you need more sophisticated conversion (e.g., case-insensitive parsing, default values for unknown strings), write a manual mapper.
Inheritance in Source and Target
The generator maps properties declared on the concrete types. If a source type inherits properties from a base class, those inherited properties are included in the property enumeration:
public class BaseEntity
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public class UserEntity : BaseEntity
{
public string Name { get; set; }
public string Email { get; set; }
}
[MapFrom(typeof(UserEntity))]
public class UserDto
{
public Guid Id { get; init; } // From BaseEntity
public string Name { get; init; } // From UserEntity
public string Email { get; init; } // From UserEntity
public DateTimeOffset CreatedAt { get; init; } // From BaseEntity
}public class BaseEntity
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public class UserEntity : BaseEntity
{
public string Name { get; set; }
public string Email { get; set; }
}
[MapFrom(typeof(UserEntity))]
public class UserDto
{
public Guid Id { get; init; } // From BaseEntity
public string Name { get; init; } // From UserEntity
public string Email { get; init; } // From UserEntity
public DateTimeOffset CreatedAt { get; init; } // From BaseEntity
}All four properties are mapped, including Id and CreatedAt from BaseEntity. The generator sees the full property surface of the concrete type, not just the properties declared directly on it.
Collection Properties
The generator handles the following collection patterns:
List to IReadOnlyList:
// Source: List<LineItemEntity> Lines
// Target: IReadOnlyList<LineItemDto> Lines
Lines = source.Lines?.Select(x => _lineItemMapper.Map(x)).ToList()
?? (IReadOnlyList<LineItemDto>)Array.Empty<LineItemDto>(),// Source: List<LineItemEntity> Lines
// Target: IReadOnlyList<LineItemDto> Lines
Lines = source.Lines?.Select(x => _lineItemMapper.Map(x)).ToList()
?? (IReadOnlyList<LineItemDto>)Array.Empty<LineItemDto>(),Array to List:
// Source: TagEntity[] Tags
// Target: List<TagDto> Tags
Tags = source.Tags?.Select(x => _tagMapper.Map(x)).ToList()
?? new List<TagDto>(),// Source: TagEntity[] Tags
// Target: List<TagDto> Tags
Tags = source.Tags?.Select(x => _tagMapper.Map(x)).ToList()
?? new List<TagDto>(),Same element type (no nested mapper needed):
// Source: List<string> Tags
// Target: IReadOnlyList<string> Tags
Tags = source.Tags?.ToList() ?? (IReadOnlyList<string>)Array.Empty<string>(),// Source: List<string> Tags
// Target: IReadOnlyList<string> Tags
Tags = source.Tags?.ToList() ?? (IReadOnlyList<string>)Array.Empty<string>(),When the element types are the same (both string, both int, etc.), no nested mapper is required. The generator emits a simple .ToList() or .ToArray() conversion.
Dictionary properties:
// Source: Dictionary<string, string> Metadata
// Target: IReadOnlyDictionary<string, string> Metadata
Metadata = source.Metadata is not null
? new Dictionary<string, string>(source.Metadata)
: new Dictionary<string, string>(),// Source: Dictionary<string, string> Metadata
// Target: IReadOnlyDictionary<string, string> Metadata
Metadata = source.Metadata is not null
? new Dictionary<string, string>(source.Metadata)
: new Dictionary<string, string>(),Dictionaries with the same key and value types are shallow-copied. Dictionaries with different value types that require mapping are handled with a nested mapper on the values.
DI Registration
Generated mappers are automatically decorated with [Injectable(Lifetime.Transient)]. This means they participate in the same source-generated DI registration as every other FrenchExDev service.
How It Works
The [Injectable] source generator (from FrenchExDev.Net.Injectable.SourceGenerator) scans the assembly for all types decorated with [Injectable]. For each type, it determines the service type (the interface) and the implementation type (the class), and emits a registration line in the generated AddInjectables() extension method.
For a generated mapper, this looks like:
// Generated by FrenchExDev.Net.Injectable.SourceGenerator
public static class InjectableRegistration
{
public static IServiceCollection AddMyAppInjectables(this IServiceCollection services)
{
// ... other registrations ...
// Mapper registrations
services.AddTransient<
IMapper<UserEntity, UserDto>,
UserEntityToUserDtoMapper>();
services.AddTransient<
IMapper<AddressEntity, AddressDto>,
AddressEntityToAddressDtoMapper>();
services.AddTransient<
IMapper<OrderEntity, OrderDto>,
OrderEntityToOrderDtoMapper>();
// ... other registrations ...
return services;
}
}// Generated by FrenchExDev.Net.Injectable.SourceGenerator
public static class InjectableRegistration
{
public static IServiceCollection AddMyAppInjectables(this IServiceCollection services)
{
// ... other registrations ...
// Mapper registrations
services.AddTransient<
IMapper<UserEntity, UserDto>,
UserEntityToUserDtoMapper>();
services.AddTransient<
IMapper<AddressEntity, AddressDto>,
AddressEntityToAddressDtoMapper>();
services.AddTransient<
IMapper<OrderEntity, OrderDto>,
OrderEntityToOrderDtoMapper>();
// ... other registrations ...
return services;
}
}Registration in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMyAppInjectables();
var app = builder.Build();var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMyAppInjectables();
var app = builder.Build();One line. All mappers, all services, all handlers, all repositories -- everything decorated with [Injectable] -- registered in a single call. No services.AddAutoMapper(typeof(Program).Assembly). No assembly scanning. No reflection. The registrations are compile-time constants in generated code.
Lifetime: Why Transient?
Mappers are stateless. The Map method takes a source, returns a target, and holds no state between calls. Transient lifetime is the natural fit: a new instance per resolution, no shared state, no disposal concerns.
If a mapper takes nested mappers as constructor dependencies, those nested mappers are also transient. The DI container creates a fresh mapper graph for each resolution. In practice, the allocation cost is negligible -- these are small objects with no initialization logic.
If you have measured that mapper allocation is a performance bottleneck (unlikely, but possible in tight loops with millions of mappings), you can override the registration to use Singleton:
// Override a specific mapper to Singleton
services.AddSingleton<
IMapper<UserEntity, UserDto>,
UserEntityToUserDtoMapper>();// Override a specific mapper to Singleton
services.AddSingleton<
IMapper<UserEntity, UserDto>,
UserEntityToUserDtoMapper>();This is safe as long as the mapper (and all its nested mappers) are truly stateless.
Resolving Mappers
Mappers are resolved like any other DI service:
public class UserService
{
private readonly IMapper<UserEntity, UserDto> _mapper;
public UserService(IMapper<UserEntity, UserDto> mapper)
{
_mapper = mapper;
}
public UserDto GetUser(int id)
{
var entity = _repository.GetById(id);
return _mapper.Map(entity);
}
}public class UserService
{
private readonly IMapper<UserEntity, UserDto> _mapper;
public UserService(IMapper<UserEntity, UserDto> mapper)
{
_mapper = mapper;
}
public UserDto GetUser(int id)
{
var entity = _repository.GetById(id);
return _mapper.Map(entity);
}
}The service depends on the interface IMapper<UserEntity, UserDto>, not on the concrete UserEntityToUserDtoMapper. This means you can replace the generated mapper with a manual one, a mock, or a different implementation without changing the consuming code.
When to Use Attributes vs Manual Mappers
The attribute DSL is not a universal solution. It handles the common case -- property-to-property mapping with optional renaming and ignoring -- and explicitly does not handle the uncommon case. Here is the decision framework:
Use Attributes When:
Properties match by name and type. The source and target have properties with the same names and compatible types. This is the 80% case for DTO-to-Entity mapping.
Properties differ only by name.
[MapProperty("SourceName")]handles this cleanly.Some properties should be excluded.
[IgnoreMapping]marks them clearly on the target type.The mapping is bidirectional but some direction should be suppressed.
[OneWay]handles this.Nested objects have their own mappers. The generator composes mappers via constructor injection.
Collections of mapped types. The generator handles
List<T>,T[],IReadOnlyList<T>, and other common collection types.
Use Manual Mappers When:
Value objects need flattening.
Money(100, "EUR")becomes two properties:Amount = 100andCurrency = "EUR". The generator does not flatten value objects.Computed properties.
FullName = $"{FirstName} {LastName}"requires logic, not property copying.Conditional mapping. "Map
DiscountRateonly if the customer is premium." The generator does not support conditions.Type conversions that require parsing.
stringtoDateTimeOffset,stringtoUri, custom parsing logic.Aggregation.
LineCount = source.Lines.Count. The generator does not call methods on source properties.Multiple sources. A target property that combines data from two different source properties.
The Hybrid Approach
In practice, most applications use both. The attribute DSL handles the straightforward mappings -- which are the majority -- and manual mappers handle the handful of cases that require logic. Both implement the same IMapper<TSource, TTarget> interface. Both are registered via [Injectable]. Both are testable with MapperAssertions. The consuming code does not know or care which kind of mapper it received.
// This service does not know whether the mapper is generated or manual
public class OrderController
{
private readonly IMapper<OrderEntity, OrderDto> _toDtoMapper;
private readonly IMapper<CreateOrderRequest, Order> _toDomainMapper;
public OrderController(
IMapper<OrderEntity, OrderDto> toDtoMapper, // Generated
IMapper<CreateOrderRequest, Order> toDomainMapper) // Manual
{
_toDtoMapper = toDtoMapper;
_toDomainMapper = toDomainMapper;
}
}// This service does not know whether the mapper is generated or manual
public class OrderController
{
private readonly IMapper<OrderEntity, OrderDto> _toDtoMapper;
private readonly IMapper<CreateOrderRequest, Order> _toDomainMapper;
public OrderController(
IMapper<OrderEntity, OrderDto> toDtoMapper, // Generated
IMapper<CreateOrderRequest, Order> toDomainMapper) // Manual
{
_toDtoMapper = toDtoMapper;
_toDomainMapper = toDomainMapper;
}
}Advanced: Struct Mapping
All five attributes work with both class and struct targets. This is explicitly declared in the AttributeUsage:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)][AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true)]Struct mapping follows the same rules as class mapping, with one difference: the generated code uses a struct initializer rather than an object initializer:
public struct CoordinateEntity
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}
[MapFrom(typeof(CoordinateEntity))]
public struct CoordinateDto
{
public double Latitude { get; init; }
public double Longitude { get; init; }
}
// Generated
public CoordinateDto Map(CoordinateEntity source)
{
return new CoordinateDto
{
Latitude = source.Latitude,
Longitude = source.Longitude,
};
}public struct CoordinateEntity
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}
[MapFrom(typeof(CoordinateEntity))]
public struct CoordinateDto
{
public double Latitude { get; init; }
public double Longitude { get; init; }
}
// Generated
public CoordinateDto Map(CoordinateEntity source)
{
return new CoordinateDto
{
Latitude = source.Latitude,
Longitude = source.Longitude,
};
}Struct mappers are useful for high-performance scenarios where you want to avoid heap allocation. The mapper itself is allocated (it is a class implementing IMapper<TSource, TTarget>), but the mapped result is a value type on the stack.
Advanced: Multiple [MapFrom] Attributes
A single target type can declare multiple sources:
[MapFrom(typeof(InternalUserEntity))]
[MapFrom(typeof(ExternalUserPayload))]
[MapFrom(typeof(LdapUserRecord))]
public class UnifiedUserDto
{
public string UserId { get; init; }
public string DisplayName { get; init; }
public string Email { get; init; }
}[MapFrom(typeof(InternalUserEntity))]
[MapFrom(typeof(ExternalUserPayload))]
[MapFrom(typeof(LdapUserRecord))]
public class UnifiedUserDto
{
public string UserId { get; init; }
public string DisplayName { get; init; }
public string Email { get; init; }
}This generates three separate mapper classes:
InternalUserEntityToUnifiedUserDtoMapperExternalUserPayloadToUnifiedUserDtoMapperLdapUserRecordToUnifiedUserDtoMapper
Each mapper independently matches properties from its source type. A property that exists on InternalUserEntity but not on ExternalUserPayload will be mapped in the first mapper and left at default in the second (or cause a diagnostic if it is non-nullable without a default).
This pattern is common in integration scenarios where multiple external systems produce user records in different shapes, and you need to normalize them into a single internal representation.
Inspecting Generated Code
Generated source files are written to the obj/ directory during compilation. You can find them at:
obj/Debug/net10.0/generated/FrenchExDev.Net.Mapper.SourceGenerator/obj/Debug/net10.0/generated/FrenchExDev.Net.Mapper.SourceGenerator/In Visual Studio, you can also see them under the "Analyzers" node in Solution Explorer:
Dependencies
└── Analyzers
└── FrenchExDev.Net.Mapper.SourceGenerator
├── UserEntityToUserDtoMapper.g.cs
├── AddressEntityToAddressDtoMapper.g.cs
└── OrderEntityToOrderDtoMapper.g.csDependencies
└── Analyzers
└── FrenchExDev.Net.Mapper.SourceGenerator
├── UserEntityToUserDtoMapper.g.cs
├── AddressEntityToAddressDtoMapper.g.cs
└── OrderEntityToOrderDtoMapper.g.csYou can open these files, read them, set breakpoints in them, and step through them in the debugger. They are real C# files compiled into your assembly. This is fundamentally different from AutoMapper, where the mapping logic exists only as compiled delegates in memory.
When something goes wrong -- a property is null that should not be, a value is truncated, a collection is empty -- you open the generated file and read the assignment. The answer is right there. No Expression tree debugging. No reflection breakpoints. No AutoMapper source code diving. Just your mapping logic in plain C#.
Composition with Other FrenchExDev Patterns
The Mapper pattern composes with every other pattern in the framework through the shared IServiceCollection registration and the IMapper<TSource, TTarget> interface.
With Guard
Validate input before mapping:
public class CreateUserHandler
{
private readonly IMapper<CreateUserRequest, UserEntity> _mapper;
public CreateUserHandler(IMapper<CreateUserRequest, UserEntity> mapper)
{
_mapper = mapper;
}
public Result<UserEntity> Handle(CreateUserRequest request)
{
return Guard.ToResult
.NotNullOrWhiteSpace(request.Name)
.Bind(name => Guard.ToResult.NotNullOrWhiteSpace(request.Email))
.Map(_ => _mapper.Map(request));
}
}public class CreateUserHandler
{
private readonly IMapper<CreateUserRequest, UserEntity> _mapper;
public CreateUserHandler(IMapper<CreateUserRequest, UserEntity> mapper)
{
_mapper = mapper;
}
public Result<UserEntity> Handle(CreateUserRequest request)
{
return Guard.ToResult
.NotNullOrWhiteSpace(request.Name)
.Bind(name => Guard.ToResult.NotNullOrWhiteSpace(request.Email))
.Map(_ => _mapper.Map(request));
}
}With Option
Map when present, propagate None when absent:
public Option<UserDto> FindUser(int id)
{
return _repository.FindById(id) // Option<UserEntity>
.Map(entity => _mapper.Map(entity)); // Option<UserDto>
}public Option<UserDto> FindUser(int id)
{
return _repository.FindById(id) // Option<UserEntity>
.Map(entity => _mapper.Map(entity)); // Option<UserDto>
}With Mediator
Map in a query handler:
public class GetUserByIdHandler : IQueryHandler<GetUserById, UserDto>
{
private readonly IUserRepository _repository;
private readonly IMapper<UserEntity, UserDto> _mapper;
public GetUserByIdHandler(
IUserRepository repository,
IMapper<UserEntity, UserDto> mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<Result<UserDto>> HandleAsync(
GetUserById query, CancellationToken ct)
{
var entity = await _repository.GetByIdAsync(query.UserId, ct);
return entity is not null
? Result.Success(_mapper.Map(entity))
: Result.Failure<UserDto>("User not found.");
}
}public class GetUserByIdHandler : IQueryHandler<GetUserById, UserDto>
{
private readonly IUserRepository _repository;
private readonly IMapper<UserEntity, UserDto> _mapper;
public GetUserByIdHandler(
IUserRepository repository,
IMapper<UserEntity, UserDto> mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<Result<UserDto>> HandleAsync(
GetUserById query, CancellationToken ct)
{
var entity = await _repository.GetByIdAsync(query.UserId, ct);
return entity is not null
? Result.Success(_mapper.Map(entity))
: Result.Failure<UserDto>("User not found.");
}
}With Clock
Include timestamps from the injected clock in manual mappers:
[Injectable(Lifetime.Transient)]
public sealed class AuditEventMapper : IMapper<DomainEvent, AuditLogEntry>
{
private readonly IClock _clock;
public AuditEventMapper(IClock clock)
{
_clock = clock;
}
public AuditLogEntry Map(DomainEvent source)
{
return new AuditLogEntry
{
EventId = source.EventId,
EventType = source.GetType().Name,
OccurredAt = source.OccurredAt,
RecordedAt = _clock.UtcNow, // When we recorded it, not when it occurred
Payload = JsonSerializer.Serialize(source),
};
}
}[Injectable(Lifetime.Transient)]
public sealed class AuditEventMapper : IMapper<DomainEvent, AuditLogEntry>
{
private readonly IClock _clock;
public AuditEventMapper(IClock clock)
{
_clock = clock;
}
public AuditLogEntry Map(DomainEvent source)
{
return new AuditLogEntry
{
EventId = source.EventId,
EventType = source.GetType().Name,
OccurredAt = source.OccurredAt,
RecordedAt = _clock.UtcNow, // When we recorded it, not when it occurred
Payload = JsonSerializer.Serialize(source),
};
}
}Performance Characteristics
Because generated mappers are plain C# code -- object initializers and property assignments -- their runtime performance is identical to hand-written mapping code. There is no abstraction overhead. The JIT compiler sees the same IL it would see if you had written the assignments yourself.
Benchmark: Generated vs AutoMapper vs Hand-Written
The following measurements are representative of a typical entity-to-DTO mapping with 10 properties, one nested object, and one collection of 5 elements:
| Approach | Time per Map | Allocations | Startup Cost |
|---|---|---|---|
| Hand-written | ~12 ns | 1 DTO + 1 nested DTO + 1 List | None |
| FrenchExDev.Mapper (generated) | ~12 ns | 1 DTO + 1 nested DTO + 1 List | None |
| AutoMapper (compiled delegate) | ~17 ns | 1 DTO + 1 nested DTO + 1 List + delegate overhead | ~2 ms per mapping config |
| AutoMapper (uncompiled) | ~350 ns | Multiple intermediate allocations | None per-map but slower |
| Reflection (manual) | ~1200 ns | PropertyInfo[] + boxing + intermediate objects | None |
The key insight: generated mapping code is not "almost as fast" as hand-written code. It is the same code. The generator writes what you would write. The JIT compiles it the same way. The performance is identical by construction.
The startup cost difference matters in serverless environments. An Azure Function with 200 AutoMapper configurations spends 400 ms compiling expression trees on cold start. An Azure Function with 200 generated mappers spends 0 ms on mapper initialization because there is nothing to initialize. The mapping code already exists in the assembly.
Mistake 1: Forgetting [IgnoreMapping] on Computed Properties
[MapFrom(typeof(UserEntity))]
public class UserDto
{
public string FirstName { get; init; }
public string LastName { get; init; }
public string FullName { get; init; } // No source match, no [IgnoreMapping]
}[MapFrom(typeof(UserEntity))]
public class UserDto
{
public string FirstName { get; init; }
public string LastName { get; init; }
public string FullName { get; init; } // No source match, no [IgnoreMapping]
}If UserEntity does not have a FullName property, the generator will emit a diagnostic warning (or error, depending on configuration). Add [IgnoreMapping] to explicitly declare that this property is not mapped.
Mistake 2: Using [MapProperty] with a Misspelled Source Name
[MapProperty("EmaiAddress")] // Typo: should be "EmailAddress"
public string Email { get; init; }[MapProperty("EmaiAddress")] // Typo: should be "EmailAddress"
public string Email { get; init; }The generator validates the source property name at compile time. If UserEntity does not have a property named EmaiAddress, the generator emits a diagnostic error. This is caught during compilation, not at runtime.
Mistake 3: Expecting Deep Cloning
The generator produces shallow copies. If a source property is a reference type and the target property is the same reference type (no nested mapper), the generated code assigns the reference directly:
// Source: Dictionary<string, object> Metadata
// Target: Dictionary<string, object> Metadata
Metadata = source.Metadata, // Same reference, not a deep copy// Source: Dictionary<string, object> Metadata
// Target: Dictionary<string, object> Metadata
Metadata = source.Metadata, // Same reference, not a deep copyIf you need a deep copy, write a manual mapper that creates a new dictionary.
Mistake 4: Circular References
If OrderEntity has a CustomerEntity and CustomerEntity has a List<OrderEntity>, generating mappers for both creates a circular dependency. The DI container will throw at resolution time. Break the cycle by using [IgnoreMapping] on one side of the circular reference, or by using [OneWay] to suppress the reverse mapper.
Package Structure
The Mapper pattern is distributed across three NuGet packages:
| Package | Contains | Target Framework | Dependencies |
|---|---|---|---|
FrenchExDev.Net.Mapper |
IMapper<TSource, TTarget> interface |
netstandard2.0 |
None |
FrenchExDev.Net.Mapper.Attributes |
[MapFrom], [MapTo], [MapProperty], [IgnoreMapping], [OneWay] |
netstandard2.0 |
None |
FrenchExDev.Net.Mapper.Testing |
MapperAssertions, MapperAssertionException |
netstandard2.0 |
FrenchExDev.Net.Mapper |
The source generator that reads the attributes and emits the mapper classes is part of FrenchExDev.Net.Injectable.SourceGenerator, which is already referenced by any project using [Injectable]. You do not need a separate analyzer package for the mapper generator.
The netstandard2.0 target means all three packages work with .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+, and .NET 10. The source generator requires the Roslyn version that ships with .NET 6+ SDKs.
Attribute Cheat Sheet
| Attribute | Target | Purpose |
|---|---|---|
[MapFrom(typeof(T))] |
Class/Struct | "Generate a mapper from T to me" |
[MapTo(typeof(T))] |
Class/Struct | "Generate a mapper from me to T" |
[MapProperty("name")] |
Property | "Map this property from the named source property" |
[IgnoreMapping] |
Property | "Do not map this property" |
[OneWay] |
Class/Struct | "Do not generate the reverse mapper" |
Interface
public interface IMapper<in TSource, out TTarget>
{
TTarget Map(TSource source);
}public interface IMapper<in TSource, out TTarget>
{
TTarget Map(TSource source);
}Testing
// Full object equality
mapper.ShouldMapTo(source, expectedTarget);
// Single property
mapper.ShouldMapProperty(source, t => t.PropertyName, expectedValue);// Full object equality
mapper.ShouldMapTo(source, expectedTarget);
// Single property
mapper.ShouldMapProperty(source, t => t.PropertyName, expectedValue);Property Matching Summary
| Scenario | What Happens |
|---|---|
| Same name, same type | Automatic assignment |
| Same name, implicit conversion | Automatic assignment with widening |
| Same name, narrowing conversion | Not mapped (compile error) |
| Different name | Use [MapProperty("SourceName")] |
| Target property has no source match | Warning (or error if non-nullable) |
Target property has [IgnoreMapping] |
Skipped |
| Nested complex type | Injected IMapper<TNested> |
| Collection of complex type | Element-by-element with injected mapper |
| Nullable source, non-nullable target | Null-coalescing to default |
Summary
The Mapper pattern replaces runtime reflection with compile-time source generation. Five attributes -- [MapFrom], [MapTo], [MapProperty], [IgnoreMapping], [OneWay] -- tell the generator what to produce. One interface -- IMapper<in TSource, out TTarget> -- defines the contract. The generated code is plain C#: readable, debuggable, and compiled with the same type-safety guarantees as your hand-written code.
The pattern does not try to replace AutoMapper entirely. AutoMapper has features -- ProjectTo<T>(), conditional mapping, value resolvers, global conventions -- that FrenchExDev.Mapper deliberately does not replicate. What FrenchExDev.Mapper provides is a simpler, faster, safer alternative for the common case: property-to-property mapping between types that share a similar shape. For the 80% of mappings that are straightforward, the attributes handle everything. For the 20% that require logic, you write a manual mapper that implements the same interface.
Five attributes. One interface. Zero reflection. Compile-time errors instead of runtime surprises.
Next in the series: Part VII: The Mediator Pattern, where we implement CQRS dispatch with IMediator, ICommand<T>, IQuery<T>, pipeline behaviors via IBehavior<TRequest, TResult>, and three notification publish strategies -- all source-generated, all testable with FakeMediator.