Architecture Decision Record
ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)
Status
Accepted (2026-08-18). Revised 2026-08-23 (the alias count and the migration-surface census were
recounted, the census gained a stated methodology, and the CheckIn and generic-parameter citations
were corrected; see the Revision (2026-08-23) at the end). Revised 2026-09-11 (the alias count and the
migration-surface census were re-measured, and the Context bullet asserting that no wrapper-struct
identifier type exists anywhere was rewritten against the framework surface ADR-115 shipped; see the
Revision (2026-09-11) at the end). Revised 2026-09-19 (the alias count and the migration-surface
census were re-measured again, and the non-int alias bullet was rewritten: there are now two Guid
aliases, not one; see the Revision (2026-09-19) at the end).
Revisits ADR-048, which stays
Accepted and unchanged in substance: the aliases remain the identifier model. What changes is the
shape of the deferral. ADR-048 left the wrapper-struct alternative "considered and left unbuilt" with
no condition attached; this record measures what the deferral actually costs today, states the
migration price in numbers, and replaces an open-ended "not now" with named triggers that would
re-open it.
Revisited by
ADR-115 (2026-09-09): none of the three triggers has
fired and this deferral stands. What changed is that the wrapper struct is now a first-class
framework capability, so acting on the greenfield trigger costs one DI call rather than a research
project.
Context
ADR-048 decided that every entity identity is a primitive
named through a per-module global using {Entity}IdentifierType = ... alias, and recorded the cost in
one line of Trade-offs: no compile-time protection against swapping two same-typed identifiers. That
is an honest sentence, and it is also the entire treatment the risk has ever received. A deferral with
no revisit condition is indistinguishable from an oversight a year later, which is the gap this record
closes.
The Section A wave was the moment to ask, because it rewrote a large number of data-access signatures at once (specification-first reads, keyset pagination, projection pushdown; see ADR-055). If a wrapper-struct migration were ever going to ride along with unrelated churn, that was the wave to fold it into. It did not, and this record says why.
Three facts frame the decision, all counted in the four repositories' Source trees on 2026-09-19:
- 48 aliases live in 10 files across the four repos. MMCA.Common declares 3 (
UserIdentifierTypeinSource/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1plus the two push-notification aliases inSource/Core/MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs:1-2); MMCA.ADC declares 32 across Conference (19, alias file:7-25), Engagement (10,:4-13), Identity (1,:2) and Notification (2,:1-2); MMCA.Store declares 11 across Catalog (6,MMCA.Store.Catalog.GlobalUsings.IdentifierType.cs:3-8), Sales (3,:5-7) and Identity (2,:3-4); MMCA.Helpdesk declares 2 in Tickets (MMCA.Helpdesk.Tickets.GlobalUsings.IdentifierType.cs:6,8). - 46 of the 48 resolve to
int. Both exceptions areSystem.Guidand both sit in ADC's Conference module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs):SpeakerIdentifierType(:23), which Sessionize forces, andSessionAssetIdentifierType(:17), whose identifier is a path segment of a public blob name and therefore has to be unguessable rather than sequential (:4-5). So for every practical purpose the whole workspace has one identifier CLR type, and the compiler sees 46 synonyms for it. - The wrapper struct exists as a framework capability and nothing uses it. The generator packages
are still absent (a sweep of the four repositories for
Vogenfinds no package reference, no project file entry, no using), but the wrapper primitives themselves ship in MMCA.Common: theIStronglyTypedId<TSelf, TValue>contract (Identifiers/IStronglyTypedId.cs:60) and theStronglyTypedIdhelper class (Identifiers/StronglyTypedId.cs:19) plus a JSON converter factory, aTypeConverter, a registry and the object-mapper wrap/unwrap pair (Identifiers/StronglyTypedIdMappings.cs:5-7) live inMMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/(six files); the EF value converter lives in Infrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/StronglyTypedIdValueConverter.cs:28, nullable twin:56) and is applied fromApplicationDbContext.ConfigureConventions(Persistence/DbContexts/ApplicationDbContext.cs:402-409). All of it is wired by one DI call,AddStronglyTypedIds(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:366), and shape-checked by a fitness rule,StronglyTypedIdsAreReadonlyRecordStructs(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.StronglyTypedIds.cs:34). That capability is ADR-115, and it is opt-in: no identifier in any of the fourSourcetrees declares a wrapper type today, so the aliases remain the only identifier model in use and this record's deferral is about the default, not about availability.
Decision
Keep the aliases. The wrapper-struct alternative is evaluated in this record, priced, and deferred again, this time against explicit triggers.
The risk is real and it is concentrated at cross-module scalar references
Inside a module an identifier is usually passed straight from a route value into one repository call,
where a transposition has nowhere to hide. The exposure concentrates where a module holds an
identifier it does not own, which is exactly the shape database-per-service
(ADR-006) produces: cross-module references are scalar columns, never
foreign keys, so the type system is the only check there is and the type system is int.
The clearest live instance is ADC's CheckIn aggregate
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:57-64), whose
constructor takes five identifiers, four of them consecutive: UserIdentifierType userId (:58) is
held apart from the run by the CheckInScope scope parameter (:59), and then EventIdentifierType eventId, SessionIdentifierType? sessionId, SponsorIdentifierType? sponsorId and
UserIdentifierType checkedInByUserId follow one another (:60-63). The Create factory carries the
same five identifiers across a wider signature (:89-96) rather than mirroring the order: sponsorId
moves to last and optional (:96) so the scan and manual paths, which can never carry one, stay
unchanged. All five are int or int? at the CLR level, since the four aliases they draw on
(UserIdentifierType, EventIdentifierType, SessionIdentifierType, SponsorIdentifierType) are
every one of them int, and two of the five are the same alias holding two different users (the
attendee and the organizer who scanned the badge). Swapping those two arguments compiles
cleanly, passes every type check, and produces a check-in attributed to the wrong person. A wrapper
struct would have made that line a compiler error. This is the concrete cost, stated once with a real
example rather than as an abstraction.
The evaluated alternative: source-generated wrapper structs
The alternative priced here is the standard one: a readonly record struct UserId(int Value) per
identifier, emitted by a source generator (the pattern the StronglyTypedId and Vogen generators
implement) so the boilerplate is not hand-written, plus an EF Core ValueConverter per type, a
JsonConverter per type, and an OpenAPI schema mapping per type. Modern generators emit all three, so
the objection is not that the wrappers are laborious to author. The objection is the blast radius of
switching.
That radius is measurable, and the measurement only means something with its counting rule stated.
An alias token here is any *IdentifierType token other than the framework's own generic
parameter TIdentifierType, counted in the .cs and .razor files of the four Source trees on
2026-10-01, with tests, bin and obj excluded, one hit per source line that carries at least one
such token. On that rule the aliases appear on 3,869 lines across 1,209 files: 296 in 117 files in
MMCA.Common, 2,282 in 694 files in MMCA.ADC, 1,227 in 367 files in MMCA.Store, and 64 in 31 files in
MMCA.Helpdesk. (Counting every token rather than every line raises the total to 4,099 and leaves the
file count unchanged.) Excluding TIdentifierType is what makes the framework figure honest: 735 of
MMCA.Common's 1,031 IdentifierType lines carry only that generic parameter, which a wrapper
migration re-satisfies with a new type argument rather than rewrites call
site by call site. Every one of the 3,869 is a signature, a property, a generic argument, or a DTO
field that a wrapper migration would have to either change or prove it can leave alone. Because
MMCA.Common is a published package family released in lockstep
(ADR-016), the framework share of that count
is a breaking public-API change that all three consumers must absorb in a single sweep, and the
identifier type is a generic parameter on BaseEntity<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:34), IBaseDTO<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9) and
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:14)
(ADR-001), the repository handles
(ADR-055) and the generic entity query surface
(ADR-034), so the change is not confined to leaf code.
The revisit triggers
The deferral holds until one of these is observed, at which point this record is re-opened rather than re-argued from scratch:
- A production defect traced to an identifier transposition. One is enough. The argument for keeping the aliases rests entirely on the claim that the risk has not materialized; a single confirmed instance retires that claim, and the incident itself supplies the evidence the migration business case needs.
- A greenfield fifth consumer. A new application built on the framework pays none of the migration cost counted above, because it has no existing signatures. If one is started, it is the right place to build wrapper structs first and let the framework's generic parameters carry them, which would also produce the compatibility evidence the four existing repos would need.
- A cross-module identifier count that keeps climbing. The exposure scales with cross-module
scalar references, not with the alias count. If the reference graph grows materially past what
CheckInand its peers represent today, the arithmetic changes even without an incident.
Absent all three, this stays a recorded, priced deferral rather than an open question.
Rationale
- The cost is paid once and the benefit accrues per defect avoided, and the defect count is currently zero. No production incident in any of the four repos has been traced to a swapped identifier. That is not proof of safety, and this record does not claim it is; it is the only evidence available, and it does not support a 1,209-file change.
- A partial migration is worse than either endpoint. Wrapping some identifiers and not others produces a codebase where the absence of a compiler error means nothing, because the reader cannot tell whether a given call site is protected or merely un-migrated. The change is therefore all-or-nothing across four repositories, which is precisely what makes it expensive.
- The friction ADR-048 avoided is still real, not merely historical. ADR-048's central claim was
that
intandGuidneed no converter at any boundary: EF Core, the SQL provider,System.Text.Json, gRPC, and the OpenAPI generator all speak them natively. Nothing since has changed that; the generators reduce the boilerplate but they do not remove the boundary code, they generate it, and generated converters at six boundaries are still six places a subtle bug can live. - The wave that would have carried it declined it deliberately. Section A rewrote the read contract and could have absorbed a wrapper migration into churn the consumers were already going to take. Recording that it was considered and rejected at that moment is more useful than recording the abstract preference again.
- Naming the triggers is the actual deliverable. The alias decision is unchanged; what this record adds is a condition under which it stops being the decision. That is the difference between a deferral and a blind spot.
Trade-offs
- The exposure is unmitigated, not reduced. This record buys no safety whatsoever. Every
transposition ADR-048 could not catch is still uncatchable today, and the
CheckInconstructor above is still a live example of a two-argument swap that compiles. - No detection either. Nothing gates, lints, or tests for a suspicious identifier assignment.
There is no analyzer and no naming convention that a reviewer could mechanically check, and the one
fitness rule (ADR-015) in this area,
StronglyTypedIdsAreReadonlyRecordStructs(ArchitectureRules.StronglyTypedIds.cs:34), constrains the shape of a wrapper type rather than any identifier assignment, so with no wrapper declared it matches nothing. Trigger 1 therefore depends on a production defect being traced to a transposition, and a wrong-user check-in is exactly the kind of defect that gets written off as a scanning mistake instead. - The migration price rises with the codebase. The 3,869 lines counted here are a snapshot and the number only grows. Deferring on cost grounds means the cost argument gets stronger every release, which is the classic shape of a decision that is never revisited on its merits.
- Trigger 3 is not measured. No count of cross-module scalar identifier references is maintained, so "keeps climbing" has no baseline to climb from. It is a qualitative trigger and is recorded as such.
- Ordering conventions carry weight the type system should. With three
intparameters in a row inCreate(CheckIn.cs:92-94) and four in the constructor (:60-63), the discipline that keepsCheckIn.Createcorrect is parameter naming and the seven parameter doc lines atCheckIn.cs:81-87. That is review-strength protection standing in for compile-time protection, which is the same class of dependency ADR-048 already recorded for the alias convention itself.
Related
ADR-048 (the decision this record revisits and upholds; its Status now points here), ADR-068 (the deliberate opposite case: domain values carry invariants and therefore do get wrapper types, which is why identifiers not getting them is a decision rather than an omission), ADR-006 (cross-module references are scalar columns, never foreign keys, which is what concentrates the exposure), ADR-016 (the lockstep release and one-pass consumer sweep any migration would have to run through), ADR-015 (the enforcement machinery that covers neither the alias convention nor identifier transposition), ADR-055 and ADR-034 (the generic surfaces parameterized by the identifier type, and therefore in the migration's blast radius), ADR-115 (the opt-in wrapper-struct capability that makes acting on a trigger a DI call instead of a build).
Revision (2026-08-23)
The decision, the priced alternative, the three triggers and the trade-offs are unchanged. What changed is arithmetic and three citations.
The alias count is 44 across 10 files, 43 of them int. ADC's Conference module gained
ActivityIdentifierType = int at the head of its alias file
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5-21),
taking Conference to seventeen and ADC to thirty. SpeakerIdentifierType = System.Guid (:19 in the
same file) is still the only non-int alias in any of the four repositories.
ADR-048 carries the same pair.
The migration-surface census is restated with its counting rule and re-measured. The previous
figure (3,641 occurrences across 1,016 files) counted the framework's generic parameter
TIdentifierType as if it were an alias, which inflated MMCA.Common's share roughly fourfold: of the
725 IdentifierType hits in MMCA.Common/Source, 538 are the generic parameter and only 187 are
alias tokens. Counting alias tokens only, in .cs and .razor under the four Source trees with
tests, bin and obj excluded, gives 3,192 occurrences across 1,001 files (Common 187/80,
ADC 2,077/613, Store 873/277, Helpdesk 55/31). The conclusion is unmoved: the blast radius is still
a four-repository, thousand-file change, and MMCA.Common's generic parameters are still in it, since
re-satisfying a type parameter with a wrapper struct is a breaking public-API change even where no
call site is edited.
Three citations corrected. ADC's CheckIn constructor takes five identifiers but not five in a
row: CheckInScope scope (CheckIn.cs:59) sits between userId (:58) and the consecutive run of
four (:60-63). The Create factory is not a mirror of the constructor: its signature spans
:89-96 and moves sponsorId to last and optional (:96). And all five of those identifier
parameters are int or int? at the CLR level, not four of the five, which sharpens rather than
softens this record's point: nothing in the CLR distinguishes any one of them from the others.
Finally, the two framework generic surfaces are spelled IBaseDTO<TIdentifierType>
(IBaseDTO.cs:9) and IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>
(IEntityDTOMapper.cs:14); this record previously named parameters (TId, TDTO) that do not
exist.
The Rationale's "no production incident traced to a swapped identifier" stands as written and stays unverified by design: the four repositories keep no incident register, postmortem folder or issue label that would record such a defect, which is exactly why the record already refuses to call it proof of safety.
Revision (2026-09-11)
The decision, the priced alternative, the three triggers and the trade-offs are unchanged. What changed is arithmetic and one Context fact.
The alias count is 46 across the same 10 files, 45 of them int. MMCA.Store now declares 11
rather than 9: Catalog carries six (Category, Product, ProductImage, ProductReview,
ProductVariant, VerifiedPurchase at
MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/MMCA.Store.Catalog.GlobalUsings.IdentifierType.cs:3-8)
alongside Sales (3) and Identity (2). Common (3), ADC (30) and Helpdesk (2) are unmoved, and
SpeakerIdentifierType = System.Guid is still the only non-int alias in any of the four
repositories.
The migration-surface census is re-measured on the same rule, with the rule sharpened to say what
a hit is: one per source line carrying at least one alias token. That gives 3,591 lines across
1,136 files (Common 265/106, ADC 2,077/637, Store 1,185/362, Helpdesk 64/31), against 3,192 across
1,001 files in August. Counting individual tokens instead gives 3,794 over the same 1,136 files. The
framework split moves with it: MMCA.Common/Source carries 988 IdentifierType lines, 723 of them
the generic parameter TIdentifierType, leaving 265 alias-token lines. The conclusion is unmoved and
slightly stronger: the blast radius is still a four-repository, thousand-file change, and it grew by
roughly 13 percent in three weeks, which is the trade-off about the price rising with the codebase
happening in the record's own numbers.
The "no wrapper-struct identifier type exists anywhere" bullet is now false and is rewritten.
ADR-115 (2026-09-09) shipped the wrapper primitives as an
opt-in framework capability in
MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/ (six files), with AddStronglyTypedIds
(DependencyInjection.cs:775) and the StronglyTypedIdsAreReadonlyRecordStructs fitness rule
(ArchitectureRules.StronglyTypedIds.cs:34). What is deferred here is therefore the default, not
the capability: no identifier in the four Source trees declares a wrapper type, no generator package
(Vogen included) is referenced anywhere, and none of the three triggers has fired. The fitness rule
constrains the shape of a wrapper if one is written and gates no identifier assignment, so the
"no detection" trade-off stands as written.
Revision (2026-09-19)
The decision, the priced alternative, the three triggers and the trade-offs are unchanged. What
changed is arithmetic and the non-int bullet.
The alias count is 48 across the same 10 files, 46 of them int. ADC's Conference module gained
two aliases since 2026-09-11, taking Conference to nineteen and ADC to thirty-two; Common (3),
Store (11) and Helpdesk (2) are unmoved. One of the two, SessionAssetIdentifierType = System.Guid
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:17),
is the first non-int alias added since this record was written, so
SpeakerIdentifierType = System.Guid (:23 in the same file) is no longer the only one. Both are
Guid for the same class of reason (an identifier minted outside the database), and neither weakens
the point the count is making: 46 of the 48 are still the same CLR type, so the type system still
distinguishes almost nothing.
The migration-surface census is re-measured on the same rule (one hit per source line carrying at
least one alias token, .cs and .razor under the four Source trees, tests, bin and obj
excluded, the generic parameter TIdentifierType not counted). That gives 3,767 lines across
1,192 files (Common 288/117, ADC 2,227/682, Store 1,188/362, Helpdesk 64/31), against 3,591 across
1,136 files eight days earlier. Counting individual tokens instead gives 3,978 over the same 1,192
files. The framework split moves with it: MMCA.Common/Source carries 1,017 IdentifierType lines,
729 of them the generic parameter, leaving 288 alias-token lines. The trade-off about the price
rising with the codebase keeps documenting itself: the surface grew by roughly 5 percent in eight
days without anyone deciding to grow it.
Revision (2026-10-01)
No decision, trigger, rationale or trade-off changed. The migration-surface census is re-measured on
the same rule: 3,869 lines across 1,209 files (Common 296/117, ADC 2,282/694, Store 1,227/367,
Helpdesk 64/31), 4,099 tokens, and MMCA.Common/Source now carries 1,031 IdentifierType lines, 735
of them the generic parameter alone. The Context bullet on the wrapper capability is corrected in one
detail: the sixth file in MMCA.Common.Shared/Identifiers/ is the object-mapper wrap/unwrap pair
(StronglyTypedIdMappings.cs:5-7), not an EF mapping; the EF value converter lives in Infrastructure
(Persistence/Conversions/StronglyTypedIdValueConverter.cs:28, nullable twin :56) and is applied
from ApplicationDbContext.ConfigureConventions (ApplicationDbContext.cs:402-409). The
AddStronglyTypedIds citation is re-anchored to DependencyInjection.cs:366.