HostLoom.Mapping 0.16.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package HostLoom.Mapping --version 0.16.1
                    
NuGet\Install-Package HostLoom.Mapping -Version 0.16.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="HostLoom.Mapping" Version="0.16.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="HostLoom.Mapping" Version="0.16.1" />
                    
Directory.Packages.props
<PackageReference Include="HostLoom.Mapping" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add HostLoom.Mapping --version 0.16.1
                    
#r "nuget: HostLoom.Mapping, 0.16.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package HostLoom.Mapping@0.16.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=HostLoom.Mapping&version=0.16.1
                    
Install as a Cake Addin
#tool nuget:?package=HostLoom.Mapping&version=0.16.1
                    
Install as a Cake Tool

HostLoom.Mapping

HostLoom.Mapping is a small, explicit alternative to runtime convention mappers. A map is an ordinary C# class, so member access, nullability, constructor choice, and conversions are checked by the compiler. Mapping performs no assembly scanning, reflection-based member mapping, expression compilation, runtime code generation, or global configuration.

The core package has no dependency-injection dependency. Install HostLoom.Mapping.DependencyInjection to register map classes with the built-in .NET container.

public sealed record Customer(string Name, Address Address);
public sealed record CustomerDto(string Name, string City);

public sealed class CustomerMapper : IMapper<Customer, CustomerDto>
{
    public CustomerDto Map(Customer source) =>
        new(source.Name, source.Address.City);
}

services.AddHostLoomMapping(mapping =>
    mapping.Add<CustomerMapper>());

// Inject the closed map. This is the default shape: it names the pair, resolves at any lifetime
// the map's own graph allows, costs one virtual call, and is what every adopter so far has used.
public sealed class CustomerService(IMapper<Customer, CustomerDto> toDto)
{
    public CustomerDto Describe(Customer customer) => toDto.Map(customer);
}

The non-generic IMapper dispatcher exists for one case: an orchestration that maps several pairs and would otherwise take one closed map per pair. It resolves the closed map from the container per call, so it costs a service lookup and is registered scoped:

using var scope = provider.CreateScope();
var mapper = scope.ServiceProvider.GetRequiredService<IMapper>();
var dto = mapper.From(customer).To<CustomerDto>();

Reach for it only when the closed maps would number more than the readability gain is worth; two closed maps still read better than one dispatcher.

IMapper is registered scoped, which has two consequences worth knowing before the first run. Resolving it from the root provider throws once scope validation is on — the default for the generic host in Development, so this fails there and would have succeeded in Production. For the same reason it cannot be constructor-injected into a singleton; inject the closed IMapper<TSource, TDestination> there, or take IServiceScopeFactory and resolve per unit of work. A closed map is the right injection there, but not unconditionally: a transient map captured by a singleton is promoted to one, so a scoped service anywhere in that map's graph reproduces the same Development-throws, Production-succeeds asymmetry. Inject a closed map into a singleton only when its whole graph is singleton-safe.

Registering the dispatcher itself as a singleton is possible and constrained: every map must then also be a singleton, and a non-singleton one is rejected at registration. A singleton dispatcher resolves from the root provider, which never goes out of scope — so a disposable map, or any disposable in its graph, would be retained for the life of the process rather than released per unit of work.

Both of those failures, and a map whose constructor asks for a pair nobody registered, are caught by the container's own validation — which Host.CreateDefaultBuilder enables only in Development. That is the wrong way round: it means the environment least like production is the only one that checks. Turn both on everywhere, so a missing inner map fails at host build rather than inside the first message that needs it:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

Add<TMapper>() reads the pair from the single closed IMapper<TSource, TDestination> the map class implements, so a registration does not restate a type triple the class already declares — and the file that registers a map needs no using for the contracts it maps between. Inference reads metadata once per registration; nothing is reflected on the map path. Use the explicit Add<TSource, TDestination, TMapper>() overload for a class that implements more than one pair, or to close an open generic map. A class implementing no pair, or more than one, fails at registration with both alternatives named.

