HostLoom.Mapping
0.16.1
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
<PackageReference Include="HostLoom.Mapping" Version="0.16.1" />
<PackageVersion Include="HostLoom.Mapping" Version="0.16.1" />
<PackageReference Include="HostLoom.Mapping" />
paket add HostLoom.Mapping --version 0.16.1
#r "nuget: HostLoom.Mapping, 0.16.1"
#:package HostLoom.Mapping@0.16.1
#addin nuget:?package=HostLoom.Mapping&version=0.16.1
#tool nuget:?package=HostLoom.Mapping&version=0.16.1
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. UseIMapperonly 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 itServiceLifetime.Singletonso 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.Selectexpressions 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 | 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 (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 |