This release is a major performance pass on the mapping engine. No public API changes — every CreateMap/ForMember/MapFrom/ConvertUsing/DI registration call you already have keeps working exactly as written. Under the hood, the engine no longer leans on live reflection (PropertyInfo.GetValue/SetValue, MethodInfo.Invoke, Activator.CreateInstance) on every mapped object — that work now happens once, at startup, and steady-state mapping runs on cached compiled delegates instead.
Also includes a thread-safety fix we'd recommend everyone take regardless of whether you care about the performance work.
Fixed — concurrency bug in open-generic map resolution
TypeMapRegistry cached closed generic type-pair maps (e.g. the first time PagedResult<Order> → PagedResultDto<OrderDto> was requested) in a plain Dictionary, written to lazily from Map() itself. Since IMapper is registered and used as a singleton, concurrent first-time requests for the same or different open-generic pairs could write to that dictionary from multiple threads at once with no synchronization — a known way to corrupt Dictionary's internal state and surface as intermittent NullReferenceException/IndexOutOfRangeException/hangs under load.
This is now a ConcurrentDictionary with a GetOrAdd-based publish, so concurrent first-time resolution is safe. If you map any open-generic types under concurrent load, upgrading is recommended even if you don't need the perf work below.
Performance
Headline numbers: ~8–15× faster on typical DTO-shaped mapping, with most of that coming from three changes:
- Member access is compiled, not reflected. Every
PropertyInfo/FieldInfoget/set on the hot path is now a cachedExpression-tree-compiled delegate, built once when a profile is registered. This removes the reflection invocation overhead (signature checks, internal boxing/marshalling) that previously ran on every member, on every mapped object. - Resolvers, converters, and mapping actions no longer reflect at call time.
IValueResolver<>,IValueConverter<>,ITypeConverter<>, andIMappingAction<>implementations were previously invoked viaType.GetMethod("Resolve"/"Convert"/"Process")+MethodInfo.Invoke(object[])on every single call. These are now compiled into direct, typed invoker delegates atCreateMap/ForMemberconfiguration time (when the generic type arguments are still known), so invocation is now a plain interface call with no per-call reflection lookup or boxed argument array. - Object construction is compiled. Parameterless and parameterized constructors are now invoked via cached
Expression.New-based delegates instead ofActivator.CreateInstance/ConstructorInfo.Invoke. We also stopped re-fetchingConstructorInfo.GetParameters()(which allocates a fresh array from metadata) on every mapped object — it's cached once per map.
Collection mapping is significantly faster and allocates far less. Previously, every collection-valued member went through an intermediate List<object?> buffer and a non-generic IList.Add(object) call into the final typed collection — for value-type elements that's a box on the way in, then a box/unbox round-trip on the way into the final list. Arrays, List<T>, HashSet<T>, and Dictionary<,> destinations are now materialized through cached, strongly-typed factories (built once per element type), and arrays allocate at their final size in a single pass when the source's count is known up front.
Other smaller wins:
Map()no longer does a redundant second compiled-map lookup when the runtime source type matches the declared source type (the overwhelmingly common case).- Resolvers/converters/actions that aren't registered in your DI container are no longer re-activated via reflection on every call — a single instance is cached per type for the process lifetime (these types are expected to be stateless; see Behavior notes below).
Dictionary<,>-to-Dictionary<,>mapping no longer reflectsKeyValuePair<,>.Key/.Valueper entry — accessors are cached once per key/value type pair.- A
Type/member-name lookup used byForPathand internally by the registry is now cached instead of re-scanning type metadata on every use.
Behavior notes (non-breaking, but worth knowing about)
- DI-less resolver/converter/action lifetime: if you construct
Mapperwithout a DI container (or a resolver/converter/action type isn't registered in your container), instances are now cached and reused for the process lifetime instead of being freshly constructed on every call. This matches how these interfaces were already designed to be used — all per-call state flows through method parameters, never through instance fields — so this should be unobservable in practice. If your resolver/converter/action does hold per-call mutable state in a field (unsupported usage, but flagging it), this is now a breaking change for you; register it through your DI container with the appropriate lifetime instead. - No changes to null-handling, condition evaluation order,
PreserveReferences,MaxDepth, flattening/prefix-postfix matching, or any other mapping semantics — only how values are read/written/constructed changed, not what gets mapped where.
Internal / for contributors
- New:
Twinify.Engine.MemberAccessorFactory— builds cached get/set delegates for aMemberInfoviaSystem.Linq.Expressions. Handles boxed-struct destinations correctly viaExpression.Unbox(mutates the existing box in place, matching prior reflectionSetValuesemantics, rather than silently mutating a discarded copy). - New:
Twinify.Engine.CollectionMaterializerFactory— cached, reflection-once-per-element-type factories forList<T>,HashSet<T>, andKeyValuePair<,>accessors. CompiledTypeMap/CompiledMembernow carry the compiled delegates described above (activators, getters/setters, invokers) alongside the existing metadata used byExplain()/ValidateMappings().TypeMapConfiguration/MemberConfigurationgained invoker delegate fields populated atCreateMap/ForMember/MapFrom/ConvertUsing/BeforeMap/AfterMapcall sites inTwinify.Abstractions.
Upgrading
Drop-in — no code changes required. If you have an existing benchmark or profiling setup, we'd love to hear real-world numbers; the figures above are from representative DTO/collection-mapping workloads and will vary with your specific type shapes (maps dominated by custom MapFrom(expr => ...) lambdas or ConvertUsing(func) delegates will see smaller gains, since those were already plain delegate calls).
As always, run your existing test suite before deploying — this release touches core mapping/construction code paths, even though the public surface is unchanged.
🗺️ What's next
- Zero-config auto-mapping — the ability to map two types with no
MappingProfile/CreateMapdeclaration at all, for cases where convention-based matching is all you need. The idea is an opt-in "just map it" path (e.g.mapper.Map<TDestination>(source)resolving an implicit convention-based map on first use, cached the same way an explicit one is) so simple DTO shapes don't require boilerplate profile setup. This is next up. - Native AOT / full trimming support. The current release targets JIT scenarios — ASP.NET Core, background services, console apps — where
Expression.Compile()is fully supported. A Roslyn source generator that emits fully static, reflection-free mapping code at compile time is being explored as the long-term path to zero-reflection AOT support.
If either of these matters to you, or you have other requests, let us know in the issues — it helps us prioritize.