Generic map classes

A map class generic in more than its pair is closed by naming it, from a generic helper. Prefer the implementation-type overload: the container constructs the map, so ValidateOnBuild checks its dependencies at startup like any other registration.

static void AddEntityMap<TEntity, TModel, TTranslation>(MappingBuilder mapping)
    where TEntity : notnull where TModel : notnull =>
    mapping.Add<TEntity, TModel, EntityMapper<TEntity, TModel, TTranslation>>();

Reach for the factory overload below only when construction needs a value the container cannot supply. A factory body is opaque to ValidateOnBuild: whatever it resolves inside is unchecked until the map is first resolved, which moves a startup failure back to first use.

static void AddEntityMap<TEntity, TModel, TTranslation>(MappingBuilder mapping, Func<TEntity, TModel> convert)
    where TEntity : notnull where TModel : notnull =>
    mapping.Add<TEntity, TModel>(_ => new EntityMapper<TEntity, TModel, TTranslation>(convert));

An open generic registration — IMapper<,> to EntityMapper<,,> — is not an option either way, because the container requires the open service type and open implementation type to have equal arity and this shape never does. Both forms above close the map at the call site, where the compiler still sees every type argument, so neither needs MakeGenericType and both keep the trimming and Native AOT analyzers clean.

Every type argument stays visible to the compiler, so this needs no MakeGenericType and keeps the trimming and Native AOT analyzers clean. Each call still produces one closed descriptor, so the registered pairs remain enumerable and duplicate detection still applies. The factory also takes the IServiceProvider, so a generic map can resolve dependencies like any other.

Maps are transient by default so constructor-injected scoped dependencies remain safe. Choose a different ServiceLifetime only when its dependency graph supports that lifetime. A singleton map instance can also be registered explicitly. Container-created map classes expose a public constructor. Duplicate type pairs fail during registration — across both overloads, since the inferred pair is the same closed service type — and a missing pair throws MappingNotFoundException with both requested types.

Sequences and null

A map handles one value. Sequences are extension methods on the closed mapper, and the null policy is in the method name rather than in configuration:

IReadOnlyList<CustomerDto> all   = customerMapper.MapMany(customers);          // null source throws
IReadOnlyList<CustomerDto> safe  = customerMapper.MapManyOrEmpty(maybeNull);   // null source -> []
IEnumerable<CustomerDto>   lazy  = customerMapper.MapManyDeferred(hugeScan);   // one at a time
CustomerDto?               maybe = customerMapper.MapOrNull(maybeNullCustomer);

MapMany and MapManyOrEmpty return IReadOnlyList<TDestination> and are sized in one allocation when the source can report a count. IReadOnlyList<T> rather than List<T> keeps the signature clear of CA1002; the knock-on in a repository running all analyzers is that .First() on the result then trips CA1826, so index it with [0]. MapManyDeferred validates its arguments eagerly but maps lazily — do not let its result outlive the scope that resolved the mapper, or a map class holding scoped dependencies will be enumerated after they are disposed.

Dictionaries

There is no dictionary extension, because the standard constructors already give every property one would add — a new dictionary, an explicit comparer rather than one silently inherited from the input, and a throw on a key that collides under that comparer — and the null policy can be named at the call site the same way the sequence extensions name it:

var data = new Dictionary<string, string>(source.Data, StringComparer.Ordinal);   // null throws
var data = source.Data is null                                                    // null -> empty
    ? new Dictionary<string, string>(StringComparer.Ordinal)
    : new Dictionary<string, string>(source.Data, StringComparer.Ordinal);
var products = source.Products.ToDictionary(                                      // projected
    pair => pair.Key.Trim(),
    pair => productMapper.Map(pair.Value),
    StringComparer.OrdinalIgnoreCase);

Reference-type keys and values are shared with the source, as MapMany shares elements; map them explicitly when a copy is required.

Migrating from a convention mapper

AutoMapper's null handling differs from these contracts in three places, all verified against AutoMapper 14 rather than assumed:

