Architecture Decision Record
ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs
Status
Accepted (2026-07-15). Revised 2026-07-21 (corrected the empty-placeholder-folder inventory and the
Directory.Build.props and ADC User source citations). Revised 2026-07-28 (refreshed the Common
Directory.Build.props alias-block line range and corrected the framing of what Common's
UserIdentifierType alias types). Revised 2026-07-29 (dropped the StronglyTypedIds placeholder-folder
evidence and deleted the folders themselves: being empty, they were untracked by git and absent from
every fresh clone, so the deferral now rests on the verifiable absence of any wrapper-struct type).
Revised 2026-08-14 (Conference's alias file now declares sixteen aliases, SponsorIdentifierType having
been added, and the ADC User source citations were re-anchored after an expanded doc comment).
Revised 2026-08-23 (Conference's alias file now declares seventeen aliases, ActivityIdentifierType
having been added, and the workspace alias count was recounted; see the Revision (2026-08-23) at the end).
Revised 2026-09-11 (recount): Store Catalog's alias file now declares six aliases, the Reviews aggregate
having added ProductReviewIdentifierType and VerifiedPurchaseIdentifierType, which took the workspace
total to 46 aliases across 10 files; three Decision citations were re-anchored after expanded doc comments
moved them. See the Revision (2026-09-11) at the end.
Revised 2026-09-19 (recount): Conference's alias file now declares nineteen aliases,
SessionAiScoreIdentifierType and SessionAssetIdentifierType having been added, so the workspace total is
48 aliases across 10 files and there are now two Guid-backed aliases rather than one. The Context
and the Decision's last bullet were also corrected: concrete wrapper-struct identifiers now exist in
Common's test tree, so what is zero is adoption in the four Source trees, not existence. See the Revision
(2026-09-19) at the end.
Revised 2026-10-01 (the Trade-offs entry on identifier swapping now names both Guid-backed aliases, and
the Conference and ADC/Store Directory.Build.props citations were re-anchored; see the Revision
(2026-10-01) at the end).
Revised 2026-10-06: the Status block now lists the 2026-10-01 revision, and the Common
Directory.Build.props and AuthenticationServiceBase citations were re-anchored; see the Revision
(2026-10-06) at the end.
Revisited by ADR-085 (2026-08-18): the wrapper-struct
alternative this record deferred was re-evaluated, priced, and deferred again, now against named
revisit triggers instead of open-endedly. The decision below is unchanged; see the Revision
(2026-08-18) at the end.
Revisited again by
ADR-115 (2026-09-09): the wrapper struct becomes a
shipped opt-in framework capability, while the aliases below stay the default, nothing migrates and
adoption is zero by design. The decision here is unchanged.
Context
Every entity needs an identity type. The framework's base entity is generic over that type:
BaseEntity<TIdentifierType> constrains it to notnull and exposes a single required init Id
(Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:34-37), over the equally generic
IBaseEntity<TIdentifierType> (Source/Core/MMCA.Common.Domain/Interfaces/IBaseEntity.cs:7-11). That
generic parameter accepts either of the two common identity styles:
- A primitive (
int,Guid) named through a per-entity alias (UserIdentifierType = int). - A strongly-typed wrapper struct (
readonly record struct UserId(int Value)) as tactical DDD prescribes, to make identifiers non-interchangeable at compile time.
The codebase chose the first, but until now that choice lived only as a CLAUDE.md convention with no
recorded trade-off. The wrapper-struct alternative was considered and left unadopted: no entity in any
of the four Source trees is identified by a wrapper struct. The type itself is no longer absent from
the workspace, since ADR-115 shipped the opt-in machinery in
Common (MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/IStronglyTypedId.cs:60) and the concrete
wrapper structs that pin its contract live in Common's test tree
(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/TestIdentifiers.cs:10). This ADR records the
primitive-alias decision and its cost so the deferral is deliberate and legible.
Decision
Model every identifier as a primitive named through a global-using alias, declared per module, not as a wrapper struct.
- Identity is a primitive behind an alias. Each module declares
global using {Entity}IdentifierType = <primitive>;in its.Sharedproject. Common declaresUserIdentifierType = intfor itself (Source/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1), with the push-notification aliases alongside it (Source/Core/MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs:1-2). Common owns noUseraggregate of its own: that alias types the audit fields every auditable entity inherits,CreatedByandLastModifiedByonAuditableBaseEntity(Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:27,31), and constrains the user type parameter ofAuthenticationServiceBase<TUser>(where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IAuthUser,Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:72), with each consuming app supplying the concreteUserentity that satisfies it. Consumers follow the same pattern: ADC Identity (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2), ADC Conference with nineteen aliases (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7-25), and Store Catalog with six (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/MMCA.Store.Catalog.GlobalUsings.IdentifierType.cs:3-8). - The alias is the type; there is no wrapping struct. The right-hand side is a bare primitive.
Most resolve to
int; the two deviations are both in Conference, and the file header comment records a distinct reason for each.SpeakerIdentifierType = System.Guidis aGuidbecause Sessionize assigns speakers GUIDs while its other imported entities carry integer IDs (MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:1-4), andSessionAssetIdentifierType = System.Guidis a server-minted GUID because the id is a path segment of the public blob name and so has to be unguessable rather than sequential (same file, lines 4-5). - Aliases are linked solution-wide via
Directory.Build.props. EachGlobalUsings.*.csfile is pulled into every project with a<Compile Include ... Link=... />block, so the alias is visible everywhere without a project reference: Common (MMCA.Common/Directory.Build.props:131-141), ADC (MMCA.ADC/Directory.Build.props:125-139), Store (MMCA.Store/Directory.Build.props:129-140). Adding a solution-wide alias is a newGlobalUsings.*.csplus a matching<Compile Include>line, nothing more. - The alias flows unchanged through every layer. Tracing the ADC
Useraggregate: the domain entity isUser : AuditableAggregateRootEntity<UserIdentifierType>(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:34); the cross-context reference to a speaker is typedSpeakerIdentifierType? LinkedSpeakerId(same file, line 78); the EF configuration isEntityTypeConfigurationSQLServer<User, UserIdentifierType>(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:13); the repository handle isGetRepository<User, UserIdentifierType>()(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:44); the API contract isUserDTO : IBaseDTO<UserIdentifierType>with aUserIdentifierType Id(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDTO.cs:8,11); and the integration event carriesUserIdentifierType UserId(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:37). No converter, serializer shim, or OpenAPI schema mapping appears at any hop:intandGuidare the values on the wire and in the store. - The wrapper-struct alternative is deliberately deferred, not planned. No entity in any of the
four
Sourcetrees is identified by a wrapper struct. The framework does ship the opt-in machinery (ADR-115,MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/IStronglyTypedId.cs:60), and the only concrete wrapper structs in the workspace are the test fixtures that pin its contract (MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/TestIdentifiers.cs:10). Adoption is zero by design: a considered option left unbuilt, not scheduled work.
Rationale
- Readable signatures at zero runtime cost.
GetRepository<User, UserIdentifierType>()reads as intent while the CLR sees a plainint. There is no allocation, boxing, or wrapper indirection per identifier. - No persistence or transport friction.
intandGuidare natively understood by EF Core, the SQL provider,System.Text.Json, and the OpenAPI schema generator, so an identifier needs no value converter, noJsonConverter, and no schema customization anywhere in the trace above. A wrapper struct would demand one (or several) at every one of those boundaries. - The generic base infrastructure already carries the type parameter. Because
BaseEntity<TId>,IBaseEntity<TId>, the repository handle,IBaseDTO<TId>, the per-entityIEntityDTOMapper<...,TId>(ADR-001), and the generic entity query surface (ADR-034) are all parameterized by the identifier type, the alias slots in with no extra plumbing. - Per-module declaration mirrors the ownership boundary. Each module declaring its own aliases in
its
.Sharedproject keeps identity definitions with the module that owns them, consistent with database-per-service ownership (ADR-006).
Trade-offs
- No compile-time protection against swapping same-typed identifiers. An alias is a type synonym,
not a distinct type. Because most aliases resolve to
int, the compiler will not stop code from passing aSessionIdentifierTypewhere aUserIdentifierTypeis expected. The twoGuid-backed aliases,SessionAssetIdentifierTypeandSpeakerIdentifierType(MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:17,23), are guarded against theintaliases only incidentally, because their underlying type differs, not by design, and nothing stops one being passed for the other. This is the exact safety a wrapper struct would buy and that this decision forgoes. - The alias is documentation-strength, erased at compile time. Reflection, tooling, serialized
payloads, and the OpenAPI document all see
int/Guid; the alias name never survives the build. - Convention, not a build-gated invariant. Unlike many framework rules that are enforced by
fitness functions (ADR-015), nothing gates that a new entity uses an alias rather than a bare
int, or that the alias is declared in the right.Sharedproject. It rests on author discipline and review. - Revisiting the trade would be a broad change. Adopting wrapper structs later would touch every
generic call site plus add converters and JSON/OpenAPI handling, which is precisely the friction
being avoided. Each module's
.Sharedproject is where the wrappers would live if that cost were ever judged worth paying.
Related
ADR-001 (the per-entity DTO mappers are parameterized by this identifier type, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>), ADR-034 (the generic entity controllers and query contract ride on the same identifier type parameter), ADR-006 (aliases are declared per module in the module's own .Shared project, matching database-per-service ownership), ADR-015 (the contrast: this convention is not fitness-enforced, unlike the invariants that gate the build), ADR-085 (the 2026-08-18 revisit of the wrapper-struct deferral recorded in the Trade-offs below: same decision, now with a price and named triggers), ADR-068 (the deliberate opposite case, where domain values do get wrapper types).
Revision (2026-08-18)
No decision, no behavior and no citation in this record changed. What changed is the standing of the deferral it records.
The last Trade-offs entry above ("Revisiting the trade would be a broad change") described the
wrapper-struct migration as expensive without ever measuring it, and the Decision's last bullet left
the alternative "deliberately deferred, not planned" with no condition that would re-open it.
ADR-085 closes both gaps: it counts the aliases (the current
census is the Revision (2026-09-11) below), counts the migration surface in occurrences of the alias token
across the .cs/.razor files of the four Source trees, tests excluded and TIdentifierType generic
parameters not counted (ADR-085 carries that figure),
names the concrete failure the deferral leaves open with a live example (ADC's CheckIn constructor,
which takes two different UserIdentifierType arguments that can be transposed silently), and records
three triggers that would re-open the question: a production defect traced to an identifier
transposition, a greenfield fifth consumer, or a materially growing cross-module identifier reference
graph.
Read the two records together as one position: this record is the decision and its evidence, ADR-085 is its price and its expiry condition.
Revision (2026-08-23)
No decision and no rationale changed. Two counts did, both because Conference gained an alias. The workspace census below is superseded by the Revision (2026-09-11), and Conference's seventeen aliases are superseded by the Revision (2026-09-19): Conference now declares nineteen.
Conference's alias file declares seventeen aliases, ActivityIdentifierType = int having been
added at the head of the list
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5-21),
which also moved SpeakerIdentifierType = System.Guid down to line 19 (same file). The Decision
section above is re-anchored to both.
Recounting the alias files across the four repositories gives 44 aliases across 10 files: Common
Domain 1 (MMCA.Common/Source/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1) and Common
Shared 2 (MMCA.Common/Source/Core/MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs:1-2);
ADC Notification 2
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/MMCA.ADC.Notification.GlobalUsings.IdentifierType.cs:1-2),
Identity 1
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2),
Engagement 10
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:4-13)
and Conference 17; Store Catalog 4
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/MMCA.Store.Catalog.GlobalUsings.IdentifierType.cs:3-6),
Identity 2
(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Shared/MMCA.Store.Identity.GlobalUsings.IdentifierType.cs:3-4)
and Sales 3
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/MMCA.Store.Sales.GlobalUsings.IdentifierType.cs:5-7);
and Helpdesk Tickets 2
(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Shared/MMCA.Helpdesk.Tickets.GlobalUsings.IdentifierType.cs:6,8).
43 of the 44 resolve to int; SpeakerIdentifierType remains the single Guid. The count in the
Revision (2026-08-18) above is refreshed to those numbers, and ADR-085 carries the same pair.
Revision (2026-09-11)
No decision and no rationale changed. The workspace alias count did, because Store Catalog gained the Reviews aggregate's two identifiers. The census in this revision is itself superseded by the Revision (2026-09-19) below, which is the current one; Catalog's six aliases stand.
Catalog's alias file declares six aliases, ProductReviewIdentifierType = int and
VerifiedPurchaseIdentifierType = int joining the original four
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/MMCA.Store.Catalog.GlobalUsings.IdentifierType.cs:3-8).
The Decision section above is re-anchored to that range.
Recounting every GlobalUsings.*IdentifierType.cs file in the four repositories, one
global using X = <primitive>; line counting as one alias, gives 46 aliases across 10 files: Common
Domain 1 (MMCA.Common/Source/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1) and Common
Shared 2 (MMCA.Common/Source/Core/MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs:1-2);
ADC Notification 2
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/MMCA.ADC.Notification.GlobalUsings.IdentifierType.cs:1-2),
Identity 1
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2),
Engagement 10
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:4-13)
and Conference 17
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5-21);
Store Catalog 6, Identity 2
(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Shared/MMCA.Store.Identity.GlobalUsings.IdentifierType.cs:3-4)
and Sales 3
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/MMCA.Store.Sales.GlobalUsings.IdentifierType.cs:5-7);
and Helpdesk Tickets 2
(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Shared/MMCA.Helpdesk.Tickets.GlobalUsings.IdentifierType.cs:6,8).
45 of the 46 resolve to int; SpeakerIdentifierType = System.Guid remains the single Guid. ADR-085
carries the same pair.
Three Decision citations moved without their text changing, each pushed down by an expanded XML doc
comment: the AuthenticationServiceBase<TUser> constraint is at
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:85, ADC's
SpeakerIdentifierType? LinkedSpeakerId at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:78,
and the UserIdentifierType UserId record parameter at
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:37.
Revision (2026-09-19)
No decision and no rationale changed. Three facts stated above did, two of them counts and one of them a claim about what exists.
Conference's alias file declares nineteen aliases, SessionAiScoreIdentifierType = int and
SessionAssetIdentifierType = System.Guid having been added
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7-25).
SessionAssetIdentifierType is the second Guid-backed alias in the workspace, for a reason of its own
rather than Sessionize's: the id is a path segment of the public blob name, so it has to be unguessable
rather than sequential (same file, lines 4-5). The Decision section above is re-anchored to both.
Recounting every GlobalUsings.*IdentifierType.cs file in the four repositories, one
global using X = <primitive>; line counting as one alias, gives 48 aliases across 10 files: Common
Domain 1 (MMCA.Common/Source/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1) and Common
Shared 2 (MMCA.Common/Source/Core/MMCA.Common.Shared/GlobalUsings.NotificationIdentifierType.cs:1-2);
ADC Notification 2
(MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/MMCA.ADC.Notification.GlobalUsings.IdentifierType.cs:1-2),
Identity 1
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2),
Engagement 10
(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:4-13)
and Conference 19; Store Catalog 6
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/MMCA.Store.Catalog.GlobalUsings.IdentifierType.cs:3-8),
Identity 2
(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Shared/MMCA.Store.Identity.GlobalUsings.IdentifierType.cs:3-4)
and Sales 3
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/MMCA.Store.Sales.GlobalUsings.IdentifierType.cs:5-7);
and Helpdesk Tickets 2
(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Shared/MMCA.Helpdesk.Tickets.GlobalUsings.IdentifierType.cs:6,8).
46 of the 48 resolve to int, and the two Guid aliases are both in Conference:
SessionAssetIdentifierType (file line 17) and SpeakerIdentifierType (file line 23). The counts in the
Revisions (2026-08-23) and (2026-09-11) above are superseded by these, and ADR-085 carries the same pair.
The Context and the Decision's last bullet claimed that no wrapper-struct identifier type existed anywhere
in the workspace. That is no longer true, and both passages now say what is: the framework ships the
opt-in wrapper-struct machinery
(MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/IStronglyTypedId.cs:60) under
ADR-115, and concrete wrapper structs exist as the test
fixtures that pin its contract, five in Common's Shared tests
(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/TestIdentifiers.cs:10,17,24,31,38) plus the
architecture-rule fixtures
(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/StronglyTypedIdFixtures/StronglyTypedIdFixtures.cs:17).
What is zero is adoption: no entity in any of the four Source trees is identified by a wrapper struct,
and no Source type in ADC, Store or Helpdesk references IStronglyTypedId at all. The decision this
record makes is unchanged by that.
Revision (2026-10-01)
No decision and no rationale changed. One Trade-offs statement and three Decision citations did.
The Trade-offs entry on identifier swapping still treated SpeakerIdentifierType as the only
Guid-backed alias. Conference declares two, SessionAssetIdentifierType and SpeakerIdentifierType
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:17,23),
so the incidental guard holds only against the int aliases and the two Guid aliases can be
transposed silently; the entry now says so.
The Decision's Conference citation is re-anchored to the alias lines
(MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7-25, the nineteen aliases below a six-line header
comment), which the Revision (2026-09-19) reported but had not applied. The Directory.Build.props alias
link blocks moved without their content changing: ADC is at MMCA.ADC/Directory.Build.props:125-139 and
Store at MMCA.Store/Directory.Build.props:129-140.
Revision (2026-10-06)
No decision and no rationale changed.
- The Status block omitted the Revision (2026-10-01); it is now listed there.
- The Common
Directory.Build.propsalias link blocks moved without their content changing and are atMMCA.Common/Directory.Build.props:131-141, the Common counterpart the Revision (2026-10-01) left stale when it re-anchored ADC and Store. - The
AuthenticationServiceBase<TUser>constraint is unchanged and now sits atMMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:72; the Decision is re-anchored (the:85recorded in the Revision (2026-09-11) stays as recorded on that date). - Every other anchor in the Context and Decision sections was re-verified against current source and holds.