Skip to content

Configuration Based Mapping

davidkallesen edited this page Mar 12, 2026 · 1 revision

πŸ”§ Configuration-Based Object Mapping

Map types you don't own -- third-party libraries, external DTOs, generated code -- without modifying the source types. Configuration-based mapping lets you define mappings in a separate mapper class or via assembly-level attributes, while the generator produces the same efficient extension methods as attribute-based [MapTo].

🎯 Key Benefits:

  • πŸ“¦ Map types you don't own - No need to modify source or target types
  • βœ… Same generated output - Produces identical extension methods as [MapTo]
  • πŸ”€ Multiple API styles - Mapper class pattern, assembly-level shorthand, class-level [MapTypes], or fluent MappingBuilder
  • 🀝 Mix with attributes - Use both [MapTo] and configuration in the same project
  • πŸš€ Native AOT ready - Pure compile-time generation with zero reflection

⚑ Quick Example:

// No attributes on the source type β€” it's from an external library
[MappingConfiguration]
public static partial class ExternalMappings
{
    public static partial TargetDto MapToTargetDto(this ExternalLib.Source source);
}

// Generated: Extension method (same output as [MapTo])
public static TargetDto MapToTargetDto(this ExternalLib.Source source) =>
    new TargetDto { Id = source.Id, Name = source.Name };

🧭 Documentation Navigation

Table of Contents


πŸ“– Overview and When to Use

The standard [MapTo] attribute requires you to decorate the source type directly. This works perfectly when you own the source code, but falls short in several common scenarios:

Scenario Problem with [MapTo] Solution
πŸ“¦ Third-party library types Can't add attributes to types you don't own Configuration-based mapping
πŸ€– Generated code (gRPC, EF scaffolding) Attributes get overwritten on regeneration Configuration-based mapping
πŸ“€ Shared packages Don't want mapping concerns in shared contracts Configuration-based mapping
πŸ”€ Cross-cutting mappings Mapping logic scattered across many files Centralized mapper class

Configuration-based mapping solves all of these by letting you define mappings externally in a dedicated mapper class or via assembly-level attributes β€” without touching the source or target types.

The generated output is identical to [MapTo]: efficient extension methods with null checks, constructor mapping, collection support, enum conversion, and all other mapping features.


πŸš€ Quick Start (5-Minute Guide)

Step 1: πŸ“¦ Install Package

dotnet add package Atc.SourceGenerators

πŸ’‘ Optional (recommended for better IntelliSense):

dotnet add package Atc.SourceGenerators.Annotations

Or in your .csproj:

<ItemGroup>
  <!-- Required: Source generator -->
  <PackageReference Include="Atc.SourceGenerators" Version="1.0.0" />

  <!-- Optional: Better IntelliSense for attributes -->
  <PackageReference Include="Atc.SourceGenerators.Annotations" Version="1.0.0" />
</ItemGroup>

Step 2: πŸ—οΈ Create a MappingConfiguration Class

Create a static partial class decorated with [MappingConfiguration]:

using Atc.SourceGenerators.Annotations;

[MappingConfiguration]
public static partial class ExternalMappings
{
}

Step 3: ✏️ Define Partial Extension Methods

Add partial extension methods for each mapping you need. The generator fills in the implementation:

using Atc.SourceGenerators.Annotations;
using ThirdParty.Models;
using MyApp.Dtos;

[MappingConfiguration]
public static partial class ExternalMappings
{
    // Properties are matched by name automatically
    public static partial CustomerDto MapToCustomerDto(this ExternalCustomer source);
}

Step 4: ✨ Use Generated Mappings

The generated extension methods work exactly like [MapTo] mappings:

using Atc.Mapping;

var externalCustomer = thirdPartyApi.GetCustomer(id);
CustomerDto dto = externalCustomer.MapToCustomerDto();

That's it! πŸŽ‰ The generator produces the same efficient mapping code as attribute-based mappings, but without modifying the source type.


πŸ“Š Comparison: All Mapping Approaches

Use this decision matrix to choose the right approach:

Criteria [MapTo] Attribute Mapper Class [MappingConfiguration] Assembly-Level [MapTypes] Class-Level [MapTypes] MappingBuilder Fluent API
You own the source type Preferred Works Works Works Works
Third-party / external types Can't modify Preferred Works Works Works
Generated types (gRPC, EF) Gets overwritten Preferred Works Works Works
Mapping definition location On the source type Separate mapper class Any file (typically AssemblyInfo.cs) Dedicated static class Program.cs / Startup.cs
Discoverability High (on the type) Medium (separate file) Low (assembly-level) Medium (grouped in class) High (in composition root)
Centralized control Scattered One location Scattered One location One location
Property ignore [MapIgnore] [MapConfigIgnore] IgnoreSourceProperties IgnoreSourceProperties ignoreSourceProperties
Property rename [MapProperty] [MapConfigProperty] PropertyMap PropertyMap propertyMap
Bidirectional Bidirectional = true [MapConfigOptions] Bidirectional = true Bidirectional = true bidirectional: true
IDE navigation Go to definition Go to definition No No No
Generated output Extension methods Identical extension methods Identical extension methods Identical extension methods Identical extension methods
Performance Zero overhead Zero overhead Zero overhead Zero overhead Zero overhead

πŸ’‘ Rule of thumb: Use [MapTo] when you own the type. Use configuration-based mapping when you don't -- or when you want all mappings in one place. For DI-integrated projects, MappingBuilder provides the most natural registration experience.


πŸ—ΊοΈ Mapper Class Pattern (Primary API)

The mapper class pattern is the primary way to define configuration-based mappings. You create a static partial class, decorate it with [MappingConfiguration], and define partial extension methods for each mapping.

πŸ”€ Basic Usage

Define a partial extension method with the source type as the this parameter and the target type as the return type. The generator matches properties by name (case-insensitive):

using Atc.SourceGenerators.Annotations;

[MappingConfiguration]
public static partial class ExternalMappings
{
    // Simple mapping - properties matched by name
    public static partial TargetDto MapToTargetDto(this ExternalLib.Source source);
}

What gets generated:

public static TargetDto MapToTargetDto(this ExternalLib.Source source)
{
    if (source is null)
    {
        return default!;
    }

    return new TargetDto
    {
        Id = source.Id,
        Name = source.Name,
        Email = source.Email
    };
}

You can define multiple mappings in the same class:

[MappingConfiguration]
public static partial class ExternalMappings
{
    public static partial CustomerDto MapToCustomerDto(this ExternalLib.Customer source);
    public static partial OrderDto MapToOrderDto(this ExternalLib.Order source);
    public static partial ProductDto MapToProductDto(this ExternalLib.Product source);
}

🚫 Property Ignore with MapConfigIgnore

Use [MapConfigIgnore] on a method to exclude specific properties from the mapping. This is the configuration-based equivalent of [MapIgnore] on a property.

[MappingConfiguration]
public static partial class ExternalMappings
{
    // Exclude InternalId and AuditTrail from the mapping
    [MapConfigIgnore("InternalId")]
    [MapConfigIgnore("AuditTrail")]
    public static partial UserDto MapToUserDto(this ThirdParty.User source);
}

What gets generated:

public static UserDto MapToUserDto(this ThirdParty.User source)
{
    if (source is null)
    {
        return default!;
    }

    return new UserDto
    {
        Id = source.Id,
        Name = source.Name,
        Email = source.Email
        // InternalId and AuditTrail are excluded
    };
}

The property name passed to [MapConfigIgnore] is matched against source properties. If the property is not found on the source type, diagnostic ATCMCF006 is emitted.


🏷️ Property Rename with MapConfigProperty

Use [MapConfigProperty] to map properties with different names between source and target types. This is the configuration-based equivalent of [MapProperty] on a property.

The first argument is the source property name and the second is the target property name:

[MappingConfiguration]
public static partial class ExternalMappings
{
    [MapConfigProperty("FullName", "DisplayName")]
    [MapConfigProperty("EmailAddress", "Email")]
    public static partial CustomerDto MapToCustomerDto(this ThirdParty.Contact source);
}

What gets generated:

public static CustomerDto MapToCustomerDto(this ThirdParty.Contact source)
{
    if (source is null)
    {
        return default!;
    }

    return new CustomerDto
    {
        DisplayName = source.FullName,     // Renamed: FullName -> DisplayName
        Email = source.EmailAddress,       // Renamed: EmailAddress -> Email
        Phone = source.Phone               // Auto-matched by name
    };
}

If the source property is not found, diagnostic ATCMCF006 is emitted. If the target property is not found, diagnostic ATCMCF007 is emitted.


βš™οΈ Advanced Options with MapConfigOptions

