Pragmatic.Mapping
High-performance, source-generated object-to-object mapping for .NET 10.
The Problem
Section titled “The Problem”Every application needs to convert between entities and DTOs. The two standard approaches each have significant drawbacks.
AutoMapper relies on runtime reflection, hides failures until production, is incompatible with AOT compilation, and makes debugging opaque. Rename a property and the mapping silently breaks.
Manual mapping is correct but tedious. Every entity/DTO pair requires hand-written code in both directions. Add a property to the entity, forget to update the mapper, and the new property silently gets default. With 30 entities and 60 DTOs, you maintain hundreds of mapping methods.
Both approaches force a tradeoff between safety and convenience.
The Solution
Section titled “The Solution”Pragmatic.Mapping eliminates the tradeoff. You declare the mapping relationship with an attribute, and the source generator produces the mapping code at compile time:
- Visible — generated code in
obj/, fully debuggable, step-through-able - Type-safe — property mismatches caught at build time with PRAG03xx diagnostics
- Zero-reflection — no runtime discovery, no startup cost, AOT-compatible
- Zero-dependency — no DI registration, no runtime library, just static methods
How It Works
Section titled “How It Works”[MapFrom<User>] Pragmatic.SourceGeneratorpublic partial class UserDto ---> generates partial class:{ - FromEntity(User entity) public Guid Id { get; init; } - Selector (Func<User, UserDto>) public string Name { get; init; } - Projection (Expression<>)} - Extension: user.ToUserDto() - Extension: users.ToUserDto()- You decorate a
partialclass/record/struct with[MapFrom<T>]or[MapTo<T>]. - The Pragmatic source generator analyzes properties at compile time.
- It emits a partial class with strongly-typed static methods and a companion extensions class.
- No DI registration needed — everything is static.
Installation
Section titled “Installation”dotnet add package Pragmatic.MappingAdd the analyzer as a project reference:
<ProjectReference Include="..\Pragmatic.SourceGenerator\src\Pragmatic.SourceGenerator\Pragmatic.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />For EF Core projection helpers (optional):
dotnet add package Pragmatic.Mapping.EFCoreQuick Start
Section titled “Quick Start”Given an entity:
public class Guest{ public Guid Id { get; set; } public string FirstName { get; set; } = ""; public string LastName { get; set; } = ""; public string Email { get; set; } = ""; public string? Phone { get; set; }}Create a DTO with [MapFrom<T>]:
using Pragmatic.Mapping.Attributes;
[MapFrom<Guest>][GenerateProjection]public partial class GuestDto{ public Guid Id { get; init; } public string FirstName { get; init; } = ""; public string LastName { get; init; } = ""; public string Email { get; init; } = ""; public string? Phone { get; init; }
[MapIgnore] public string FullName => $"{FirstName} {LastName}";}The generator creates:
// Static factoryvar dto = GuestDto.FromEntity(guest);
// Extension methodsvar dto = guest.ToGuestDto();var dtos = guests.ToGuestDto(); // IEnumerable<Guest>var dtos = guestList.ToGuestDto(); // List<Guest>var dtos = guestArray.ToGuestDto(); // Guest[]
// In-memory delegate (for LINQ Select)var dtos = guests.Select(GuestDto.Selector);
// EF Core projection (translates to SQL SELECT)var dtos = await db.Guests .Where(g => g.Email != null) .Select(GuestDto.Projection) .ToListAsync();This example comes from the Showcase app. See examples/showcase/src/Showcase.Booking/Dtos/GuestDto.cs.
Attributes Reference
Section titled “Attributes Reference”[MapFrom<TSource>]
Section titled “[MapFrom<TSource>]”Generates mapping from a source type (typically an entity) to the decorated DTO.
Generated members:
static TDto FromEntity(TSource entity)— static factory methodstatic Func<TSource, TDto> Selector— delegate for in-memory LINQ- Extension methods:
entity.ToDto(),IEnumerable<T>.ToDto(),List<T>.ToDto(),T[].ToDto()
Usage:
[MapFrom<User>]public partial class UserDto{ public Guid Id { get; init; } public string Name { get; init; } = "";}Requirements: The type must be declared as partial. Supports class, record, struct, and record struct.
[MapTo<TTarget>]
Section titled “[MapTo<TTarget>]”Generates mapping from the DTO to a target type (typically an entity).
Generated members:
TTarget ToEntity()— creates a new entity instancevoid ApplyTo(TTarget entity)— updates an existing entity (partial update semantics for nullable properties)
Usage:
[MapTo<User>]public partial record CreateUserDto{ public string Email { get; init; } = ""; public string FirstName { get; init; } = "";}
// Creates a new entityUser user = dto.ToEntity();
// Updates an existing entitydto.ApplyTo(existingUser);Note: ID properties (Id, {EntityName}Id) are excluded from ToEntity() by default. Use [MapProperty] on the ID property to force inclusion.
[GenerateProjection]
Section titled “[GenerateProjection]”Generates Expression<Func<TSource, TDto>> Projection for EF Core Select() queries. Requires [MapFrom<T>] on the same type.
Projections translate to SQL, so only SQL-translatable operations are supported. Converters ([MapConverter]), format strings, and CustomizeMapping() are excluded from projections with compile-time warnings.
Usage:
[MapFrom<Property>][GenerateProjection]public partial class PropertySummaryDto{ public Guid Id { get; init; } public string Name { get; init; } = ""; public string City { get; init; } = ""; public int StarRating { get; init; }}
// SQL-optimized queryvar dtos = await db.Properties .Where(p => p.IsActive) .Select(PropertySummaryDto.Projection) .ToListAsync();See Projections Guide for details.
[GenerateBodyOnlyVariant]
Section titled “[GenerateBodyOnlyVariant]”Generates an additional FromEntityBodyOnly() method that maps only scalar properties, skipping collections, nested DTOs, and dictionaries. Useful for mutation scenarios.
[MapFrom<Reservation>][GenerateBodyOnlyVariant]public partial class ReservationSummaryDto{ public Guid Id { get; init; } public decimal TotalAmount { get; init; } // Mapped in both public ReservationStatus Status { get; init; } // Mapped in both
[MapProperty("Guest.FirstName")] public string GuestFirstName { get; init; } = ""; // Skipped in BodyOnly}[MapProperty]
Section titled “[MapProperty]”Customizes how a single property is mapped.
Capabilities:
| Feature | Syntax | Example |
|---|---|---|
| Explicit source path | [MapProperty("Address.City")] | Flatten navigation |
| Concatenation | [MapProperty("FirstName", "LastName")] | Multiple paths joined |
| Custom separator | Separator = ", " | "Doe, John" |
| Format string | Format = "yyyy-MM-dd" | Date formatting via .ToString(format) |
| Default value | Default = "N/A" | Fallback for nullable to non-nullable |
| Target path (MapTo) | Target = "Customer.Name" | Map to nested entity property |
Examples from Showcase:
// Navigation flattening (Showcase: RoomTypeSummaryDto)[MapProperty("Property.Name")]public string PropertyName { get; init; } = "";
// Format string (Showcase: PropertyDetailDto)[MapProperty("CreatedAt", Format = "yyyy-MM-dd")]public string CreatedDate { get; init; } = "";
// Concatenation[MapProperty(nameof(User.FirstName), nameof(User.LastName))]public string FullName { get; init; } = "";
// Default value for nullable source[MapProperty(nameof(User.MiddleName), Default = "N/A")]public string MiddleName { get; init; } = "";[MapIgnore]
Section titled “[MapIgnore]”Excludes a property from mapping. Use for computed properties or properties populated elsewhere.
[MapFrom<Guest>]public partial class GuestDto{ public string FirstName { get; init; } = ""; public string LastName { get; init; } = "";
[MapIgnore] public string FullName => $"{FirstName} {LastName}";}[MapConverter<TConverter>]
Section titled “[MapConverter<TConverter>]”Specifies a custom IValueConverter<TSource, TTarget> for a property. The converter must have a parameterless constructor.
Not supported in projections (PRAG0320 warning) — converters cannot be translated to SQL.
// 1. Implement IValueConverterpublic sealed class MoneyToStringConverter : IValueConverter<decimal, string>{ public string Convert(decimal source) => source.ToString("N2"); public decimal ConvertBack(string target) => decimal.TryParse(target, out var result) ? result : 0m;}
// 2. Use on a property (Showcase: InvoiceSummaryDto)[MapFrom<Invoice>]public partial class InvoiceSummaryDto{ [MapProperty(nameof(Invoice.TotalAmount))] [MapConverter<MoneyToStringConverter>] public string TotalFormatted { get; init; } = "";}[MapConstructor]
Section titled “[MapConstructor]”Forces the generator to use a specific constructor when creating entity instances in [MapTo] scenarios. When omitted, the generator uses a best-match algorithm.
public class User{ public User() { }
[MapConstructor] public User(int id, string email) { Id = id; Email = email; }}Property Matching Rules
Section titled “Property Matching Rules”The generator resolves DTO properties to source properties in this order:
| Priority | Strategy | Example |
|---|---|---|
| 1 | [MapProperty] explicit path | [MapProperty("Address.City")] on City |
| 2 | [MapConverter<T>] | Custom type conversion |
| 3 | Direct name match (case-insensitive) | Name maps to Name |
| 4 | Flattening convention | AddressCity maps to Address.City |
| 5 | Concatenation convention | FullName maps to FirstName + " " + LastName |
Unmatched properties generate a PRAG0303 warning and receive default.
Auto-Default for Nullable to Non-Nullable
Section titled “Auto-Default for Nullable to Non-Nullable”When the source is nullable and the target is not, the generator applies sensible defaults: string? to string uses ?? "", numeric/bool/date/Guid types use .GetValueOrDefault(), enums use .GetValueOrDefault(). For explicit control, use [MapProperty(Default = "value")].
Type Conversions
Section titled “Type Conversions”The generator detects type mismatches and applies conversions automatically. Supported conversions include: numeric/enum/Guid/bool/DateTime to/from string (via .ToString() / .Parse()), DateTime to/from DateOnly and TimeOnly, and implicit widening (int to long). See Feature Matrix for the full table.
Collections and Nested DTOs
Section titled “Collections and Nested DTOs”Collections (List<T>, T[], IEnumerable<T>, HashSet<T>, Dictionary<K,V>) are mapped element-by-element. Cross-collection type conversions (List<T> to T[] and vice versa) are supported.
When a DTO property is itself a DTO with [MapFrom<T>], the generator calls FromEntity() recursively. Circular references are detected at compile time (PRAG0313 info) and handled with instance tracking.
Customization Hooks
Section titled “Customization Hooks”The generator emits two partial methods that you can implement:
[MapFrom<User>]public partial class UserDto{ public Guid Id { get; init; } public string Name { get; init; } = "";
// Called BEFORE mapping. Return a result to short-circuit. static partial void BeforeMapping(User source, ref UserDto? result) { if (source.IsDeleted) { result = new UserDto { Name = "[Deleted]" }; // Mapping stops here } }
// Called AFTER mapping. Modify the context to adjust values. static partial void CustomizeMapping(User source, ref UserDtoMappingContext ctx) { ctx.Name = ctx.Name.ToUpperInvariant(); }}The generator creates a {TypeName}MappingContext struct with mutable fields for each mapped property. CustomizeMapping receives this struct by ref, allowing you to modify any mapped value before the final DTO is constructed.
Note: CustomizeMapping is ignored in [GenerateProjection] (PRAG0319 warning) because Expression Trees cannot contain arbitrary C# logic.
EF Core Integration
Section titled “EF Core Integration”Pragmatic.Mapping.EFCore Package
Section titled “Pragmatic.Mapping.EFCore Package”The Pragmatic.Mapping.EFCore package provides extension methods for IQueryable<T> that work with generated projections:
| Method | Description |
|---|---|
SelectDto(projection) | Projects query using expression |
ToListDtoAsync(projection) | Async projection to list |
FirstOrDefaultDtoAsync(projection) | Async single item projection |
SingleOrDefaultDtoAsync(projection) | Async single-or-default projection |
ToArrayDtoAsync(projection) | Async projection to array |
ToPagedDtoAsync(projection, page, size) | Paginated projection with metadata |
ToSliceDtoAsync(projection, offset, limit) | Offset/limit projection |
All methods include OpenTelemetry ActivitySource tracing with Pragmatic.Mapping activity names.
// Paginated resultsvar page = await db.Properties .Where(p => p.IsActive) .OrderBy(p => p.Name) .ToPagedDtoAsync(PropertySummaryDto.Projection, pageNumber: 1, pageSize: 20);
page.Items // IReadOnlyList<PropertySummaryDto>page.TotalCount // Total matching itemspage.TotalPages // Computed page countpage.HasNextPage // Pagination flagThe Include Problem
Section titled “The Include Problem”FromEntity() operates on in-memory objects. Navigation properties not loaded will be null. Two solutions:
// Option 1: Explicit Include (from Showcase: GetReservationEndpoint)var reservation = await db.Reservations .Include(r => r.Guest) .Include(r => r.Property) .FirstOrDefaultAsync(r => r.PersistenceId == id);var dto = ReservationSummaryDto.FromEntity(reservation);
// Option 2: Use Projection (BEST - translates to SQL JOIN)var dto = await db.Reservations .Where(r => r.PersistenceId == id) .Select(ReservationSummaryDto.Projection) .FirstOrDefaultAsync();The generator tracks required navigations and exposes them as RequiredNavigations:
ReservationSummaryDto.RequiredNavigations // ["Guest", "Property"]Mutation Helpers
Section titled “Mutation Helpers”MutationHelpers provides composable methods for applying DTO changes to entities:
MapOneToOne— Maps a 1:1 reference navigation. If the DTO value is null, the navigation is set to null. Otherwise updates in-place or creates new.MapOneToMany— Synchronizes a collection navigation with three strategies:CollectionStrategy.Sync— Add new, update existing, remove missing (default)CollectionStrategy.AddOnly— Add new, update existing, never removeCollectionStrategy.Replace— Clear all and recreate from DTO
MutationHelpers.MapOneToOne( dto.ShippingAddress, () => order.ShippingAddress, addr => order.ShippingAddress = addr, addrDto => addrDto.ToEntity(), (addrDto, addr) => addrDto.ApplyTo(addr));
MutationHelpers.MapOneToMany( dto.Lines, order.Lines, d => d.Id, e => e.Id, lineDto => lineDto.ToEntity(), (lineDto, line) => lineDto.ApplyTo(line), CollectionStrategy.Sync);Diagnostics
Section titled “Diagnostics”| ID | Severity | Description |
|---|---|---|
| PRAG0300 | Error | Type must be declared as partial |
| PRAG0301 | Error | Source type not found |
| PRAG0302 | Error | Property not found on source type |
| PRAG0303 | Warning | No matching source property (will use default) |
| PRAG0304 | Error | Incompatible types |
| PRAG0305 | Error | Converter must implement IValueConverter<TSource, TTarget> |
| PRAG0306 | Error | Converter must have parameterless constructor |
| PRAG0307 | Warning | Required property not mapped in [MapTo] |
| PRAG0309 | Error | Nested type missing [MapFrom] |
| PRAG0310 | Error | [GenerateProjection] requires [MapFrom<T>] |
| PRAG0311 | Warning | Projection contains untranslatable method |
| PRAG0313 | Info | Circular reference detected, using instance tracking |
| PRAG0314 | Error | Conflicting [MapIgnore] and [MapProperty] |
| PRAG0317 | Error | Nullable to non-nullable without Default |
| PRAG0319 | Warning | CustomizeMapping ignored in Projection |
| PRAG0320 | Warning | [MapConverter] not supported in Projection |
| PRAG0321 | Warning | Format string not translatable to SQL |
| PRAG0323 | Warning | Ambiguous mapping (direct + convention match) |
| PRAG0324 | Info | ID property excluded from ToEntity() |
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Persistence | Mutations use [MapFrom<T>] DTOs as input; FromEntity() maps query results |
| Pragmatic.Endpoints | Endpoint response DTOs use [MapFrom<T>] for entity-to-response mapping |
| Pragmatic.Actions | Action input/output DTOs with generated mapping |
| Pragmatic.Caching | Cached queries project with [GenerateProjection] before caching |
Samples
Section titled “Samples”See samples/Pragmatic.Mapping.Samples/ for 17 runnable scenarios covering all 8 attributes, 4-level nesting, nullable intermediates, self-referencing round-trip, converter combinations, property name mismatches, multi-level flattening, ApplyTo, BodyOnly variant, MapConstructor, and bidirectional with TargetPath.
| Concepts | Architecture, core concepts, and decision guide |
| Getting Started | Your first mapping from entity to DTO |
| Projections | SQL-translatable Expression mappings and Include detection |
| Custom Converters | IValueConverter<TSource, TTarget> and manual property mapping |
| Feature Matrix | Complete feature comparison: FromEntity vs Selector vs Projection |
| Common Mistakes | Avoid the most frequent mapping pitfalls |
| Troubleshooting | Problem/solution guide with diagnostics reference |
Decision Guide
Section titled “Decision Guide”| Scenario | Use |
|---|---|
| EF Core query to API response | [GenerateProjection] + Projection (SQL-level, best performance) |
| In-memory collection transform | .Select(Dto.Selector) (compiled delegate) |
| Single entity with hooks/custom logic | FromEntity() + CustomizeMapping() |
| Write path (DTO to entity) | [MapTo<T>] + ToEntity() |
| Update existing entity | [MapTo<T>] + ApplyTo() |
| Hot path, value types | record struct DTO |
| Scalar-only mapping for mutations | [GenerateBodyOnlyVariant] + FromEntityBodyOnly() |
Requirements
Section titled “Requirements”- .NET 10.0+
Pragmatic.SourceGeneratoranalyzer
License
Section titled “License”Part of the Pragmatic.Design ecosystem.