Source AutoMapper (default) HostLoom Map / MapMany Behaviour-preserving
null scalar null ArgumentNullException MapOrNull
null collection empty collection ArgumentNullException MapManyOrEmpty
null collection member empty collection whatever the map writes MapManyOrEmpty

The third row is the one that surprises. AllowNullCollections = false is not only a top-level rule — it also rewrites null collection members to empty during ordinary object mapping. So a destination whose collection member came back empty may have had a null source all along, and a hand-written map that forwards the null is a behaviour change even though no call site changed.

Translate to the OrEmpty and OrNull forms first, which preserves behaviour exactly, then treat each one as its own decision. Because the tolerance is named at the call site rather than set globally, every place that depends on it stays greppable — which a configuration flag does not.

Updating an existing object

A creation map returns a new destination. When the caller already holds the destination — an entity a persistence context is tracking, whose identity and audit members the source does not carry — replacing it discards those members and breaks the tracking. An update map writes into the instance instead:

public sealed record VendorUpdate(string Name, string Region);

public sealed class VendorUpdateMapper : IUpdateMapper<VendorUpdate, VendorEntity>
{
    // Id and CreatedAt are outside the update contract and keep their values.
    public void MapInto(VendorUpdate source, VendorEntity destination)
    {
        ArgumentNullException.ThrowIfNull(source);
        ArgumentNullException.ThrowIfNull(destination);
        destination.Name = source.Name;
        destination.Region = source.Region;
    }
}

services.AddHostLoomMapping(mapping =>
    mapping.Add<VendorMapper>().AddUpdate<VendorUpdateMapper>());

public sealed class VendorService(
    IVendorRepository vendors,
    IUpdateMapper<VendorUpdate, VendorEntity> apply)
{
    public async Task UpdateAsync(Guid id, VendorUpdate update, CancellationToken cancellationToken)
    {
        VendorEntity entity = await vendors.GetAsync(id, cancellationToken);
        apply.MapInto(update, entity);   // same instance: the repository still tracks it
        await vendors.SaveAsync(cancellationToken);
    }
}

MapInto returns nothing, so "the supplied instance is the one that changed" is a property of the signature. Both arguments are non-null by contract and a null is rejected with ArgumentNullException rather than read as "nothing to update". The destination is a reference type, because updating a copy of a struct would update nothing the caller can see.

A partial update is the normal case, which is what separates this contract from a creation map. The completeness analyzers (HLM0004, HLM0005) inspect Map only; an update map states the members it leaves alone in its class documentation and pins them with a test. Nested objects are updated by composing another closed update map, as creation maps compose. Collection behavior is chosen in the body and nowhere else — clear and refill the list the entity owns, merge by key, or leave it untouched — since the framework adds no automatic merging or implicit replacement.

AddUpdate has the same four overloads as Add: pair inference from the one IUpdateMapper<,> the class implements, the explicit triple, a factory, and a prebuilt instance. Each call registers exactly one contract, so a class that both creates and updates one pair goes through Add and AddUpdate; the two are distinct services and coexist. The IMapper dispatcher never resolves an update map — inject the closed IUpdateMapper<TSource, TDestination> — which also means the singleton-dispatcher lifetime rule does not constrain one.

Asserting the registered pairs

A missing pair is a runtime failure on the first code path that needs it. GetMappedPairs exposes what was registered, so a service can assert its expectations while the container is still being composed:

MappedPairRegistry pairs = services.GetMappedPairs();
if (!pairs.Contains(typeof(TransferModel), typeof(PaymentTransfer)))
{
    throw new InvalidOperationException("the transfer map is not registered");
}

Update maps are listed separately, as UpdatePairs and ContainsUpdate, so the hint below never points at a map the dispatcher cannot resolve.

When a pair really is missing, MappingNotFoundException names the destinations the source is registered to map to. The near miss is usually the diagnosis — a destination named one letter differently, or the pair registered in the other direction.

Testing without a container

