Implements mapping methods to replace AutoMapper. Supports manual extension methods (default) and Mapperly source generators.
Creates mapping code that replicates AutoMapper behavior exactly. Supports two approaches:
Before proceeding, read mappingApproach from inventory.json:
| Value | Action |
|---|---|
"manual" | Follow the Manual Mapping section |
"mapperly" | Follow the Mapperly section |
| Not set | Default to Manual Mapping |
From inventory entry:
{
"sourceType": "Order",
"destType": "OrderDto",
"tier": "formember",
"features": {
"hasForMember": true,
"hasIgnore": true
}
}
Read source and destination class definitions:
Scan the destination type declaration for properties with private set or private init:
public\s+\w+\??\s+\w+\s*\{\s*get;\s*private\s+(set|init)
If any exist, the mapper cannot use a plain object initializer for those properties. On .NET 8+ the correct approach is UnsafeAccessorAttribute, which provides zero-cost access with no reflection overhead and is AOT-compatible.
using System.Runtime.CompilerServices;
// Declare one accessor per private-setter property.
// The first parameter MUST be the declaring type of the member
// (which may be a base class, not the concrete type).
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Status")]
private static extern void SetStatus(Order instance, OrderStatus value);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_TotalAmount")]
private static extern void SetTotalAmount(Order instance, decimal value);
public static Order ToDomain(this OrderDao dao)
{
var order = new Order { Id = dao.Id }; // public-init properties via initializer
SetStatus(order, dao.Status); // private set via UnsafeAccessor
SetTotalAmount(order, dao.TotalAmount);
return order;
}
If the private setter is on a base class, declare the accessor with the base class as the first parameter type. Using the wrong type causes a runtime
MissingMethodException.
Naming convention: Method names must follow ^To(Dao|Domain|(\w*Dto))$. The class is a static class in the Mappers namespace.
File: src/Mappers/{SourceType}Mapper.cs — one mapper class per file, no exceptions unless the user has explicitly approved grouping (see repoFacts.fileGroupingApproved in inventory.json). If you are about to write multiple mapper classes into one file without that flag, stop and ask the user first.
Structure:
namespace {Namespace}.Mappers
{
public static class {SourceType}Mapper
{
public static {DestType} ToDto(this {SourceType} source)
{
if (source == null) return null;
return new {DestType}
{
// Property mappings
};
}
}
}
Direct mapping (same name, same type):
Id = source.Id,
ForMember with MapFrom:
// Original: .ForMember(d => d.FullName, o => o.MapFrom(s => $"{s.First} {s.Last}"))
FullName = $"{source.First} {source.Last}",
Ignore:
// Property not assigned, uses default
Flattening (e.g., Customer.Name → CustomerName):
CustomerName = source.Customer?.Name,
Items = source.Items?.Select(i => i.ToItemDto()).ToList(),
Ensure nested type mappers exist first (dependency order).
Base mapper:
public static class BaseEntityMapper
{
public static void MapBaseProperties(BaseEntity source, BaseEntityDto dest)
{
dest.Id = source.Id;
dest.CreatedAt = source.CreatedAt;
}
}
Derived mapper:
public static DerivedDto ToDto(this Derived source)
{
if (source == null) return null;
var dto = new DerivedDto();
BaseEntityMapper.MapBaseProperties(source, dto);
dto.DerivedProp = source.DerivedProp;
return dto;
}
Simple converter:
public static MoneyDto ToDto(this Money source)
{
return new MoneyDto
{
Amount = source.Amount,
CurrencyCode = source.Currency.IsoCode
};
}
When the same type conversion appears across multiple mappers (e.g., Money ↔ decimal, DateTime ↔ DateOnly), extract it into a shared static class rather than duplicating logic:
// Shared conversions reused across all mappers
public static class CommonMappings
{
public static DateOnly ToDateOnly(this DateTime dt) => DateOnly.FromDateTime(dt);
public static DateTime ToDateTime(this DateOnly d) => d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
// Nullable variants
public static DateOnly? ToDateOnly(this DateTime? dt) => dt.HasValue ? DateOnly.FromDateTime(dt.Value) : null;
public static DateTime? ToDateTime(this DateOnly? d) => d?.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
}
Call these from individual mappers:
public static OrderDto ToDto(this Order source)
{
return new OrderDto
{
OrderDate = source.OrderDate.ToDateOnly(), // ← shared conversion
};
}
This keeps individual mappers clean and ensures type conversions are tested in one place.
AfterMap:
public static OrderDto ToDto(this Order source)
{
var dto = new OrderDto { ... };
// AfterMap logic
dto.CalculatedField = CalculateValue(source);
return dto;
}
public static {DestType} To{DestType}(this {SourceType} source)
public static IEnumerable<{DestType}> To{DestType}s(this IEnumerable<{SourceType}> source)
{
return source?.Select(x => x.To{DestType}());
}
Nullable source:
if (source == null) return null;
Nullable properties:
Address = source.Address?.ToDto(),
Collections:
Items = source.Items?.Select(i => i.ToDto()).ToList() ?? new List<ItemDto>(),
AutoMapper:
CreateMap<Order, OrderDto>()
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer.Name))
.ForMember(d => d.InternalCode, o => o.Ignore());
Manual:
public static OrderDto ToDto(this Order source)
{
if (source == null) return null;
return new OrderDto
{
Id = source.Id,
OrderDate = source.OrderDate,
CustomerName = source.Customer?.Name,
// InternalCode intentionally not mapped (was Ignore())
};
}
<PackageReference Include="Riok.Mapperly" Version="4.*" PrivateAssets="all" />
PrivateAssets="all" makes it a build-only dependency with no runtime footprint.
Same check as Manual (see above). Mapperly's MemberVisibility.All only handles fully-private properties. For the common public get; private set pattern, Mapperly does not generate UnsafeAccessorAttribute and leaves the setter unset. You still need manual accessor declarations for these properties.
using Riok.Mapperly.Abstractions;
[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)]
public static partial class OrderMapper
{
// Mapperly generates the implementation from the signature alone
public static partial OrderDto ToDto(this Order source);
}
Property name mismatch (flattening):
[MapProperty(nameof(Order.Customer.Name), nameof(OrderDto.CustomerName))]
public static partial OrderDto ToDto(this Order source);
Ignore a target property:
[MapperIgnoreTarget(nameof(OrderDto.InternalCode))]
public static partial OrderDto ToDto(this Order source);
Custom type conversion (auto-discovered by Mapperly when defined in the same class):
private static DateOnly DateTimeToDateOnly(DateTime dt) => DateOnly.FromDateTime(dt);
private static DateTime DateOnlyToDateTime(DateOnly d) => d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
For properties Mapperly cannot set, wrap the generated method and apply UnsafeAccessorAttribute manually:
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Status")]
private static extern void SetStatus(Order instance, OrderStatus value);
// Generated partial handles all public properties
[MapperIgnoreTarget(nameof(Order.Status))]
private static partial Order ToDomainInternal(OrderDao source);
// Public entry point wires the private setter
public static Order ToDomain(this OrderDao dao)
{
var order = ToDomainInternal(dao);
SetStatus(order, dao.Status);
return order;
}
If the domain type has no public default constructor, write the entry point manually and call Mapperly for the property population:
public static Order ToDomain(this OrderDao dao)
{
var order = Order.Create(dao.OrderNumber); // factory method or custom constructor
// Mapperly cannot instantiate it, but you can populate remaining properties manually
order.Description = dao.Description;
return order;
}
Mapperly produces compile-time diagnostics:
RMG020 — unmapped target propertyRMG012 — source property not foundTo treat unmapped properties as errors:
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public static partial class OrderMapper { ... }
AutoMapper:
CreateMap<Order, OrderDto>()
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer.Name))
.ForMember(d => d.InternalCode, o => o.Ignore());
Mapperly:
[Mapper]
public static partial class OrderMapper
{
[MapProperty(nameof(Order.Customer.Name), nameof(OrderDto.CustomerName))]
[MapperIgnoreTarget(nameof(OrderDto.InternalCode))]
public static partial OrderDto ToDto(this Order source);
}