|
| 1 | +# ADR-0023: Strongly-Typed ID Source Generator — Vogen |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Accepted |
| 6 | + |
| 7 | +**Date:** 2026-05-20 |
| 8 | +**Deciders:** @platform |
| 9 | + |
| 10 | +## Decision Drivers |
| 11 | + |
| 12 | +- **Strongly-typed IDs are a hard requirement.** |
| 13 | + [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) |
| 14 | + forbids raw `Guid` on the public surface of any entity. Every aggregate root and |
| 15 | + cross-module reference uses `record struct CourseId(Guid Value) : IStronglyTypedId<Guid>` |
| 16 | + shape; the shape is fixed, the *emitter* is what this ADR picks. |
| 17 | +- **The emitter has to produce four artefacts per ID type**, not just the struct: |
| 18 | + EF Core value converter, `JsonConverter`, ASP.NET Core minimal-API model binder, and |
| 19 | + OpenAPI schema mapping. Hand-rolling four boilerplate files per ID across ~15 modules |
| 20 | + with multiple aggregates each is a maintenance crime. |
| 21 | +- **Value objects share the same emitter shape.** Standards 02 calls out `Email`, `Slug`, |
| 22 | + `LocaleCode` as value objects with invariants. An emitter that handles *both* IDs and |
| 23 | + value objects with the same pattern is a leverage point; one that handles only IDs |
| 24 | + leaves a second hand-rolled track for value objects. |
| 25 | +- **Pre-implementation phase is the cheapest commit window.** Phase 02a's Packet 2 |
| 26 | + introduces the first `Entity<TId>` and `AuditableEntity<T>` bases, which depend on |
| 27 | + the emitter at compile time. Choosing now means every subsequent packet wires the |
| 28 | + same generator from the first commit; choosing later means a forced re-emit pass |
| 29 | + across every module that already landed an ID type. |
| 30 | +- **Provider lock-in budget is zero on the core paths.** ADR-0014 puts every external |
| 31 | + port behind an interface (`IEventBus`, `ICacheService`, `IStorageProvider`, …). The |
| 32 | + ID emitter is a compile-time build dependency, not a runtime port — it cannot sit |
| 33 | + behind an interface — so the chosen library has to be one we are comfortable depending |
| 34 | + on for the platform's lifetime, or removable with a one-shot find-and-replace if it |
| 35 | + goes unmaintained. |
| 36 | +- **PostgreSQL 18 native `gen_uuid_v7()` is available** ([ADR-0031](0031-postgresql-major-version.md)). |
| 37 | + The emitter has to play well with DB-side `DEFAULT gen_uuid_v7()` as well as |
| 38 | + app-side `Guid.CreateVersion7()` (.NET 9+) — both code paths exist in the codebase |
| 39 | + (DB-side for high-volume audit / outbox tables; app-side for aggregates that need |
| 40 | + the ID before flush). |
| 41 | + |
| 42 | +## Considered Options |
| 43 | + |
| 44 | +1. **Vogen** (chosen). MIT, source generator authored by Steven Giesel; emits a `record |
| 45 | + struct` value object (works for both IDs and richer value objects), plus |
| 46 | + pre-baked `EFCoreValueConverter`, `SystemTextJsonConverter`, `TypeConverter`, |
| 47 | + ASP.NET Core minimal-API model binder, Dapper handler, `INumber<T>`/`IParsable<T>` |
| 48 | + conformance, OpenAPI schema customizer. Annotation-based opt-in: |
| 49 | + `[ValueObject<Guid>(...)]` on a partial record struct. |
| 50 | +2. **StronglyTypedId** (rejected). MIT, Andrew Lock's library; the original entrant |
| 51 | + in this space. Focused exclusively on IDs (no broader value-object support), |
| 52 | + smaller surface, older codebase. Active maintenance but the project's velocity |
| 53 | + has slowed since 2024. |
| 54 | +3. **Custom in-house emitter** (rejected). A Roslyn source generator in |
| 55 | + `backend/analyzers/` emitting the four-artefact set per `[Id]`-marked struct. |
| 56 | + Maximum control, zero third-party trust, full alignment with LearnStack's |
| 57 | + own conventions. |
| 58 | + |
| 59 | +## Decision |
| 60 | + |
| 61 | +LearnStack uses **Vogen** as the source generator for strongly-typed IDs **and** |
| 62 | +value objects. |
| 63 | + |
| 64 | +- Every aggregate's ID type is declared as a partial `record struct` |
| 65 | + annotated with `[ValueObject<Guid>(conversions: Conversions.EfCoreValueConverter | |
| 66 | + Conversions.SystemTextJson | Conversions.TypeConverter)]`. The |
| 67 | + `TypeConverter` flag is what makes ASP.NET Core route-parameter binding |
| 68 | + work — Vogen does not ship a separate `AspNetCoreRouteParameter` flag. |
| 69 | + OpenAPI schema customisation is wired separately (see Implementation Notes |
| 70 | + § OpenAPI). |
| 71 | +- The Vogen **build-time** generator (`Vogen` package) is referenced via |
| 72 | + `PrivateAssets="all"` on **each project that hosts `[ValueObject<...>]` |
| 73 | + declarations** — `LearnStack.SharedKernel` for cross-cutting value objects |
| 74 | + (`Email`, `Slug`, `LocaleCode`, `Money`), and **each |
| 75 | + `LearnStack.Modules.<X>.Domain`** for the module's aggregate-root IDs. The |
| 76 | + reference is centralised via `Directory.Build.props` so adding a new module |
| 77 | + picks the generator up automatically. The **runtime** assembly |
| 78 | + `Vogen.SharedTypes` (which carries the `Conversions` enum and a handful of |
| 79 | + helper types the generated code calls into) flows transitively to |
| 80 | + consumers — this is a small (~10 KB) MIT dependency, not a heavyweight |
| 81 | + runtime. |
| 82 | +- Value objects with invariants (`Email`, `Slug`, `LocaleCode`, `Money`, …) |
| 83 | + follow the same annotation pattern, with a `Validate` static method |
| 84 | + enforcing the invariant. |
| 85 | +- The `IStronglyTypedId<TKey>` marker interface ([Standards 02 |
| 86 | + § Strongly-Typed Identifiers](../standards/02-backend-coding.md)) is |
| 87 | + implemented by every Vogen-emitted ID struct. The interface stays; Vogen |
| 88 | + is just the body. |
| 89 | + |
| 90 | +The choice covers the four-artefact emission requirement, the value-object case, the |
| 91 | +PostgreSQL 18 DB-side UUIDv7 path (Vogen can wrap any `Guid`, including those minted |
| 92 | +by `gen_uuid_v7()`), and a `[Description]`/`[ReadOnly]` annotation surface Roslyn |
| 93 | +analyzers can read for additional compile-time rules. |
| 94 | + |
| 95 | +## Context |
| 96 | + |
| 97 | +### Why Vogen over StronglyTypedId |
| 98 | + |
| 99 | +StronglyTypedId was the field's first mover and would have worked for the ID case. |
| 100 | +Three things pushed the choice: |
| 101 | + |
| 102 | +- **Value-object coverage.** Standards 02 already lists `Email`, `Slug`, `LocaleCode` |
| 103 | + as value objects with invariants. Vogen's `[ValueObject]` annotation generates the |
| 104 | + same emitter set for these as for IDs — one generator covers both surfaces. With |
| 105 | + StronglyTypedId, IDs use the library and value objects use a hand-rolled path, with |
| 106 | + divergent EF/JSON conversion patterns. |
| 107 | +- **OpenAPI schema customizer.** Vogen ships a Swashbuckle / Microsoft.OpenApi schema |
| 108 | + filter that registers the underlying primitive (`format: uuid`) on every emitted |
| 109 | + type. StronglyTypedId requires a manual `MapType<CourseId>(() => new |
| 110 | + OpenApiSchema(...))` per ID type in the OpenAPI setup — across 60+ ID types |
| 111 | + projected for Phase 02a–08, that is a real cost. |
| 112 | +- **Maintenance velocity.** Vogen released 12 versions in 2024 and 3 in early 2025; |
| 113 | + StronglyTypedId's release cadence has slowed. Both are MIT, both are forkable in |
| 114 | + a worst case, but the active-maintenance signal favours Vogen. |
| 115 | + |
| 116 | +### Why not custom |
| 117 | + |
| 118 | +A custom Roslyn source generator would have produced the same artefacts. We |
| 119 | +considered it because: |
| 120 | + |
| 121 | +- The IL it emits is small and well-understood; we could match Vogen's output by hand. |
| 122 | +- We have no external pressure to ship Vogen's "Bogus customization", "Dapper |
| 123 | + handler", or other peripheral surfaces. |
| 124 | +- LearnStack already has a Roslyn analyzer project (`backend/analyzers/` per |
| 125 | + ADR-0032's `LearnStackException-DomainExceptionThrow`); adding one more isn't |
| 126 | + conceptually new. |
| 127 | + |
| 128 | +Three things outweighed the appeal: |
| 129 | + |
| 130 | +- **Vogen has 5+ years of community-found bugs already fixed.** Equality semantics, |
| 131 | + EF Core conversion edge cases (nullable navigation), JSON deserialization of `null` |
| 132 | + vs `0`, OpenAPI schema for nested generic types — Vogen's issue tracker is the |
| 133 | + receipts. Rebuilding that from scratch costs months we should spend on the domain. |
| 134 | +- **The maintenance interface is `git pull`, not "find the file we wrote in |
| 135 | + 2026".** A custom generator is a forever-owned artefact; an MIT package is owned |
| 136 | + externally with a clean exit (fork) if it stalls. |
| 137 | +- **Roslyn source generator design has a steep learning curve.** Generator-author |
| 138 | + experience (incremental generators, attribute discovery, cancellation tokens, |
| 139 | + `IIncrementalGenerator` vs the older `ISourceGenerator`) is non-trivial; the |
| 140 | + library represents real expertise we'd otherwise rediscover. |
| 141 | + |
| 142 | +### What would change our minds |
| 143 | + |
| 144 | +- Vogen license shift away from MIT. |
| 145 | +- Vogen archived / abandoned for > 12 months on .NET 11+ without a community fork |
| 146 | + picking it up. |
| 147 | +- A LearnStack-specific emission requirement Vogen cannot model via a custom |
| 148 | + `Conversions` enum entry or a `[Description]`-style annotation pair — for instance, |
| 149 | + a regulatory ID format we needed to enforce at compile time that Vogen's |
| 150 | + validation hook could not express. |
| 151 | + |
| 152 | +### What we explicitly punted on |
| 153 | + |
| 154 | +- **UUIDv7 source.** Both DB-side (`gen_uuid_v7()`) and app-side |
| 155 | + (`Guid.CreateVersion7()`) are valid; the choice between them is per-aggregate (high- |
| 156 | + volume insert paths like `audit_log` / `outbox_messages` prefer DB-side, aggregates |
| 157 | + that need the ID before flush prefer app-side). This stays a Standards 05 (database) |
| 158 | + micro-rule, not an ADR. |
| 159 | +- **Vogen-emitted type comparison semantics.** Default record-struct equality is |
| 160 | + by-value, which is correct for IDs; we accept Vogen's defaults rather than tuning |
| 161 | + them. |
| 162 | + |
| 163 | +## Consequences |
| 164 | + |
| 165 | +### Positive |
| 166 | + |
| 167 | +- Compile-time strongly-typed IDs across the codebase from the first aggregate |
| 168 | + onwards; no raw-`Guid` slippage at the boundary. |
| 169 | +- EF Core, JSON, OpenAPI, ASP.NET route binding all "just work" per ID type — no |
| 170 | + per-ID boilerplate. |
| 171 | +- Value objects (`Email`, `Slug`, …) reuse the same annotation pattern; one shape |
| 172 | + to teach the team. |
| 173 | +- OpenAPI spec generation knows the underlying primitive; the SDK generator (per |
| 174 | + ADR-0024) emits clean wrapper types for SDK consumers without manual schema |
| 175 | + hints. |
| 176 | +- Roslyn-analyzer-friendly: future architecture tests can read Vogen's |
| 177 | + `[ValueObject]` attribute to enforce "every aggregate root ID uses Vogen, not |
| 178 | + raw `Guid`". |
| 179 | + |
| 180 | +### Negative |
| 181 | + |
| 182 | +- One more compile-time dependency on a third-party generator. Bumping Vogen major |
| 183 | + versions can break emission; we pin the version in `Directory.Packages.props` and |
| 184 | + treat upgrades as deliberate ADR-adjacent changes. |
| 185 | +- Source generators slow incremental build slightly. Measured impact in Phase 02a's |
| 186 | + scaffold is < 200ms on a clean build; reassess if the codebase grows to a point |
| 187 | + where the generator dominates compile time. |
| 188 | +- Diagnostics on emitted code reference Vogen-generated source files — IDE |
| 189 | + "go to definition" lands on generated `obj/` files; team has to know to |
| 190 | + navigate to the partial declaration instead. |
| 191 | + |
| 192 | +### Neutral |
| 193 | + |
| 194 | +- The `IStronglyTypedId<TKey>` interface in `LearnStack.SharedKernel` stays as a |
| 195 | + type-system contract; Vogen-generated structs implement it. |
| 196 | +- Consumer projects gain a small (~10 KB) **runtime** dependency on |
| 197 | + `Vogen.SharedTypes` (which carries the `Conversions` enum and a handful of |
| 198 | + helper types referenced by generated code). The Vogen generator package |
| 199 | + itself (`Vogen`) stays build-time-only via `PrivateAssets="all"`. |
| 200 | + |
| 201 | +## Implementation Notes |
| 202 | + |
| 203 | +- **Package references:** `Directory.Packages.props` pins `<PackageVersion Include="Vogen" Version="..." />`. **Every project that hosts `[ValueObject<>]` declarations** — `LearnStack.SharedKernel` (for cross-cutting value objects: `Email`, `Slug`, `LocaleCode`, `Money`) and **each `LearnStack.Modules.<X>.Domain`** (for its aggregate-root IDs) — adds `<PackageReference Include="Vogen" PrivateAssets="all" />`. A `Directory.Build.props` rule under `backend/src/Modules/` keeps the per-module addition automatic when a new module is scaffolded. Source generators only run on projects that reference the generator package; transitive references do **not** carry the generator (this is a `PrivateAssets="all"` semantics constraint, not a Vogen quirk). |
| 204 | +- **Naming convention (per Standards 02):** ID type names end in `Id` |
| 205 | + (`TenantId`, `OrganizationId`, `CourseId`, …); value object types are named |
| 206 | + for the concept (`Email`, not `EmailValueObject`). |
| 207 | +- **Default conversions enum:** every ID + value object opts into the same |
| 208 | + `Conversions` mask: `EfCoreValueConverter | SystemTextJson | TypeConverter`. The |
| 209 | + `TypeConverter` member carries ASP.NET Core minimal-API + MVC route-parameter |
| 210 | + binding (Vogen does not expose a separate `AspNetCoreRouteParameter` flag). |
| 211 | + A `LearnStack.SharedKernel.VogenDefaults` const captures the mask so the |
| 212 | + annotation reads `[ValueObject<Guid>(LearnStackVogenDefaults.IdMask)]`. |
| 213 | +- **OpenAPI schema mapping:** Vogen does **not** ship a `Conversions.SwaggerSchemaFilter`-style |
| 214 | + flag. Schema customisation is wired one of two ways: (a) an assembly-level |
| 215 | + `[VogenDefaults(openApiSchemaCustomizations: ...)]` attribute in |
| 216 | + `LearnStack.SharedKernel` so every emitted type advertises its primitive |
| 217 | + shape, or (b) a custom `IOpenApiSchemaTransformer` in `LearnStack.Api` |
| 218 | + (Microsoft.AspNetCore.OpenApi) that detects Vogen-generated wrappers via the |
| 219 | + generated `IVogenValueObject<T>`-marker interface and emits the underlying |
| 220 | + primitive (`format: uuid`, `format: int64`, …). Packet 4 picks one when API |
| 221 | + conventions wiring lands; both paths are documented in Vogen's upstream docs. |
| 222 | +- **EF Core registration:** the `EfCoreValueConverter` Vogen flag **generates** |
| 223 | + the converter type per ID; it does not auto-register it. Each module's |
| 224 | + `DbContext.OnConfiguring` (or a shared `IModelCustomizer`) calls |
| 225 | + `configurationBuilder.Properties<TId>().HaveConversion<TId.EfCoreValueConverter>()` |
| 226 | + for each Vogen-emitted ID. A `LearnStack.SharedKernel.Infrastructure` |
| 227 | + helper `ModelConfigurationBuilder.RegisterVogenIds(Assembly[])` reflects |
| 228 | + over the Domain assemblies and applies the registration in one call. |
| 229 | +- **Architecture test (lands in Phase 02a Packet 2):** |
| 230 | + `Aggregate_Roots_Use_StronglyTypedId` — every type implementing |
| 231 | + `IAggregateRoot<TId>` has `TId : IStronglyTypedId<Guid>`, and every such |
| 232 | + `TId` carries `[ValueObject<Guid>]`. Catalogued under |
| 233 | + [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) |
| 234 | + when the first ID lands. |
| 235 | +- **UUIDv7 minting:** |
| 236 | + - DB-side (`gen_uuid_v7()` per ADR-0031) for `audit_log`, `outbox_messages`, |
| 237 | + `inbox_messages`, `idempotency_keys` — high-volume append-only tables. |
| 238 | + - App-side (`Guid.CreateVersion7()`) for aggregates that need the ID before |
| 239 | + `SaveChangesAsync()` to emit domain events / outbox writes referencing the |
| 240 | + new aggregate ID. |
| 241 | +- **PostgreSQL 18 alignment:** Vogen-emitted `Guid` wrapper types are wire-compatible |
| 242 | + with `uuid` columns; the `gen_uuid_v7()` `DEFAULT` is a server-side concern, |
| 243 | + invisible to Vogen. |
| 244 | + |
| 245 | +## Amendments |
| 246 | + |
| 247 | +_(none yet)_ |
| 248 | + |
| 249 | +## References |
| 250 | + |
| 251 | +- [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) |
| 252 | +- [ADR-0031 PostgreSQL — Start on 18.x](0031-postgresql-major-version.md) — native |
| 253 | + UUIDv7 widens the design space; this ADR commits to Vogen-emitted wrappers on |
| 254 | + that primitive. |
| 255 | +- [ADR-0032 Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) |
| 256 | + — establishes the `backend/analyzers/` Roslyn analyzer location; future ID-shape |
| 257 | + analyzers live alongside. |
| 258 | +- [Vogen on GitHub](https://github.com/SteveDunn/Vogen) — upstream project (MIT). |
0 commit comments