Bsft.Mapping
Convention-based object mapping with explicit profiles, plus expression-tree projection so a DTO mapping can run inside the database query rather than after it.
Contract
public interface IMapper
{
TDest Map<TDest>(object source);
TDest Map<TSource, TDest>(TSource source);
TDest Map<TSource, TDest>(TSource source, TDest existing);
Expression<Func<TSource, TDest>> GetProjection<TSource, TDest>();
}
Everything is synchronous; there is no CancellationToken anywhere on this
interface. The three-argument Map maps into an existing instance, which is
what you want when updating a tracked entity rather than replacing it.
Profiles
A profile derives from MappingProfileBase and declares its type pairs:
public class ProductMappingProfile : MappingProfileBase
{
public override void Configure(ProfileBuilder builder)
{
builder.CreateMap<Product, ProductDto>();
}
}
Properties are matched by name. When they do not line up, configure the member explicitly, or exclude it:
public override void Configure(ProfileBuilder builder)
{
builder.CreateMap<Order, OrderDto>()
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer.Name))
.Ignore(d => d.InternalNotes);
}
ForMember takes the destination member and an options callback whose only
method is MapFrom. Ignore removes a destination member from the map, which
is how you stop the validator complaining about something intentionally unset.
Each type pair is registered once. A profile refuses to declare the same pair twice, and so does the mapper across profiles: two profiles declaring one pair used to leave the winner to the order the assembly scan returned them in.
What the mapper writes
Only a property with a public setter is assigned. A property counts as public as
soon as its getter does, so { get; private set; } would otherwise be writable
through reflection, and mapping a DTO onto a tracked entity would overwrite
exactly the members the entity made unsettable — its identifier, its owner, its
tenant. A member the destination computes or keeps to itself is skipped by
Map, left out of the projection, and not something the validator demands a
configuration for.
Properties without a setter are still matched by name, because an immutable destination takes them through its constructor.
Setup
services.AddBsftMapping(mapping => mapping
.AddProfilesFromAssembly(typeof(ProductMappingProfile).Assembly));
The configure delegate is required. MappingConfiguration has exactly one
public method, AddProfilesFromAssembly(Assembly), which may be called several
times to compose profiles from several modules. Naming one assembly twice scans
it once.
The registration adds IMapper and IMappingValidationService as singletons and
a MappingStartupValidator as an IStartupValidator. Validation reads the model
this call built: a host that registers its own IMapper afterwards replaces the
execution, and the discovered profiles are still what its configuration has to be
complete against.
The package's types live in namespaces by role: the contracts IMapper and
IMappingValidationService in Bsft.Mapping.Abstractions, their implementations
in Bsft.Mapping.Services, MappingValidationException in
Bsft.Mapping.Exceptions, and the profile-authoring API (MappingProfileBase,
ProfileBuilder, MapBuilder<,>, MemberMapOptions<,>, MappingConfiguration,
ProjectTo) in Bsft.Mapping itself, which is where an application writing a
profile already is.
There is no IConfiguration overload and no options type. MappingConfiguration
and MemberMapOptions<,> are builder-configuration values that live for the
duration of a call: a mapping is code, and an assembly to scan is not a value a
configuration provider can supply.
Startup validation
Mappings are verified at startup, not on first use. A destination member that is
neither matched by convention, nor configured with MapFrom, nor Ignored is a
startup failure rather than a silently null field discovered in production.
MappingValidationException carries the list of problems in its Errors
property.
This only fires if the host runs the startup validators — see
AddBsftStartupValidation() in hosting.md, which
AddBsftDefaults() calls for you.
Projection
GetProjection<TSource, TDest>() returns an expression built from member-init
nodes, which EF Core can translate. The ProjectTo extension applies it:
using Bsft.Mapping;
using Bsft.Mapping;
var dtos = await db.Products
.Where(p => p.IsActive)
.ProjectTo<ProductDto>(mapper)
.ToListAsync(ct);
Note the signature — the mapper is an argument, not an ambient dependency:
public static IQueryable<TDest> ProjectTo<TDest>(this IQueryable source, IMapper mapper)
The difference from Map matters. ProjectTo produces a SELECT of only the
columns the DTO needs; mapping after materialisation pulls whole entities into
memory first. For a list endpoint that is usually the difference between one
narrow query and a table's worth of rows.
Example
public sealed class GetProductsHandler(IInventoryDbContext db, IMapper mapper)
: IQueryHandler<GetProducts, IReadOnlyList<ProductDto>>
{
public async Task<IReadOnlyList<ProductDto>> HandleAsync(GetProducts query, CancellationToken ct = default) =>
await db.Products
.AsNoTracking()
.ProjectTo<ProductDto>(mapper)
.ToListAsync(ct);
}
See also
- data.md — filtering, sorting and pagination over the same queryable.
- hosting.md — where startup validation is executed.