Skip to main content

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 plain class) for the with-expression fixup pattern to work — CityDto/CountryDto/QueueSettingsDto/ RedisSettingsDto were converted from classes specifically for this, a safe no-behavior-change conversion since they were already init-only.
  • Renaming a nullable source onto a required non-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 = false fixes the throw, but the field still can't be ignored, so it can't go through the same with-fixup pattern as everything else (see CityMapper.cs).
  • PropertyNameMappingStrategy.CaseInsensitive resolves true case-only differences (UpdtSkuref1 vs UpdtSkuRef1) but not abbreviation differences (Dflt vs Default, FrmRef vs CFrmRef16 sourced 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 — its UserAuthRecord is 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).