Lightweight, high-performance object mapping library for .NET 10 with first-class support for parameterized EF Core projections.
Only external dependency: Microsoft.Extensions.DependencyInjection.Abstractions.
// 1. Register in DI (Program.cs)
builder.Services.AddMapping(typeof(Program).Assembly);
// 2. Inject IProjectionProvider wherever you query
public class ProductsController(AppDbContext db, IProjectionProvider projections)
{
public async Task<IActionResult> GetAll(string lang)
{
var products = await db.Products
.ProjectTo<Product, ProductViewModel>(projections, p => p.Set("lang", lang))
.ToListAsync();
var categories = await db.Categories
.ProjectTo<Category, CategoryDto>(projections)
.ToListAsync();
return Ok(new { products, categories });
}
}| Feature | Description |
|---|---|
| Expression projections | Generates Expression<Func<TSource, TDest>> for EF Core IQueryable — only mapped columns appear in SQL |
| Parameterized projections | ParameterSlot<T> uses a closure pattern that EF Core translates to native SQL parameters (@__param_0), preserving the query plan cache |
| Nested collection projection | IEnumerable<TSrc> → IEnumerable<TDest> projected automatically via .Select(new TDest {...}); self-referential hierarchies bounded by MaxDepth(n) |
| In-memory mapping | Compiled Func<TSource, TDest> delegates for fast object-to-object mapping |
| Conventions | Auto-maps by matching property names + flattens nested objects (Address.City -> AddressCity) |
| Fluent API | CreateMap<S, D>().ForMember(...).Ignore(...).ConstructUsing(...).ReverseMap() |
| Eager validation | All mappings validated at startup; throws immediately with the full list of issues |
| DI integration | services.AddMapping() with assembly scanning and singleton registration |
- .NET 10 SDK (10.0.201+)
- C# 14 (
LangVersion preview)
public class ProductProfile : MappingProfile
{
public ProductProfile()
{
// Simple mapping with conventions
CreateMap<Product, ProductDto>();
// Custom member mapping
CreateMap<Product, ProductDetailDto>()
.ForMember(d => d.Name, o => o.MapFrom(s => s.Title))
.Ignore(d => d.InternalCode)
.ConstructUsing(src => new ProductDetailDto { Source = "db" })
.ReverseMap();
// Parameterized projection
var lang = DeclareParameter<string>("lang");
CreateMap<Product, ProductViewModel>()
.ForMember(d => d.LocalizedName, o => o.MapFrom(lang,
(src, l) => l == "ru" ? src.NameRu : src.NameUz));
}
}Inject IProjectionProvider via DI and pass it to every ProjectTo call:
// Recommended — IProjectionProvider injected via constructor DI
source.ProjectTo<Product, ProductDto>(projections);
source.ProjectTo<Product, ProductDto>(projections, p => p.Set("lang", "ru"));
// Single generic — TSource inferred from the IQueryable element type
source.ProjectTo<ProductDto>(projections);
source.ProjectTo<ProductDto>(projections, p => p.Set("lang", "ru"));The legacy overloads without
IProjectionProvider(source.ProjectTo<ProductDto>()etc.) are deprecated in 1.1.0 (diagnosticSMAM0002) and will be removed in 2.0. See Migrating from 1.0.x below.
var mapper = serviceProvider.GetRequiredService<IMapper>();
var dto = mapper.Map<Product, ProductDto>(product);When CreateMap<S, D>() is called without ForMember, the compiler automatically matches properties.
Same name (case-insensitive):
Source.Name → Dest.Name
Source.Price → Dest.Price
Nested object flattening:
Source.Address.City → Dest.AddressCity
Source.Address.ZipCode → Dest.AddressZipCode
Collections with the same name:
Source.Children (List<Category>) → Dest.Children (List<CategoryViewModel>)
Source.Products (List<Product>) → Dest.Products (List<ProductDto>)
If CreateMap<Category, CategoryViewModel>() exists, the compiler emits
src.Children.Select(c => new CategoryViewModel { ... }).ToList() recursively —
no extra ForMember needed.
Explicit ForMember always overrides conventions.
Collection properties with mapped element types are projected recursively into a
single EF Core query. No extra configuration, no manual .Select(...) in the
controller.
public class CategoryProfile : MappingProfile
{
public CategoryProfile()
{
// Self-referential: CategoryViewModel has List<CategoryViewModel> Children.
// MaxDepth(n) bounds the generated tree — otherwise recursion would be infinite.
CreateMap<Category, CategoryViewModel>().MaxDepth(5);
}
}
// Controller — one query, full tree (IProjectionProvider injected via DI):
var tree = _db.Categories
.Where(c => c.ParentId == null)
.ProjectTo<Category, CategoryViewModel>(_projections)
.ToList();The generated SQL traverses the hierarchy via LEFT JOIN LATERAL (or multiple
subqueries, depending on the EF Core provider) — there is no N+1.
Rules:
- Collection types supported:
IEnumerable<T>,ICollection<T>,IList<T>,IReadOnlyCollection<T>,IReadOnlyList<T>, arrays (T[]) and concreteList<T>. - Mapping between element types must be registered with
CreateMap<TSrc, TDest>(). - For self-references,
MaxDepth(n)is mandatory — without it configuration validation throws.nis the maximum number of times the same map may appear on a single recursive branch. - For non-recursive nested collections (e.g.
Category.Products),MaxDepthis optional.
// Auto-scan assembly — finds all MappingProfile subclasses
builder.Services.AddMapping(typeof(Program).Assembly);
// With manual configuration
builder.Services.AddMapping(
cfg => cfg.AddProfile<MyCustomProfile>(),
typeof(SomeProfile).Assembly);Registers as Singleton:
MapperConfiguration— frozen configurationIMapper— stateless mapperIProjectionProvider— projection provider + staticProjectionProviderAccessor
All mappings validated at registration. On errors: MappingValidationException with the full list of issues.
This is the library's key innovation. Common approaches (string interpolation, constant replacement) break EF Core's query plan cache. SmAutoMapper uses the closure pattern — the same one the C# compiler generates for closures.
1. User declares:
var lang = DeclareParameter<string>("lang");
CreateMap<Product, ProductViewModel>()
.ForMember(d => d.LocalizedName, o => o.MapFrom(lang,
(src, l) => l == "ru" ? src.NameRu : src.NameUz))
2. Library generates a dynamic closure holder:
class ClosureHolder_1 { public string lang { get; set; } }
3. Compiler builds an expression with closure pattern:
src => holderInstance.lang == "ru" ? src.NameRu : src.NameUz
1. User calls:
_db.Products.ProjectTo<Product, ProductViewModel>(projections, p => p.Set("lang", "ru"))
2. Library creates a new holder with lang="ru", swaps it in the expression tree.
Tree shape stays IDENTICAL — only the value changes.
3. EF Core translates:
SELECT CASE WHEN @__lang_0 = 'ru' THEN "p"."NameRu" ELSE "p"."NameUz" END
FROM "Products" AS "p"
-- @__lang_0 is a SQL parameter, not an inlined constant!
4. Next call with lang="uz" → EF Core REUSES the query plan.
Only the parameter value changes: @__lang_0 = 'uz'
The project samples/SmAutoMapper.WebApiSample is a working ASP.NET Core Web API with EF Core SQLite and parameterized localization.
public class ProductMappingProfile : MappingProfile
{
public ProductMappingProfile()
{
var lang = DeclareParameter<string>("lang");
CreateMap<Product, ProductViewModel>()
.ForMember(d => d.LocalizedName, o => o.MapFrom(lang,
(src, l) => l == "uz" ? src.NameUz
: l == "lt" ? src.NameLt
: src.NameRu))
.ForMember(d => d.LocalizedDescription, o => o.MapFrom(lang,
(src, l) => l == "uz" ? src.DescriptionUz
: l == "lt" ? src.DescriptionLt
: src.DescriptionRu));
}
}[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] string lang = "ru")
{
var products = await _db.Products
.ProjectTo<Product, ProductViewModel>(_projections, p => p.Set("lang", lang))
.ToListAsync();
return Ok(products);
}GET /api/products?lang=ru:
[
{"id": 1, "localizedName": "iPhone 16 Pro", "localizedDescription": "Newest Apple smartphone", "price": 12990000.0},
{"id": 2, "localizedName": "Samsung Galaxy S25", "localizedDescription": "Flagship Samsung smartphone", "price": 10490000.0}
]SQL generated by EF Core:
SELECT CASE WHEN @__lang_0 = 'ru' THEN "p"."NameRu" ELSE "p"."NameUz" END AS "LocalizedName",
"p"."Id", "p"."Price"
FROM "Products" AS "p"public class CategoryViewModelProfile : MappingProfile
{
public CategoryViewModelProfile()
{
var lang = DeclareParameter<string>("lang");
CreateMap<Category, CategoryViewModel>()
.MaxDepth(5)
.ForMember(d => d.LocalizedName, o => o.MapFrom(lang,
(src, l) => l == "uz" ? src.NameUz
: l == "lt" ? src.NameLt
: src.NameRu));
// Children is projected automatically via convention (same name as Category.Children).
}
}
// Controller (IProjectionProvider injected via DI):
var tree = _db.Categories
.Where(c => c.ParentId == null)
.ProjectTo<Category, CategoryViewModel>(_projections, p => p.Set("lang", lang))
.ToList();GET /api/categories/tree?lang=ru returns the full hierarchy up to depth 5 as
a single SQL query.
Note: parameter holders are not shared across recursive levels yet — the
langvalue currently applies to the root level only; nested children fall back toNameRu. Tracked as a TODO inProjectionCompiler.
cd samples/SmAutoMapper.WebApiSample
dotnet run
# Swagger UI: http://localhost:5000/swagger| Endpoint | Description |
|---|---|
GET /api/products?lang=ru |
All products with localization |
GET /api/products/{id}?lang=ru |
Single product by Id |
GET /api/products/by-category/{id}?lang=uz |
Products by category |
GET /api/categories/tree?lang=ru |
Category tree with hierarchy |
GET /api/categories/flat?lang=lt |
Flat category list |
# Unit tests
dotnet test tests/SmAutoMapper.UnitTests
# Integration tests (EF Core SQLite)
dotnet test tests/SmAutoMapper.IntegrationTests
# All tests
dotnet testCompares SmAutoMapper vs AutoMapper (16.1.1) vs Mapster (10.0.3) vs manual mapping.
cd tests/SmAutoMapper.Benchmarks
# All benchmarks (5-15 min)
dotnet run -c Release -- --filter *
# Specific benchmark
dotnet run -c Release -- --filter *SimpleMappingBenchmark*
# Quick run (less accurate, 1-3 min)
dotnet run -c Release -- --filter * --job short
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26200.8037)
Unknown processor
.NET SDK 10.0.202
[Host] : .NET 10.0.6 (10.0.626.17701), X64 RyuJIT AVX2
DefaultJob : .NET 10.0.6 (10.0.626.17701), X64 RyuJIT AVX2
Categories=Simple
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------- |----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Manual | 3.467 ns | 0.0171 ns | 0.0152 ns | 1.00 | 0.01 | 0.0031 | 48 B | 1.00 |
| Mapster | 7.860 ns | 0.0481 ns | 0.0450 ns | 2.27 | 0.02 | 0.0031 | 48 B | 1.00 |
| SmAutoMapper | 13.049 ns | 0.0344 ns | 0.0287 ns | 3.76 | 0.02 | 0.0030 | 48 B | 1.00 |
| AutoMapper | 24.757 ns | 0.0577 ns | 0.0539 ns | 7.14 | 0.03 | 0.0030 | 48 B | 1.00 |
Important: always run with
-c Release. Debug builds produce inaccurate results.
# Build
dotnet build
# Run tests
dotnet test
# Run Web API example
cd samples/SmAutoMapper.WebApiSample
dotnet run
# Pack for NuGet
dotnet pack -c Release
dotnet pack src/SmAutoMapper/SmAutoMapper.csproj -c Release -o ./nupkgSmAutoMapper is not compatible with Native AOT or aggressive trimming.
The library generates closure-holder types at runtime via Reflection.Emit
(AssemblyBuilder.DefineDynamicAssembly) and compiles projections with
Expression.Lambda(...).Compile(). Both require the JIT and dynamic code
generation, which Native AOT removes by design.
The package ships with:
IsAotCompatible=falseandIsTrimmable=false— sets the correct expectation for consumer projects.EnableAotAnalyzer=trueandEnableTrimAnalyzer=true— surfacesIL3050,IL2026, and related warnings inside the library at build time.[RequiresDynamicCode]and[RequiresUnreferencedCode]on every public entry point that reaches reflection or dynamic compilation —AddMapping, everyProjectTooverload,MapperConfiguration.CreateMapper,MapperConfiguration.CreateProjectionProvider,MappingProfile.CreateMap,MappingConfigurationBuilder.AddProfile*/Build, andITypeMappingExpression.ReverseMap.
If your application enables PublishAot=true or EnableAotAnalyzer=true,
the compiler will propagate IL3050 / IL2026 warnings from the
SmAutoMapper API up to your call sites. Suppress them locally where you call
into the library:
#pragma warning disable IL3050, IL2026
builder.Services.AddMapping(typeof(Program).Assembly);
#pragma warning restore IL3050, IL2026#pragma warning disable IL3050, IL2026
var products = await _db.Products
.ProjectTo<Product, ProductViewModel>(_projections,
p => p.Set("lang", lang))
.ToListAsync();
#pragma warning restore IL3050, IL2026The Web API sample (samples/SmAutoMapper.WebApiSample) uses this exact
pattern at every SmAutoMapper call site as a reference.
If your deployment targets Native AOT, SmAutoMapper will fail at runtime — the reflection and emit code paths have no AOT fallback. Use a source-generator-based mapper for AOT scenarios.
Release 1.1.0 introduces two compile-time deprecation warnings for consumers still using the service-locator path:
- SMAM0001 —
ProjectionProviderAccessoris deprecated. InjectIProjectionProvidervia DI instead. - SMAM0002 — Single-generic
ProjectTo<TDest>(IQueryable)overloads are deprecated. Use the overloads that take an explicitIProjectionProvider.
services.AddMapping(cfg => cfg.AddProfile<UserProfile>());
// in a query:
var dtos = db.Users.ProjectTo<UserDto>().ToList();services.AddMapping(cfg => cfg.AddProfile<UserProfile>());
// in a query (inject IProjectionProvider via constructor):
public sealed class UserService(IProjectionProvider projectionProvider, AppDbContext db)
{
public List<UserDto> GetAll() =>
db.Users.ProjectTo<User, UserDto>(projectionProvider).ToList();
}Deprecated paths continue to work in 1.x but will be removed in 2.0. Both diagnostic IDs can be suppressed locally with #pragma warning disable SMAM0001, SMAM0002 during a staged migration.
MIT