Skip to content

## Twinify v1.0.1 and V1.0.2 — Performance Release

Latest

Choose a tag to compare

@StevenKamwaza StevenKamwaza released this 07 Aug 06:25

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/FieldInfo get/set on the hot path is now a cached Expression-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<>, and IMappingAction<> implementations were previously invoked via Type.GetMethod("Resolve"/"Convert"/"Process") + MethodInfo.Invoke(object[]) on every single call. These are now compiled into direct, typed invoker delegates at CreateMap/ForMember configuration 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 of Activator.CreateInstance/ConstructorInfo.Invoke. We also stopped re-fetching ConstructorInfo.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 reflects KeyValuePair<,>.Key/.Value per entry — accessors are cached once per key/value type pair.
  • A Type/member-name lookup used by ForPath and 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 Mapper without 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 a MemberInfo via System.Linq.Expressions. Handles boxed-struct destinations correctly via Expression.Unbox (mutates the existing box in place, matching prior reflection SetValue semantics, rather than silently mutating a discarded copy).
  • New: Twinify.Engine.CollectionMaterializerFactory — cached, reflection-once-per-element-type factories for List<T>, HashSet<T>, and KeyValuePair<,> accessors.
  • CompiledTypeMap/CompiledMember now carry the compiled delegates described above (activators, getters/setters, invokers) alongside the existing metadata used by Explain()/ValidateMappings().
  • TypeMapConfiguration/MemberConfiguration gained invoker delegate fields populated at CreateMap/ForMember/MapFrom/ConvertUsing/BeforeMap/AfterMap call sites in Twinify.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/CreateMap declaration 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.