HostLoom.Mapping.Testing builds the dispatcher from explicit maps, so a unit test does not have to compose an IServiceCollection to get one:

IMapper mapper = new TestMapperBuilder()
    .Add<Customer, CustomerDto>(new CustomerMapper())
    .Add<Address, AddressDto>(source => new AddressDto(source.City, source.PostalCode))
    .Build();

A consumer that takes a closed IMapper<TSource, TDestination> or IUpdateMapper<TSource, TDestination> — the shapes recommended above — needs nothing from that package: construct the map class and pass it.

Completeness

The compiler catches a rename, a changed constructor, and a nullability mismatch. It does not catch a member you simply never assigned — on a destination with settable properties, forgetting destination.ImageUrl = source.ImageUrl compiles, passes review, and ships. That is the one axis on which an explicit map is weaker than the convention mapping it replaces, which got the member right by accident.

HostLoom.Analyzers closes it. HLM0004 reports a destination member a map never assigns; HLM0005 reports a map body it cannot check, so silence always means checked rather than skipped. Deliberate omissions are named, which keeps a member added later from being excused along with them:

[UnmappedMembers(nameof(PaymentTransfer.CardMask))]
public sealed class TransferModelToPaymentTransferMapper : IMapper<TransferModel, PaymentTransfer>

UnmappedMembersAttribute ships in this package, so applying it adds no dependency beyond the one already present. The analyzer package itself is compiler-only and carries no runtime assembly.

Design rules

  • Use different destination types for semantically different views instead of selecting a hidden named profile for the same pair. The pair is the key, so several sources mapping to one shared destination is fine and expected — what this rule forbids is one pair with two meanings.
  • When a source member is nullable and the destination contract is not, forward the null with ! and record the mismatch as its own change. Substituting a default inside an unrelated refactor buries a contract defect in a diff nobody is reviewing for it.
  • Keep mapping synchronous and deterministic. Fetch or enrich data outside the map.
  • Inject a closed IMapper<TSource, TDestination> into focused consumers. Use IMapper only when coordinating multiple pairs. The dispatcher resolves the pair from the scope on every call, so it costs roughly twice an injected closed map; when a map is stateless, register it ServiceLifetime.Singleton so the dispatcher stops constructing one per call. That removes the dispatcher's extra allocation entirely rather than merely reducing it — the benchmarks measure 112 B against 88 B, the same as the injected closed map.
  • Write database projections directly as IQueryable.Select expressions so the provider sees the complete expression tree; do not materialize records merely to pass them through a mapper.
  • Treat null as an application decision. The contracts require non-null inputs; represent a nullable result explicitly in the destination model when that is meaningful.
  • Prefer immutable destination records and constructor mapping. Required members and constructors make incomplete mappings compile-time failures.
  • Reach for an update map only when the caller already holds the destination and its identity or retained members matter. Creating a fresh destination is the default; an update map is a deliberate partial write, documented as such.

Both packages target .NET 10, enable the SDK's Native AOT and trimming analyzers, and keep the map dispatch path free of reflection and dynamic code generation.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on HostLoom.Mapping:

Package Downloads
HostLoom.Mapping.DependencyInjection

Microsoft.Extensions.DependencyInjection registration for HostLoom mapping.

HostLoom.Mapping.Testing

Container-free mapper composition for testing HostLoom mapping consumers.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.17.0 32 10/25/2026
0.16.1 91 10/22/2026
0.16.0 185 10/14/2026
0.15.1 128 10/12/2026
0.15.0 199 10/4/2026
0.14.0 124 10/4/2026
0.13.0 159 9/28/2026
0.12.0 146 9/27/2026
0.11.0 151 9/25/2026
0.10.0 150 9/22/2026
0.9.0 128 9/22/2026
0.8.0 149 9/19/2026
0.7.0 146 9/17/2026
0.6.0 144 9/6/2026
0.5.0 134 9/5/2026
0.4.0 137 9/3/2026
0.3.0 145 8/28/2026
0.2.0 134 8/27/2026