Use [MapConfigOptions] to enable advanced mapping features like bidirectional mapping:

[MappingConfiguration]
public static partial class ExternalMappings
{
    [MapConfigOptions(Bidirectional = true)]
    public static partial OrderDto MapToOrderDto(this ExternalLib.Order source);
}

With Bidirectional = true, the generator produces two extension methods:

// Forward mapping
public static OrderDto MapToOrderDto(this ExternalLib.Order source) { ... }

// Reverse mapping (auto-generated)
public static ExternalLib.Order MapToOrder(this OrderDto source) { ... }

You can combine [MapConfigOptions] with [MapConfigIgnore] and [MapConfigProperty]:

[MappingConfiguration]
public static partial class ExternalMappings
{
    [MapConfigOptions(Bidirectional = true)]
    [MapConfigIgnore("InternalId")]
    [MapConfigProperty("FullName", "DisplayName")]
    public static partial CustomerDto MapToCustomerDto(this ExternalLib.Customer source);
}

πŸ“Ž Assembly-Level Pattern (Shorthand API)

For simple mappings where you don't need the full mapper class pattern, you can use [assembly: MapTypes(...)] attributes. This is a concise shorthand that works well for straightforward type-to-type mappings.

πŸ”€ Basic Usage

Apply [assembly: MapTypes] in any file (typically AssemblyInfo.cs or a dedicated Mappings.cs):

using Atc.SourceGenerators.Annotations;

// Simple mapping - properties matched by name
[assembly: MapTypes(typeof(ExternalLib.Contact), typeof(MyApp.Customer))]

// With options
[assembly: MapTypes(
    typeof(ExternalLib.Order),
    typeof(MyApp.OrderDto),
    Bidirectional = true)]

// With property ignores
[assembly: MapTypes(
    typeof(ExternalLib.User),
    typeof(MyApp.UserDto),
    IgnoreSourceProperties = new[] { "InternalId", "AuditTrail" })]

🏷️ PropertyMap Syntax

Use the PropertyMap parameter to rename properties. Each entry uses "SourceProperty:TargetProperty" syntax:

[assembly: MapTypes(
    typeof(ExternalLib.Contact),
    typeof(MyApp.Customer),
    PropertyMap = new[] { "EmailAddress:Email", "PhoneNumber:Phone" },
    IgnoreSourceProperties = new[] { "InternalId" },
    Bidirectional = true)]

This generates the same output as the equivalent mapper class definition:

[MappingConfiguration]
public static partial class ContactMappings
{
    [MapConfigOptions(Bidirectional = true)]
    [MapConfigIgnore("InternalId")]
    [MapConfigProperty("EmailAddress", "Email")]
    [MapConfigProperty("PhoneNumber", "Phone")]
    public static partial Customer MapToCustomer(this ExternalLib.Contact source);
}

πŸ€” When to prefer assembly-level vs mapper class:

Assembly-Level [MapTypes] Mapper Class [MappingConfiguration]
Simple, straightforward mappings Complex mappings with many renames
Quick one-liners Multiple related mappings grouped together
No IDE navigation to method Navigate-to-definition support
Less ceremony More explicit and discoverable

πŸ”„ Enum Auto-Detection

When the source and target types have properties with different enum types, the generator automatically detects the mismatch and generates type-safe switch expression mappings -- no explicit enum mapping configuration required.

πŸ” How It Works

  1. πŸ” The generator compares source and target property types during mapping analysis
  2. πŸ”€ When it finds two properties with the same name but different enum types, it generates an inline switch expression
  3. πŸ”€ Enum values are matched by name (case-insensitive), with automatic special case handling
// Source (third-party library)
public class ExternalOrder
{
    public int Id { get; set; }
    public ExternalOrderStatus Status { get; set; }  // Different enum type
}

public enum ExternalOrderStatus { None, Pending, Shipped, Delivered }

// Target (your app)
public class OrderDto
{
    public int Id { get; set; }
    public OrderStatus Status { get; set; }  // Different enum type
}

public enum OrderStatus { Unknown, Pending, Shipped, Delivered }

// Configuration
[MappingConfiguration]
public static partial class OrderMappings
{
    public static partial OrderDto MapToOrderDto(this ExternalOrder source);
}

What gets generated:

public static OrderDto MapToOrderDto(this ExternalOrder source)
{
    if (source is null)
    {
        return default!;
    }

    return new OrderDto
    {
        Id = source.Id,
        Status = source.Status switch
        {
            ExternalOrderStatus.None => OrderStatus.Unknown,       // Special case auto-detected
            ExternalOrderStatus.Pending => OrderStatus.Pending,
            ExternalOrderStatus.Shipped => OrderStatus.Shipped,
            ExternalOrderStatus.Delivered => OrderStatus.Delivered,
            _ => throw new global::System.ArgumentOutOfRangeException(
                nameof(source.Status), source.Status, "Unmapped enum value"),
        }
    };
}

✨ Special Case Mappings

The generator uses EnumMappingHelper to automatically detect common naming patterns:

Source Value Auto-Detected Target Values
None Unknown, Default, NotSet
Active Enabled, On, Running
Inactive Disabled, Off, Stopped
Deleted Removed, Archived
Pending InProgress, Processing
Completed Done, Finished

These special cases work in both directions (e.g., Unknown also maps to None).

πŸ”§ Overriding Auto-Detected Mappings

If you need different enum mapping behavior than the auto-detected defaults, you have two options:

Option 1: Define an explicit [MapTo] attribute on the enum (if you own it):

[MapTo(typeof(OrderStatus))]
public enum ExternalOrderStatus
{
    None,       // Will use [MapTo] mapping rules instead of auto-detection
    Pending,
    Shipped,
    Delivered
}

When a [MapTo] attribute exists on the enum, the generator uses the attribute-based enum mapping extension method instead of generating an inline switch.

Option 2: If you don't own the enum either, use a manual conversion in a BeforeMap / AfterMap hook (see Working with Object Mapping for hook details).


🀝 Mixed Mode: Attributes + Configuration

You can use both [MapTo] attributes and configuration-based mapping in the same project. This is useful when migrating gradually or when some types are yours (use [MapTo]) and others are external (use configuration).

πŸ“ Precedence Rules

When both an attribute-based [MapTo] and a configuration-based mapping exist for the same source-to-target pair, the following rules apply:

  1. πŸ† Attribute-based [MapTo] always wins -- the configuration-based mapping is skipped
  2. ⚠️ Warning ATCMAP005 is emitted -- so you know about the conflict
  3. βœ… No duplicate extension methods -- only one mapping is generated
// Attribute-based (on the source type)
[MapTo(typeof(CustomerDto))]
public partial class Customer
{
    public string Name { get; set; } = string.Empty;
}

// Configuration-based (in mapper class) β€” this will be SKIPPED
[MappingConfiguration]
public static partial class Mappings
{
    // ATCMAP005: Mapping from Customer to CustomerDto already defined via [MapTo]
    public static partial CustomerDto MapToCustomerDto(this Customer source);
}

⚠️ ATCMAP005 Warning Explained

Severity: Warning

Message: Mapping from 'Customer' to 'CustomerDto' is already defined via [MapTo] attribute. The configuration-based mapping will be skipped.

Why it exists: Having two mapping definitions for the same type pair would produce duplicate extension methods. The generator resolves this by giving precedence to [MapTo] and warning you about the redundant configuration.

πŸ”§ How to fix:

  • βœ… Remove the configuration-based mapping if the [MapTo] attribute is correct
  • βœ… Remove the [MapTo] attribute if you want the configuration-based mapping to take effect
  • πŸ’‘ Use #pragma warning disable ATCMAP005 if both exist intentionally (e.g., during migration)

πŸ” Diagnostics Reference

The configuration-based mapping generator provides the following diagnostics during compilation.

🚨 ATCMCF001: Class Must Be Static

Severity: Error

The class decorated with [MappingConfiguration] is not static.

Example:

[MappingConfiguration]
public partial class ExternalMappings  // Missing 'static' keyword
{
    public static partial TargetDto MapToTargetDto(this Source source);
}

Fix:

[MappingConfiguration]
public static partial class ExternalMappings  // Added 'static'
{
    public static partial TargetDto MapToTargetDto(this Source source);
}

Why: Mapping configuration classes contain only static extension methods and must be declared static.


🚨 ATCMCF002: Class Must Be Partial

Severity: Error

The class decorated with [MappingConfiguration] is not partial.

Example:

[MappingConfiguration]
public static class ExternalMappings  // Missing 'partial' keyword
{
    public static partial TargetDto MapToTargetDto(this Source source);
}

Fix:

[MappingConfiguration]
public static partial class ExternalMappings  // Added 'partial'
{
    public static partial TargetDto MapToTargetDto(this Source source);
}

Why: The generator needs to emit the method implementations in a separate file, which requires the class to be partial.


🚨 ATCMCF004: Method Must Be an Extension Method

Severity: Error

A method in a [MappingConfiguration] class is not an extension method (missing this keyword on the first parameter).

Example:

[MappingConfiguration]
public static partial class ExternalMappings
{
    public static partial TargetDto MapToTargetDto(Source source);  // Missing 'this'
}

Fix:

[MappingConfiguration]
public static partial class ExternalMappings
{
    public static partial TargetDto MapToTargetDto(this Source source);  // Added 'this'
}

Why: The generator produces extension methods that are called as source.MapToTargetDto(). The first parameter must use the this modifier.


🚨 ATCMCF005: Return Type Must Be a Class, Struct, or Record

Severity: Error

The return type of a mapping method is not a concrete type.

Example:

[MappingConfiguration]
public static partial class ExternalMappings
{
    public static partial ITargetDto MapToTargetDto(this Source source);  // Interface return type
}

Fix:

[MappingConfiguration]
public static partial class ExternalMappings
{
    public static partial TargetDto MapToTargetDto(this Source source);  // Concrete class
}

Why: The generator needs to instantiate the return type using new. Interfaces and abstract classes cannot be instantiated.


🚨 ATCMCF006: Source Property Not Found

Severity: Error

A property name referenced in [MapConfigIgnore] or [MapConfigProperty] does not exist on the source type.

Example:

[MappingConfiguration]
public static partial class ExternalMappings
{
    [MapConfigIgnore("NonExistent")]  // Source type doesn't have this property
    public static partial TargetDto MapToTargetDto(this Source source);
}

Fix: Check the source type for the correct property name. Property names are case-sensitive in [MapConfigIgnore] and [MapConfigProperty].


🚨 ATCMCF007: Target Property Not Found

Severity: Error

A target property name referenced in [MapConfigProperty] does not exist on the target type.

Example:

[MappingConfiguration]
public static partial class ExternalMappings
{
    [MapConfigProperty("Name", "NonExistent")]  // Target type doesn't have this property
    public static partial TargetDto MapToTargetDto(this Source source);
}

Fix: Check the target type for the correct property name.


ℹ️ ATCMCF008: Unmatched Properties

Severity: Info

Lists source or target properties that could not be automatically matched by name.

This is an informational diagnostic to help you identify properties that are not being mapped. It does not prevent compilation.

πŸ”§ How to resolve:

  • 🏷️ Use [MapConfigProperty] to map properties with different names
  • 🚫 Use [MapConfigIgnore] to explicitly ignore properties you don't want mapped
  • βœ… No action needed if unmapped properties are intentional

🚨 ATCMCF010: Assembly MapTypes Source Type Not Found

Severity: Error

The source type specified in [assembly: MapTypes] could not be resolved.

Example:

[assembly: MapTypes(typeof(NonExistent.Type), typeof(MyApp.TargetDto))]  // Type not found

Fix: Ensure the source type is accessible from the current project. You may need to add a project or package reference.


⚠️ ATCMAP005: Duplicate Mapping (Attribute Wins)

Severity: Warning

Message: Mapping from 'SourceType' to 'TargetType' is already defined via [MapTo] attribute. The configuration-based mapping will be skipped.

This occurs when both a [MapTo] attribute and a configuration-based mapping target the same source-to-target pair. See Mixed Mode: Attributes + Configuration for details.


⚠️ ATCMAP006: Duplicate Configuration Mapping

Severity: Warning

Two configuration-based mappings define the same source-to-target pair.

Example:

[MappingConfiguration]
public static partial class Mappings
{
    public static partial TargetDto MapToTargetDto(this Source source);
    public static partial TargetDto ConvertToTarget(this Source source);  // Duplicate pair
}

Fix: Remove one of the duplicate mappings. Only one mapping per source-to-target pair is supported.


🚨 ATCMCF011: Map() Requires Exactly Two Type Arguments

Severity: Error

A Map() call in a MappingBuilder lambda does not have exactly two type arguments.

Example:

builder.Services.AddMappings(map =>
{
    map.Map<AddressDto>();  // Missing second type argument
});

Fix: Provide both source and target type arguments: map.Map<AddressDto, AddressEntity>().


🚨 ATCMCF013: AddMappings() Requires a Lambda Expression

