Skip to content

Pragmatic.Mapping

High-performance, source-generated object-to-object mapping for .NET 10.

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.

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
[MapFrom<User>] Pragmatic.SourceGenerator
public 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()
  1. You decorate a partial class/record/struct with [MapFrom<T>] or [MapTo<T>].
  2. The Pragmatic source generator analyzes properties at compile time.
  3. It emits a partial class with strongly-typed static methods and a companion extensions class.
  4. No DI registration needed — everything is static.
Terminal window
dotnet add package Pragmatic.Mapping

Add 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):

Terminal window
dotnet add package Pragmatic.Mapping.EFCore

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 factory
var dto = GuestDto.FromEntity(guest);
// Extension methods
var 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.


Generates mapping from a source type (typically an entity) to the decorated DTO.

Generated members:

  • static TDto FromEntity(TSource entity) — static factory method
  • static 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.

Generates mapping from the DTO to a target type (typically an entity).

Generated members:

  • TTarget ToEntity() — creates a new entity instance
  • void 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 entity
User user = dto.ToEntity();
// Updates an existing entity
dto.ApplyTo(existingUser);

Note: ID properties (Id, {EntityName}Id) are excluded from ToEntity() by default. Use [MapProperty] on the ID property to force inclusion.

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 query
var dtos = await db.Properties
.Where(p => p.IsActive)
.Select(PropertySummaryDto.Projection)
.ToListAsync();

See Projections Guide for details.

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
}

Customizes how a single property is mapped.

Capabilities:

FeatureSyntaxExample
Explicit source path[MapProperty("Address.City")]Flatten navigation
Concatenation[MapProperty("FirstName", "LastName")]Multiple paths joined
Custom separatorSeparator = ", ""Doe, John"
Format stringFormat = "yyyy-MM-dd"Date formatting via .ToString(format)
Default valueDefault = "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; } = "";

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}";
}

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 IValueConverter
public 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; } = "";
}

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; }
}

The generator resolves DTO properties to source properties in this order:

PriorityStrategyExample
1[MapProperty] explicit path[MapProperty("Address.City")] on City
2[MapConverter<T>]Custom type conversion
3Direct name match (case-insensitive)Name maps to Name
4Flattening conventionAddressCity maps to Address.City
5Concatenation conventionFullName maps to FirstName + " " + LastName

Unmatched properties generate a PRAG0303 warning and receive default.

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")].

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 (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.


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.


The Pragmatic.Mapping.EFCore package provides extension methods for IQueryable<T> that work with generated projections:

MethodDescription
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 results
var 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 items
page.TotalPages // Computed page count
page.HasNextPage // Pagination flag

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"]

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 remove
    • CollectionStrategy.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);

IDSeverityDescription
PRAG0300ErrorType must be declared as partial
PRAG0301ErrorSource type not found
PRAG0302ErrorProperty not found on source type
PRAG0303WarningNo matching source property (will use default)
PRAG0304ErrorIncompatible types
PRAG0305ErrorConverter must implement IValueConverter<TSource, TTarget>
PRAG0306ErrorConverter must have parameterless constructor
PRAG0307WarningRequired property not mapped in [MapTo]
PRAG0309ErrorNested type missing [MapFrom]
PRAG0310Error[GenerateProjection] requires [MapFrom<T>]
PRAG0311WarningProjection contains untranslatable method
PRAG0313InfoCircular reference detected, using instance tracking
PRAG0314ErrorConflicting [MapIgnore] and [MapProperty]
PRAG0317ErrorNullable to non-nullable without Default
PRAG0319WarningCustomizeMapping ignored in Projection
PRAG0320Warning[MapConverter] not supported in Projection
PRAG0321WarningFormat string not translatable to SQL
PRAG0323WarningAmbiguous mapping (direct + convention match)
PRAG0324InfoID property excluded from ToEntity()

With ModuleIntegration
Pragmatic.PersistenceMutations use [MapFrom<T>] DTOs as input; FromEntity() maps query results
Pragmatic.EndpointsEndpoint response DTOs use [MapFrom<T>] for entity-to-response mapping
Pragmatic.ActionsAction input/output DTOs with generated mapping
Pragmatic.CachingCached queries project with [GenerateProjection] before caching

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 |

ScenarioUse
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 logicFromEntity() + CustomizeMapping()
Write path (DTO to entity)[MapTo<T>] + ToEntity()
Update existing entity[MapTo<T>] + ApplyTo()
Hot path, value typesrecord struct DTO
Scalar-only mapping for mutations[GenerateBodyOnlyVariant] + FromEntityBodyOnly()
  • .NET 10.0+
  • Pragmatic.SourceGenerator analyzer

Part of the Pragmatic.Design ecosystem.