Mappify.NET
1.0.2
dotnet add package Mappify.NET --version 1.0.2
NuGet\Install-Package Mappify.NET -Version 1.0.2
<PackageReference Include="Mappify.NET" Version="1.0.2" />
<PackageVersion Include="Mappify.NET" Version="1.0.2" />
<PackageReference Include="Mappify.NET" />
paket add Mappify.NET --version 1.0.2
#r "nuget: Mappify.NET, 1.0.2"
#:package Mappify.NET@1.0.2
#addin nuget:?package=Mappify.NET&version=1.0.2
#tool nuget:?package=Mappify.NET&version=1.0.2
Mappify.NET
A zero-dependency, production-ready object-mapping library for .NET 10 written in C# 14.
It replicates the core behaviour of AutoMapper — automatic property mapping, nested-object
recursion, collection projection, and a fluent ForMember configuration API — entirely through
compiled System.Linq.Expressions delegates that are cached per type-pair for near-zero per-call
overhead.
Installation
dotnet add package Mappify.NET
Table of Contents
- Features
- Project Structure
- Architecture & How It Works
- Getting Started
- Defining Types
- Creating a Mapping Profile
- ForMember – All Four Options
- Registering Profiles & Building the Mapper
- Mapping Methods on IMapper
- Nested Object Mapping
- Collection Mapping
- Type Conversion Rules
- Null Safety
- Error Handling
- Performance Notes
- Complete End-to-End Example
- API Reference
- Limitations
Features
| Capability | Detail |
|---|---|
| Automatic property mapping | All public readable source properties are mapped to writable destination properties with the same name (case-insensitive). |
| Nested object recursion | Complex property types are recursively mapped, provided a mapping is registered for the nested type-pair. |
| Collection projection | T[], List<T>, IList<T>, ICollection<T>, IEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T>, HashSet<T>, ISet<T>. |
Fluent ForMember API |
Per-property overrides: custom resolver, source-expression, ignored member, constant value. |
| Compiled expression trees | Getter and setter delegates are compiled once per PropertyInfo and stored in a ConcurrentDictionary. Subsequent calls pay zero reflection cost. |
| Circular-reference guard | Configurable maximum depth (default 32). Exceeding it throws a descriptive MappingException. |
| Null safety | Null sources return default(TDestination). Null property values are propagated only to properties that accept null. |
| Thread-safe | MapperConfiguration and Mapper are safe to share across threads and should be treated as singletons. |
| No third-party dependencies | Pure .NET 10 BCL only. |
Project Structure
AkzoAutoMapper/
│
├── MappingException.cs # Typed exception with SourceType / DestinationType
├── TypePair.cs # Internal readonly struct — cache dictionary key
│
├── IMappingExpression.cs # Public fluent-API interfaces
├── MemberConfig.cs # Internal resolver storage (abstract base + generic impl)
├── MappingExpression.cs # Internal fluent-builder; implements IHasMemberConfigs
│
├── MappingProfile.cs # Abstract base class — users derive from this
├── MapperConfiguration.cs # MappingRegistry + MapperConfiguration + IMapperConfigurationExpression
├── IMapper.cs # Public IMapper contract
├── Mapper.cs # Engine: plan builder, collection mapper, compiled accessors
│
└── Examples/
└── MappingExamples.cs # Runnable demonstrations (7 scenarios)
Architecture & How It Works
MapperConfiguration.Create(cfg => cfg.AddProfile<MyProfile>())
│
▼
MappingRegistry ← Dictionary<TypePair, MappingExpression<,>>
│
▼
MapperConfiguration.CreateMapper()
│
▼
Mapper ──── _planCache ─────────── ConcurrentDictionary<TypePair, Func<obj,obj,Mapper,int,obj>>
│
First call for a type-pair
│
BuildPlan(srcType, dstType)
│
┌───────────────────────────┤
│ │
ForMember configs Auto-map by name
(custom resolvers) (case-insensitive)
│ │
└──── compiled Action<obj,obj,Mapper,int>[] ──── frozen array
│
Subsequent calls execute the frozen array directly
Plan execution routing (per property, per call)
value is null?
├─ dest type is reference / Nullable<T> → setter(null)
└─ dest type is non-nullable value type → skip (leave default)
dest.IsAssignableFrom(src) → direct setter [fastest path]
both are collections (non-string IEnumerable)
→ MapCollection(...)
both are primitive/scalar
(bool,byte,char,int,long,float,double,decimal,
string,DateTime,DateTimeOffset,DateOnly,
TimeOnly,TimeSpan,Guid, + Nullable<T> of each)
→ Convert.ChangeType(value, underlyingType)
both are complex objects (neither is primitive)
→ MapCore(value, depth + 1) [recursive]
otherwise → throw MappingException
Compiled getter / setter (built once per PropertyInfo)
// Getter
(object owner) => (object)((OwnerType)owner).Property
// Setter — reference type or non-nullable value type
(object owner, object? value) => ((OwnerType)owner).Property = (PropType)value
// Setter — Nullable<T>
(object owner, object? value) =>
((OwnerType)owner).Property = (value == null ? default(T?) : (T?)(T)value)
Getting Started
1 — Reference the project
Add a project reference from your application to AkzoAutoMapper.csproj, or package it as a
NuGet library and install the package.
2 — Create a singleton mapper
using AkzoAutoMapper;
// Build configuration once at application start-up.
var config = MapperConfiguration.Create(cfg =>
{
cfg.AddProfile<UserProfile>();
cfg.AddProfile<OrderProfile>();
});
// IMapper is thread-safe — share it for the application lifetime.
IMapper mapper = config.CreateMapper();
With ASP.NET Core dependency injection:
// Program.cs
builder.Services.AddSingleton(_ =>
{
var config = MapperConfiguration.Create(cfg =>
{
cfg.AddProfile<UserProfile>();
cfg.AddProfile<OrderProfile>();
});
return config.CreateMapper();
});
Defining Types
Destination types must expose a public parameterless constructor.
Properties must be public and either readable (source) or writable (destination).
// Source (domain model)
public class User
{
public int Id { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Email { get; set; } = "";
public DateTime DateOfBirth { get; set; }
}
// Destination (DTO)
public class UserDto
{
public int Id { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Email { get; set; } = "";
public DateTime DateOfBirth { get; set; }
}
Creating a Mapping Profile
Derive from MappingProfile and call CreateMap<TSource, TDestination>() inside the constructor.
Every call returns IMappingExpression<TSource, TDestination> for optional ForMember chaining.
public class UserProfile : MappingProfile
{
public UserProfile()
{
// Pure auto-mapping — all property names and types match exactly.
CreateMap<User, UserDto>();
}
}
ForMember – All Four Options
ForMember selects a destination property and then routes to one of four behaviours:
Option 1 — MapFrom with a source expression
Best for simple, direct property access. The lambda is compiled once; type conversion between compatible primitives is automatic.
CreateMap<Order, OrderDto>()
.ForMember(
d => d.CustomerName,
o => o.MapFrom(src => src.Customer.FullName)); // expression tree, compiled once
Option 2 — MapFrom with a two-argument resolver (src, dst) => ...
Use for transformations, calculations, string formatting, or when the result depends on the partially-mapped destination object.
CreateMap<Employee, EmployeeDto>()
// Combine two source fields
.ForMember(
d => d.FullName,
o => o.MapFrom((src, _) => $"{src.FirstName} {src.LastName}".Trim()))
// Calculation
.ForMember(
d => d.MonthlySalary,
o => o.MapFrom((src, _) => Math.Round(src.AnnualSalary / 12m, 2)))
// Derived / computed value
.ForMember(
d => d.YearsEmployed,
o => o.MapFrom((src, _) => (int)((DateTime.Today - src.HireDate).TotalDays / 365.25)))
// Null-coalescing
.ForMember(
d => d.Department,
o => o.MapFrom((src, _) => src.Department ?? "Unassigned"))
// Using a partially-mapped destination value
.ForMember(
d => d.Label,
o => o.MapFrom((src, dst) => $"{dst.FullName} — {src.Department}"));
Option 3 — Ignore
The destination property is skipped entirely and retains its default value.
CreateMap<User, UserDto>()
.ForMember(d => d.PasswordHash, o => o.Ignore())
.ForMember(d => d.InternalNotes, o => o.Ignore());
Option 4 — UseValue
Always assigns a compile-time constant to the destination property.
CreateMap<CsvRow, ProductDto>()
.ForMember(d => d.Source, o => o.UseValue("CSV-Import"))
.ForMember(d => d.IsDeleted, o => o.UseValue(false));
Registering Profiles & Building the Mapper
AddProfile<TProfile>()
Instantiates the profile internally — the simplest option.
var config = MapperConfiguration.Create(cfg =>
{
cfg.AddProfile<UserProfile>();
cfg.AddProfile<OrderProfile>();
cfg.AddProfile<ProductProfile>();
cfg.AddProfile<EmployeeProfile>();
});
AddProfile(instance)
Use when your profile needs constructor arguments (e.g., injected services).
var config = MapperConfiguration.Create(cfg =>
{
cfg.AddProfile(new OrderProfile(currencyService));
});
Mapping Methods on IMapper
| Method | Description |
|---|---|
Map<TDest>(object source) |
Maps using the runtime type of source as the source key. Returns default(TDest) when source is null. |
Map<TSrc, TDest>(TSrc source) |
Maps using TSrc as the compile-time source key. Returns default(TDest) when source is null. |
Map<TSrc, TDest>(TSrc source, TDest destination) |
Populates an existing destination instance. Useful when the destination was created outside the mapper. Returns destination. |
MapList<TSrc, TDest>(IEnumerable<TSrc> source) |
Maps every element and returns a List<TDest>. Returns an empty list when source is null. |
// New destination instance (compile-time types)
UserDto dto = mapper.Map<User, UserDto>(user);
// New destination instance (runtime source type)
UserDto dto = mapper.Map<UserDto>(user);
// Populate existing instance
mapper.Map<User, UserDto>(updatedUser, existingDto);
// Collection
List<UserDto> dtos = mapper.MapList<User, UserDto>(users);
Nested Object Mapping
Register a CreateMap for each type-pair that appears anywhere in the object graph.
The mapper resolves nested properties recursively with an incremented depth counter.
public class OrderProfile : MappingProfile
{
public OrderProfile()
{
// Must register the nested type-pair separately.
CreateMap<Address, AddressDto>();
// Order.ShippingAddress (Address) is mapped to OrderDto.ShippingAddress (AddressDto)
// automatically, because the Address→AddressDto mapping is registered above.
CreateMap<Order, OrderDto>()
.ForMember(
d => d.PlacedAt,
o => o.MapFrom((s, _) => s.PlacedAt.ToString("yyyy-MM-dd HH:mm")));
}
}
Depth limit — the mapper allows up to 32 levels of nesting before throwing
MappingException. This prevents runaway recursion on circular object graphs.
Collection Mapping
No extra configuration is required. Property-level collection mapping is automatic when the element types have a compatible mapping registered.
Supported destination collection shapes
| Destination type | Backing implementation returned |
|---|---|
T[] |
Array.CreateInstance(T, count) |
List<T> |
List<T> |
IList<T> |
List<T> |
ICollection<T> |
List<T> |
IEnumerable<T> |
List<T> |
IReadOnlyList<T> |
List<T> |
IReadOnlyCollection<T> |
List<T> |
HashSet<T> |
HashSet<T> |
ISet<T> |
HashSet<T> |
// Using MapList (explicit)
List<ProductDto> dtos = mapper.MapList<Product, ProductDto>(products);
// Via auto-mapped property
public class Catalogue { public List<Product> Items { get; set; } = []; }
public class CatalogueDto { public List<ProductDto> Items { get; set; } = []; }
// No ForMember needed — mapper recurses into Items automatically.
CreateMap<Catalogue, CatalogueDto>();
CreateMap<Product, ProductDto>();
Type Conversion Rules
When source and destination property types do not match, the mapper applies the following rules in priority order:
| Priority | Condition | Action |
|---|---|---|
| 1 | destType.IsAssignableFrom(srcType) |
Direct assignment — fastest path |
| 2 | Both types are collections | MapCollection |
| 3 | Both types are primitives / scalars (see table below) | Convert.ChangeType |
| 4 | Both types are complex objects | Recursive MapCore |
| — | None of the above | MappingException |
Primitive / scalar types recognised:
bool, byte, sbyte, char, short, ushort, int, uint, long, ulong,
float, double, decimal, string, DateTime, DateTimeOffset, DateOnly, TimeOnly,
TimeSpan, Guid, and Nullable<T> wrappers of any of the above.
// int → long works automatically (Convert.ChangeType)
// int → int? works automatically (Nullable<T> setter unwraps the type)
// string → Guid works if the string is a valid GUID
Null Safety
| Scenario | Behaviour |
|---|---|
source argument is null |
Returns default(TDestination) — never throws |
Source property value is null and destination property is a reference type |
Destination property is set to null |
Source property value is null and destination property is Nullable<T> |
Destination property is set to null (i.e. no value) |
Source property value is null and destination property is a non-nullable value type |
Destination property is left at its default value — no exception |
Entire nested object property is null |
Destination property is set to null (not a new empty instance) |
// Safe — returns null, not an exception
UserDto? dto = mapper.Map<User, UserDto>(null!); // dto == null
// Null nested object
var order = new Order { ShippingAddress = null! };
OrderDto orderDto = mapper.Map<Order, OrderDto>(order);
// orderDto.ShippingAddress == null
Error Handling
All mapping failures throw MappingException — never a raw InvalidCastException,
NullReferenceException, or TargetInvocationException.
public sealed class MappingException : Exception
{
public Type SourceType { get; } // the source type at the point of failure
public Type DestinationType { get; } // the destination type at the point of failure
// Message: "Mapping 'FullTypeName' → 'FullTypeName': <reason>"
}
Common causes and fixes
| Exception message contains | Likely cause | Fix |
|---|---|---|
does not expose a public parameterless constructor |
Destination type has no public T() |
Add public MyDto() {} or use Map(src, existingDest) |
No compatible mapping found for property |
Incompatible non-primitive property types | Add ForMember with a custom resolver |
Maximum mapping depth (32) exceeded |
Circular reference in the object graph | Add .ForMember(d => d.BackRef, o => o.Ignore()) on the back-reference |
Unsupported destination collection type |
Collection type not in the supported list | Use one of the supported shapes or map manually via ForMember |
Cannot convert property |
Convert.ChangeType failed (e.g. invalid GUID string) |
Add ForMember with an explicit conversion |
try
{
OrderDto dto = mapper.Map<Order, OrderDto>(order);
}
catch (MappingException ex)
{
Console.WriteLine(ex.SourceType.Name); // e.g. "Order"
Console.WriteLine(ex.DestinationType.Name); // e.g. "OrderDto"
Console.WriteLine(ex.Message); // full explanation
Console.WriteLine(ex.InnerException); // original BCL exception, if any
}
Performance Notes
| Concern | How it is addressed |
|---|---|
| Reflection per call | Zero. Getter and setter delegates are compiled via Expression.Lambda(...).Compile() once per PropertyInfo and stored in static ConcurrentDictionary<PropertyInfo, Func/Action>. |
| Plan construction per call | Zero after first use. The full mapping plan (frozen Action[]) is stored in ConcurrentDictionary<TypePair, Func<...>> keyed on (SourceType, DestinationType). |
| Memory | Plans are never evicted. Memory grows linearly with the number of distinct type-pairs encountered at runtime — typically a few dozen in a real application. |
| Thread safety | ConcurrentDictionary.GetOrAdd ensures at most one plan is compiled per type-pair even under contention. |
| Nullable setter overhead | For Nullable<T> destination properties a one-time conditional expression (value == null ? default(T?) : (T?)(T)value) is compiled into the setter delegate — no branching at the call site. |
Complete End-to-End Example
using AkzoAutoMapper;
// ── Models ────────────────────────────────────────────────────────────────────
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int StockCount { get; set; }
}
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string FormattedPrice { get; set; } = "";
public bool InStock { get; set; }
}
// ── Profile ───────────────────────────────────────────────────────────────────
public class ProductProfile : MappingProfile
{
public ProductProfile()
{
CreateMap<Product, ProductDto>()
.ForMember(d => d.FormattedPrice,
o => o.MapFrom((s, _) => $"$ {s.Price:F2}"))
.ForMember(d => d.InStock,
o => o.MapFrom((s, _) => s.StockCount > 0));
}
}
// ── Application entry point ───────────────────────────────────────────────────
var config = MapperConfiguration.Create(cfg => cfg.AddProfile<ProductProfile>());
IMapper mapper = config.CreateMapper();
// Single object
var product = new Product { Id = 1, Name = "Primer", Price = 24.99m, StockCount = 50 };
ProductDto dto = mapper.Map<Product, ProductDto>(product);
Console.WriteLine($"{dto.Name} {dto.FormattedPrice} InStock:{dto.InStock}");
// Primer $ 24.99 InStock:True
// Collection
var products = new List<Product>
{
new() { Id = 2, Name = "Topcoat", Price = 89.50m, StockCount = 0 },
new() { Id = 3, Name = "Varnish", Price = 49.00m, StockCount = 12 }
};
List<ProductDto> dtos = mapper.MapList<Product, ProductDto>(products);
dtos.ForEach(d => Console.WriteLine($"{d.Name} {d.FormattedPrice} InStock:{d.InStock}"));
// Topcoat $ 89.50 InStock:False
// Varnish $ 49.00 InStock:True
For seven fully worked examples (simple, nested, collection, custom, existing-instance, null-safety, error-handling), run:
AkzoAutoMapper.Examples.MappingExamples.Run();
API Reference
MapperConfiguration
| Member | Description |
|---|---|
static Create(Action<IMapperConfigurationExpression>) |
Builds the configuration. Call once at startup. |
CreateMapper() |
Returns a thread-safe IMapper backed by this configuration. |
IMapperConfigurationExpression
| Member | Description |
|---|---|
AddProfile<TProfile>() |
Instantiates TProfile (must have a public parameterless constructor) and merges its mappings. |
AddProfile(MappingProfile profile) |
Merges an already-instantiated profile. |
MappingProfile
| Member | Description |
|---|---|
CreateMap<TSource, TDestination>() |
Registers the type-pair and returns IMappingExpression<TSource, TDestination> for fluent chaining. |
IMappingExpression<TSource, TDestination>
| Member | Description |
|---|---|
ForMember<TMember>(dest => dest.Prop, opts => ...) |
Configures a single destination member. Chainable. |
IMemberConfigurationExpression<TSource, TDestination, TMember>
| Member | Description |
|---|---|
MapFrom<TSourceMember>(src => src.Prop) |
Maps via a compiled expression. Supports primitive type conversion. |
MapFrom((src, dst) => ...) |
Maps via a runtime delegate. Full access to source and partial destination. |
Ignore() |
Skips the destination member entirely. |
UseValue(TMember value) |
Sets the destination member to a constant value. |
IMapper
| Member | Description |
|---|---|
Map<TDest>(object source) |
Maps using runtime source type. |
Map<TSrc, TDest>(TSrc source) |
Maps using compile-time source type. |
Map<TSrc, TDest>(TSrc source, TDest destination) |
Maps onto an existing instance. |
MapList<TSrc, TDest>(IEnumerable<TSrc> source) |
Maps every element to List<TDest>. |
MappingException
| Member | Description |
|---|---|
SourceType |
The source Type at the point of failure. |
DestinationType |
The destination Type at the point of failure. |
Message |
"Mapping 'SrcFullName' → 'DstFullName': <reason>" |
InnerException |
The original BCL exception, if any. |
Limitations
| Limitation | Notes |
|---|---|
| Write-only properties | Source properties without a getter and destination properties without a setter are silently skipped. |
| Constructor injection on destination | Destinations must have a public parameterless constructor. Use Map(src, existingDest) to work around this. |
| Reverse mapping | CreateMap<A, B>() does not automatically create a B → A mapping. Call CreateMap<B, A>() explicitly. |
| Inheritance / polymorphism | Map<TSrc, TDest> uses typeof(TSrc) as the lookup key. Map<TDest>(object) uses source.GetType(), which handles runtime polymorphism. |
| Fields | Only properties are mapped. Public fields are ignored. |
| Plan eviction | The plan cache grows for the lifetime of the Mapper instance. In applications with thousands of dynamic types this could become a concern; restart the Mapper if needed. |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.