Severity: Error

The argument to AddMappings() or MappingBuilder.Configure() is not a lambda expression.

Example:

Action<MappingBuilder> config = GetMappingConfig();
builder.Services.AddMappings(config);  // Must be an inline lambda

Fix: Pass an inline lambda expression:

builder.Services.AddMappings(map =>
{
    map.Map<AddressDto, AddressEntity>();
});

Why: The generator parses the lambda at compile time. Delegates, method groups, and variables cannot be analyzed statically.


⚠️ ATCMCF014: Type Not Resolved

Severity: Warning

A type referenced in a mapping method could not be resolved. This typically occurs when the type is produced by another source generator (sibling generator) running in the same compilation.

Example:

[MappingConfiguration]
public static partial class Mappings
{
    // ATCMCF014: If 'GeneratedDto' is produced by another source generator
    public static partial GeneratedDto MapToDto(this MyDomain.Entity source);
}

Fix: Move the generated type to a separate project (compiled assembly reference). See Integration with Atc.Rest.Api.SourceGenerator for the recommended multi-project setup.

Why: Source generators cannot reference types produced by sibling generators in the same compilation. This is a fundamental Roslyn limitation, not a bug in your code.


πŸ—οΈ Constructor and Struct Support

The configuration-based mapping generator supports constructor mapping, struct source types, and several advanced type-matching scenarios.

πŸ”¨ Constructor Mapping for Positional Records

The generator automatically detects constructors on target types (records, classes with primary constructors) and uses constructor call syntax when all parameters can be matched from source properties:

// Target record with positional parameters
public record TargetDto(string Name, int Width, int Height);

// Source type (third-party)
public class Widget { public string Name { get; set; } public int Width { get; set; } public int Height { get; set; } }

[MappingConfiguration]
public static partial class Mappings
{
    public static partial TargetDto MapToTargetDto(this Widget source);
}

What gets generated:

public static TargetDto MapToTargetDto(this Widget source)
{
    if (source is null)
    {
        return default!;
    }

    return new TargetDto(
        source.Name,
        source.Width,
        source.Height);
}

If not all constructor parameters match source properties, unmatched parameters use defaults (null for reference types, default for value types).

🏷️ MapConfigProperty with Constructor Parameters

When [MapConfigProperty] renames map to constructor parameter names, the generator uses named arguments to ensure correct parameter binding:

public record GridPositionDto(int Row, int Column);

[MappingConfiguration]
public static partial class Mappings
{
    [MapConfigProperty("X", "Column")]
    [MapConfigProperty("Y", "Row")]
    public static partial GridPositionDto MapToGridPositionDto(this GridPosition source);
}

What gets generated:

return new GridPositionDto(Row: source.Y, Column: source.X);

πŸ“¦ Struct Source Types

When the source type is a value type (struct), the generator omits the null guard. Structs cannot be null, so the if (source is null) check would cause CS0037:

public struct Point { public int X { get; set; } public int Y { get; set; } }

[MappingConfiguration]
public static partial class Mappings
{
    public static partial PointDto MapToPointDto(this Point source);
}

What gets generated:

public static PointDto MapToPointDto(this Point source)
{
    // No null guard β€” structs cannot be null
    return new PointDto
    {
        X = source.X,
        Y = source.Y
    };
}

πŸ”— Value Type Nested Mapping

When mapping a reference-type property to a value-type (struct) target, the generator uses a ternary pattern instead of ?. to avoid producing Nullable<T>:

// source.Location?.MapToPosition()! would produce Position? (nullable struct)
// Instead, the generator emits:
source.Location is not null ? source.Location.MapToPosition() : default

πŸ”’ Numeric Type Conversions in Constructors

Constructor parameter matching accepts numeric type pairs (e.g., uint to int, ushort to int). The generator inserts explicit casts:

// Source has uint Width, target constructor takes int width
return new TargetDto((int)source.Width, (int)source.Height);

πŸ“‹ Collection Type Compatibility in Constructors

Constructor matching accepts collection parameters with compatible element types. For example, IList<SourceItem> matches a List<TargetItem> constructor parameter when SourceItem and TargetItem are mappable custom types.


πŸ”„ Built-in Type Conversions

The generator automatically handles common type conversions between source and target properties.

🌐 String ↔ Uri

// string β†’ Uri
new global::System.Uri(source.Link)

// Uri β†’ string
source.Link.ToString()

