Compile-time mapping · zero reflection · everyday .NET utilities

Compile-time mapping. No runtime mystery.

Transformations.Mapping is a Roslyn source generator for .NET. Describe the relationship once — get predictable, type-safe mapping methods emitted as plain C# at compile time. No reflection, no startup cost, no runtime mapper tax.

✓ Plain generated C# ✓ No reflection or profiles ✓ NativeAOT compatible
Transformations.Mapping.Generator compile-time
Describe the mapping once
[MapTo<UserDto>]
public partial class User {
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
Plain C# emitted at build time
  • user.ToUserDto()new instance
  • users.ToUserDtoList()optimized list path
  • users.ToUserDtoArray()array output
  • users.ToUserDtoEnumerable()lazy IEnumerable
  • users.ToUserDtoAsyncEnumerable()lazy IAsyncEnumerable
  • user.MapTo(existing)update existing target
  • query.ProjectToUserDto()IQueryable expression
  • dto.ToUser()reverse mapping
Mapper files 0 maintained
Runtime magic none
NativeAOT compatible
8.4 nsper object — generated mapping
8methods generated per mapping pair
6.7×faster than AutoMapper in this benchmark
0runtime mapper configuration
BenchmarkDotNet · .NET 10 · ShortRun

Fast because there is almost nothing left to do.

Measured with BenchmarkDotNet on an AMD Ryzen 7 5700G. The point is not benchmark theatre: generated mapping lands on the same allocation baseline as hand-written code, with no reflection, no profile discovery, and no runtime configuration step.

Single object mapping — lower is better
Library Time Config cost
fastestTransformations
8.4 ns compile-time only
referenceHand-written
9.5 ns manual upkeep
competitorMapster
18.8 ns runtime startup
competitorAutoMapper
56.2 ns runtime startup
Collection mapping — 1,000 objects, lower is better
Library Time Alloc vs manual
fastestTransformations
10.17 µs 1.00× baseline
competitorMapster
10.97 µs 1.00× baseline
referenceHand-written
12.14 µs 1.00× baseline
competitorAutoMapper
13.58 µs +10% allocations

Bars are proportional within each test group — shorter is faster. The collection path uses CollectionsMarshal.SetCount + dual-span indexed writes, avoiding per-element List.Add overhead. Generated methods carry [AggressiveInlining], so the JIT can see through the call boundary when the surrounding code allows it.

Compile-time generation

Four steps to make mapping disappear.

The Roslyn incremental generator runs inside your build. There is no container registration, profile scan, or runtime discovery step — the methods simply exist when your project compiles.

One attribute → eight methods

A consistent API for every mapping pair.

Every annotation produces the same predictable surface. No profile class, no startup call, no hidden registry — and no mapper file that quietly drifts when the model evolves.

Without Transformations — helper methods that become infrastructure
// One method covers one path. The upkeep starts with the next DTO.
public static UserDto ToUserDto(this User src)
if (src == null) return null!;
return new UserDto { Id = src.Id, Name = src.Name, ... };
}
// + ToUserDtoList() — SetCount & span loop
// + ToUserDtoArray()
// + ToUserDtoEnumerable()
// + ToUserDtoAsyncEnumerable()
// + MapTo(UserDto target) — overlay
// + ProjectToUserDto() — expression tree
// + dto.ToUser() — reverse
// A new DTO target means another set of helpers.
With Transformations.Mapping — describe the relationship once
[MapTo<UserDto>]
public partial class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
// ✓ user.ToUserDto()
// ✓ users.ToUserDtoList() — span fast path
// ✓ users.ToUserDtoArray()
// ✓ users.ToUserDtoEnumerable()
// ✓ users.ToUserDtoAsyncEnumerable()
// ✓ user.MapTo(existingDto)
// ✓ query.ProjectToUserDto()
// ✓ dto.ToUser() — reverse
// A new DTO target is another attribute, not another mapper file.
The Transformations family

Take only what you need.

Install the full package when you want the everyday toolkit, or reference individual slices to keep each project layer lean. Mapping is only one part of the family: the same philosophy applies to conversion, strings, dates, data access, web helpers, and resilience.

Transformations

All-in-one

The meta-package for application projects that want the full toolkit: Core, Data, Web, Dapper, and EntityFramework helpers in one install.

full toolkit
Transformations.Core

Foundation

Strings, collections, dates, holidays, type conversion, batching, resilience, and diagnostics. The package you reach for when you want small helpers without dragging in an application stack.

foundation layer
Transformations.Data

Data access

DataRow, DataReader, and DataTable helpers, SQL parameter builders, and CSV export. Boring data plumbing, made explicit around Microsoft.Data.SqlClient.

data plumbing
Transformations.Web

ASP.NET Core

HTTP context helpers, session and cookie utilities, query-string builders, and SelectList extensions for MVC views. Kept separate so Core stays usable outside ASP.NET.

web layer only
Transformations.Dapper

ORM integration

Resilient Dapper query wrappers with transient SQL-fault detection. A companion Transformations.EntityFramework package covers resilient SaveChanges and related EF Core data workflows.

Dapper · EF Core
Transformations.Text

Text extraction

Multi-format text extraction for RAG pipelines. PDF, DOCX, PPTX, EPUB, RTF, HTML, Markdown, ODT, ZIP and more — with chunking, TableMode, and per-chunk provenance (page number, heading path).

RAG · chunking · 18 formats
Transformations

Install once.
Stop repeating boilerplate.

NuGet — full package or mapper only
$ dotnet add package Transformations.Mapping
$ dotnet add package Transformations
View Transformations on NuGet.org Modern .NET 8+ · Mapping is NativeAOT compatible · MIT licensed
FAQ

Any Questions?

For you to decide whether this mapper belongs in production code, build pipelines, and NativeAOT workloads.

How can generated code benchmark ahead of hand-written helpers?

There is no magic: the generated methods are small, direct, and marked with [MethodImpl(AggressiveInlining)]. In the benchmarked shape, the JIT can see through the call boundary and optimize the mapping body aggressively. The collection path also uses CollectionsMarshal.SetCount combined with dual-span indexed writes, avoiding the per-element overhead of List.Add.

Does it support nested objects and custom converters?

Yes. Use [MapProperty("TargetProp", SourcePath = "Customer.Address.City")] for dotted-path flattening — the generator emits null-conditional expressions (source.Customer?.Address?.City) for nullable intermediates. Use Converter = nameof(MyStaticMethod) to run a static method as a per-property transform.

What happens when types don't match exactly?

The generator detects common convertible pairs and emits explicit conversion code such as .ToString(), ConvertTo<T>, or int.Parse where appropriate. Incompatible pairs are left unmapped and surfaced as TXMAP001 diagnostics in the IDE, so mismatches do not silently become production behaviour.

Is it NativeAOT and trimming safe?

Yes — the mapping output does not depend on reflection. The generator targets netstandard2.0, emits plain C# at build time, and keeps the output straightforward when projects enable IsAotCompatible.

How does ProjectToUserDto() compare to AutoMapper's ProjectTo?

Transformations generates a static Expression<Func<User, UserDto>> at build time. query.ProjectToUserDto() calls Queryable.Select with that expression, giving EF Core a normal projection without building ProjectTo configuration at query time.

Can I annotate the DTO instead of the domain class?

Yes — [MapFrom<User>] on the DTO side generates the same mapping surface as [MapTo<UserDto>] on the source side, from the other direction. Choose whichever keeps the annotation closest to the code you own.