Mapperly: standard entity↔DTO mapping across Mas*/Automation repositories
Adopted 2026-08-10 as the standard mechanism for the Reference CRUD pattern's
hand-written Map(entity) methods and entity-construction/mutation blocks —
replaced with Mapperly-generated ToDto/ProjectToDto (read) and
ApplyToEntity/ApplyCreateFieldsToEntity (write) extension methods, one pair
of mapper classes per repository (<Entity>Mapper.cs, <Entity>WriteMapper.cs).
It's a source generator only — nothing runtime-shippable, unlike AutoMapper
(rejected: every version trips NU1903). Rolled out across every
EntityFrameworkCore-strategy Mas*/Automation repository.
:::danger The one hard rule — found the hard way, twice
Never use [MapProperty(..., Use = ...)] with a custom conversion method.
Mapperly treats any method matching a type-pair's signature as a class-wide
"user-implemented mapping" and silently reuses it for every other
property sharing that exact signature — not just the one it's declared next
to. This shipped two real bugs before being caught (MenuMapper.cs/
UserMapper.cs — see their own doc comments) and nearly shipped a third
during ClientWriteMapper authoring. ThrowOnMappingNullMismatch = false
does not prevent the leak (confirmed by testing directly) — that setting
only changes behavior when no user method exists for a signature.
The real fix: every value-transform field (byte↔bool, null-coalescing
defaults, blob/JSON encode-decode, etc.) is [MapperIgnoreSource]/
[MapperIgnoreTarget]'d out of the generated method and applied explicitly
by the repository afterward — a with expression after ToDto() for reads,
plain field assignment after ApplyToEntity()/ApplyCreateFieldsToEntity()
for writes. Built-in conversions Mapperly generates natively (string↔enum,
nullable widening) are not "user-implemented mappings" and aren't subject
to this leak.
:::
Always inspect the generated code, not just a green build
A clean compile only proves every member resolved to something — it says nothing about whether that something is correct.
dotnet build -p:EmitCompilerGeneratedFiles=true -p:CompilerGeneratedFilesOutputPath=<dir>
Read the .g.cs output under Riok.Mapperly/Riok.Mapperly.MapperGenerator/.
This caught both shipped bugs above, plus several would-be bugs during
authoring (a forgotten per-method ignore letting a field leak into
ApplyCreateFieldsToEntity that should have stayed Update-only; an explicit
rename plus Mapperly's own same-name auto-match both firing for the same
target field).
Other gotchas, documented per-mapper where they applied
- A DTO must be a
record(not a plainclass) for thewith-expression fixup pattern to work —CityDto/CountryDto/QueueSettingsDto/RedisSettingsDtowere converted from classes specifically for this, a safe no-behavior-change conversion since they were already init-only. - Renaming a nullable source onto a
requirednon-nullable target member can't be[MapperIgnoreTarget]'d (CS9035— the generated initializer would omit a value the language demands) and defaults to throwing on null, not defaulting.ThrowOnMappingNullMismatch = falsefixes the throw, but the field still can't be ignored, so it can't go through the samewith-fixup pattern as everything else (seeCityMapper.cs). PropertyNameMappingStrategy.CaseInsensitiveresolves true case-only differences (UpdtSkuref1vsUpdtSkuRef1) but not abbreviation differences (DfltvsDefault,FrmRefvsCFrmRef16sourced from a different field) — those still need an explicit[MapProperty].
Not every repository fits this pattern — deliberate exceptions
TrCodeRepository— raw SQL (ExecuteSqlRawAsync/ExecuteUpdateAsync) with no tracked entity to map onto, because of confirmed schema drift.UserAuthRepository— itsUserAuthRecordis built from five independent sources (the user row, a lockout computation, role names, group extras, an equipment-session lookup), not a 1:1 entity↔DTO shape.ScenarioRepository.Map— flattens two EF navigation properties and projects two child collections into positional-record DTOs.GroupRoleAssignmentRepository/RoleMenuGrantRepository/UserMenuOverrideRepository/EosActionCatalogRepository/EosTriggerRepository— read-only mappers only; their write paths take scalar parameters or a DI-registered catalog, never a DTO.
Read-only mapper batch, and repositories deliberately skipped
A second pass (2026-08-10) added read-only mappers to the remaining
StoredProcedure/Hybrid/unmarked read-only repositories
(AddressType, AttachType, ChoiceType, Status, Attachment,
EdiFile, MasterOrderPackageLine/Terms/CaseRange/Unit,
TpAuthConfig, WarehouseOrder header+line — the largest by field count at
~200, but the simplest by shape, and EmailAccountRepository).
Deliberately left with no dedicated mapper — not an oversight — where a
mapper class would be more ceremony than the one-liner it replaces:
every repository whose DTO is the shared generic LookupItemDto(Code, Name)
(Carrier, Currency, Forwarder, Incoterm, ModeOfTransport,
OrderProfile, Route, ServiceLevel, ServiceType, Supplier,
Terminal, Vessel), plus ClientBranchRepository (single-bool-field
settings DTO), LabelTranslationRepository (multi-entity coalesce, no 1:1
source), OrderTypeRepository (same LookupItemDto shape, or a raw
SqlDataReader bit-flag read with no backing entity), TerritoryAccessRepository
(no DTO at all, returns raw ID sets), and MasterOrderRepository's Stats
builder (20+ independent cross-table queries).
Reference files
ClientWriteMapper.cs (the canonical worked example and fullest statement of
the Use= rule), MenuMapper.cs/UserMapper.cs (the two confirmed-shipped
bugs and their fix), CityMapper.cs (the required-member/
ThrowOnMappingNullMismatch interaction), AddressWriteMapper.cs (a second,
independently-confirmed Use= leak on a repo with genuinely divergent
Create/Update logic).