// Nullable variants use null-safe patterns
// string? β†’ Uri?
source.Link is not null ? new global::System.Uri(source.Link) : null

// Uri? β†’ string?
source.Link?.ToString()

🏷️ String ↔ Enum

// string β†’ enum
global::System.Enum.TryParse<global::TargetNs.StatusType>(source.Status, out var __parsed)
    ? __parsed
    : default(global::TargetNs.StatusType)

// enum β†’ string
source.Status.ToString()

// Nullable enum β†’ string
source.Status?.ToString()

πŸ”’ Numeric Type Conversions

Widening and narrowing conversions between numeric types generate explicit casts:

// int β†’ double
(double)source.Value

// long β†’ int
(int)source.Count

// Nullable variants
(double?)source.Value

❓ Nullable Type Handling

The generator unwraps Nullable<T> for type analysis:

  • int? to int? β€” mapped as numeric conversion
  • MyEnum? to MyEnum? β€” mapped with null-safe pattern:
source.Status is { } __status ? __status.MapToTargetEnum() : null

πŸ”— Auto-Generated Nested Type Mappings

When a mapping references nested types (via properties or collection elements) that don't have their own explicit mapping declaration, the generator automatically creates private mapping sub-methods. This is recursive β€” if the nested type itself has nested properties, those are also generated.

// Only declare the top-level mapping
[MappingConfiguration]
public static partial class Mappings
{
    public static partial TargetNs.Order MapToOrder(this SourceNs.Order source);
    // If Order has a Detail property of type OrderDetail,
    // and OrderDetail has a Price property of type Money,
    // the generator auto-creates private MapToOrderDetail and MapToMoney sub-methods
}

Key behaviors:

  • πŸ”„ Works for both nested object properties AND collection element types
  • 🚫 Avoids duplicate generation β€” if a top-level mapping already exists for the type pair, no sub-method is generated
  • πŸ”’ Sub-methods are emitted as private static methods within the mapping class
  • πŸ” Recursion is unbounded β€” deeply nested structures are fully supported

πŸ”’ Private Setter and Metadata Type Support

When the target type comes from a compiled assembly reference (DLL), properties with { get; private set; } are still included in property matching.

Roslyn hides private setters on metadata-loaded types (SetMethod returns null), but the generator no longer requires a public setter for target property matching. This is critical for positional records that re-declare properties with private setters for controlled mutability.

// Target from an external assembly (DLL reference)
public record ExternalDto
{
    public string Name { get; private set; }  // Private setter β€” still matched
    public int Value { get; private set; }    // Private setter β€” still matched
}

Properties are matched by name and included in constructor mapping or object initializer as appropriate, regardless of setter accessibility on metadata-loaded types.


πŸ’‘ Best Practices

  1. πŸ“‚ Group related mappings together. Create one [MappingConfiguration] class per external library or bounded context:

    // PaymentGatewayMappings.cs
    [MappingConfiguration]
    public static partial class PaymentGatewayMappings
    {
        public static partial PaymentDto MapToPaymentDto(this Stripe.Payment source);
        public static partial RefundDto MapToRefundDto(this Stripe.Refund source);
    }
  2. 🎯 Prefer the mapper class pattern over assembly-level [MapTypes] for mappings with property renames or ignores. The mapper class provides better discoverability and IDE navigation.

  3. 🏷️ Use [MapTo] when you own the type. Don't reach for configuration-based mapping if you can simply decorate the source type. Attribute-based mapping is more discoverable and colocated with the type.

  4. πŸ“› Name your mapper methods consistently. Follow the MapTo{TargetType} convention to match the pattern used by [MapTo]-generated methods:

    // Consistent naming
    public static partial CustomerDto MapToCustomerDto(this Contact source);
    
    // Inconsistent naming (avoid)
    public static partial CustomerDto ConvertContact(this Contact source);
  5. πŸ”„ Handle enum mismatches explicitly when auto-detection doesn't match your requirements. Review the generated switch expressions to ensure special case mappings (e.g., None to Unknown) are correct for your domain.

  6. πŸ”’ Use [MapConfigIgnore] for sensitive data. When mapping external types that contain internal identifiers, tokens, or other sensitive properties, explicitly ignore them:

    [MapConfigIgnore("ApiSecret")]
    [MapConfigIgnore("InternalTrackingId")]
    public static partial PublicUserDto MapToPublicUserDto(this ExternalUser source);
  7. ⚠️ Don't mix both approaches for the same pair. If a source-to-target pair already has a [MapTo] attribute, don't also define it in a configuration class. The ATCMAP005 warning exists to catch this.


πŸ†• Phase 9: Class-Level MapTypes and MappingBuilder

Phase 9 introduces two additional ways to define configuration-based mappings: class-level [MapTypes] for better organization and a fluent MappingBuilder API for registration in Program.cs/Startup.cs.

🏷️ Class-Level [MapTypes] Pattern

In addition to applying [MapTypes] at the assembly level, you can apply it on a static class. This provides the same functionality but scopes the mappings to a class for better organization and discoverability.

[MapTypes(typeof(DeliveryReport), typeof(DeliveryStatus))]
[MapTypes(typeof(AddressDto), typeof(AddressEntity), Bidirectional = true)]
static class Mappings;

This is semantically equivalent to assembly-level [MapTypes]:

[assembly: MapTypes(typeof(DeliveryReport), typeof(DeliveryStatus))]
[assembly: MapTypes(typeof(AddressDto), typeof(AddressEntity), Bidirectional = true)]

βœ… Benefits over assembly-level:

  • πŸ“‚ Mappings are grouped in a named class, making them easier to find
  • πŸ—‚οΈ Multiple mapping classes can organize mappings by domain or external library
  • πŸ” Better code navigation in IDEs (find the class, see all its mappings)

Example: Organizing by external library:

[MapTypes(typeof(Stripe.Payment), typeof(PaymentDto))]
[MapTypes(typeof(Stripe.Refund), typeof(RefundDto))]
static class StripeMappings;

[MapTypes(typeof(SendGrid.Message), typeof(EmailDto))]
[MapTypes(typeof(SendGrid.Contact), typeof(RecipientDto), Bidirectional = true)]
static class SendGridMappings;

All [MapTypes] parameters work identically in both assembly-level and class-level contexts: PropertyMap, IgnoreSourceProperties, IgnoreTargetProperties, Bidirectional, and PropertyNameStrategy.


πŸ”— Inline Registration with MappingBuilder

Register mappings in Program.cs or Startup.cs using a fluent API. This approach integrates naturally with the standard .NET service registration pattern.

With DI (generates AddMappings extension on IServiceCollection):

// In Program.cs
builder.Services.AddMappings(map =>
{
    map.Map<AddressDto, AddressEntity>(bidirectional: true);
    map.Map(typeof(OrderDto), typeof(OrderEntity));
    map.Map<CustomerDto, CustomerEntity>(
        propertyMap: new[] { "FullName:Name" },
        ignoreSourceProperties: new[] { "InternalId" });
});

Without DI:

MappingBuilder.Configure(map =>
{
    map.Map<AddressDto, AddressEntity>(bidirectional: true);
});

Both forms are no-ops at runtime β€” the generator parses the lambda expression at compile time to produce the same extension methods as all other mapping approaches. The AddMappings() call and MappingBuilder.Configure() call are completely eliminated from the compiled output.

Map() Parameters:

Parameter Type Description
bidirectional bool Generate both forward and reverse mappings
propertyMap string[] Rename properties with "Source:Target" syntax
ignoreSourceProperties string[] Exclude source properties from mapping
ignoreTargetProperties string[] Exclude target properties from mapping
propertyNameStrategy PropertyNameStrategy PascalCase/camelCase/snake_case conversion

πŸ€” When to prefer MappingBuilder:

  • πŸ”§ Your project already uses builder.Services for all registration
  • πŸ‘οΈ You want mapping configuration visible in the composition root alongside DI and options
  • πŸ’§ You prefer fluent APIs over attributes
  • πŸ“ You want all configuration in one place (Program.cs)

⚠️ Limitations:

  • The argument to AddMappings() and MappingBuilder.Configure() must be an inline lambda expression. Variables, method groups, and delegates cannot be analyzed at compile time (see diagnostic ATCMCF013).
  • Each Map() call must have exactly two type arguments (see diagnostic ATCMCF011).

πŸ“š Related Documentation


For more information and examples, visit the Atc.SourceGenerators GitHub repository.

🏠 Home

πŸ“– Getting Started

⚑ Generators

🎯 Examples

πŸ”— Integrations

πŸ” Reference

πŸ“‹ Feature Roadmaps


πŸ”— Resources

Clone this wiki locally