-
Notifications
You must be signed in to change notification settings - Fork 0
docs(decisions): Phase 02a packet 1 — foundation decisions (ADR 0023/0024/0028) #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
258 changes: 258 additions & 0 deletions
258
docs/decisions/0023-strongly-typed-id-source-generator.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| # ADR-0023: Strongly-Typed ID Source Generator — Vogen | ||
|
|
||
| ## Status | ||
|
|
||
| Accepted | ||
|
|
||
| **Date:** 2026-05-20 | ||
| **Deciders:** @platform | ||
|
|
||
| ## Decision Drivers | ||
|
|
||
| - **Strongly-typed IDs are a hard requirement.** | ||
| [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) | ||
| forbids raw `Guid` on the public surface of any entity. Every aggregate root and | ||
| cross-module reference uses `record struct CourseId(Guid Value) : IStronglyTypedId<Guid>` | ||
| shape; the shape is fixed, the *emitter* is what this ADR picks. | ||
| - **The emitter has to produce four artefacts per ID type**, not just the struct: | ||
| EF Core value converter, `JsonConverter`, ASP.NET Core minimal-API model binder, and | ||
| OpenAPI schema mapping. Hand-rolling four boilerplate files per ID across ~15 modules | ||
| with multiple aggregates each is a maintenance crime. | ||
| - **Value objects share the same emitter shape.** Standards 02 calls out `Email`, `Slug`, | ||
| `LocaleCode` as value objects with invariants. An emitter that handles *both* IDs and | ||
| value objects with the same pattern is a leverage point; one that handles only IDs | ||
| leaves a second hand-rolled track for value objects. | ||
| - **Pre-implementation phase is the cheapest commit window.** Phase 02a's Packet 2 | ||
| introduces the first `Entity<TId>` and `AuditableEntity<T>` bases, which depend on | ||
| the emitter at compile time. Choosing now means every subsequent packet wires the | ||
| same generator from the first commit; choosing later means a forced re-emit pass | ||
| across every module that already landed an ID type. | ||
| - **Provider lock-in budget is zero on the core paths.** ADR-0014 puts every external | ||
| port behind an interface (`IEventBus`, `ICacheService`, `IStorageProvider`, …). The | ||
| ID emitter is a compile-time build dependency, not a runtime port — it cannot sit | ||
| behind an interface — so the chosen library has to be one we are comfortable depending | ||
| on for the platform's lifetime, or removable with a one-shot find-and-replace if it | ||
| goes unmaintained. | ||
| - **PostgreSQL 18 native `gen_uuid_v7()` is available** ([ADR-0031](0031-postgresql-major-version.md)). | ||
| The emitter has to play well with DB-side `DEFAULT gen_uuid_v7()` as well as | ||
| app-side `Guid.CreateVersion7()` (.NET 9+) — both code paths exist in the codebase | ||
| (DB-side for high-volume audit / outbox tables; app-side for aggregates that need | ||
| the ID before flush). | ||
|
|
||
| ## Considered Options | ||
|
|
||
| 1. **Vogen** (chosen). MIT, source generator authored by Steven Giesel; emits a `record | ||
| struct` value object (works for both IDs and richer value objects), plus | ||
| pre-baked `EFCoreValueConverter`, `SystemTextJsonConverter`, `TypeConverter`, | ||
| ASP.NET Core minimal-API model binder, Dapper handler, `INumber<T>`/`IParsable<T>` | ||
| conformance, OpenAPI schema customizer. Annotation-based opt-in: | ||
| `[ValueObject<Guid>(...)]` on a partial record struct. | ||
| 2. **StronglyTypedId** (rejected). MIT, Andrew Lock's library; the original entrant | ||
| in this space. Focused exclusively on IDs (no broader value-object support), | ||
| smaller surface, older codebase. Active maintenance but the project's velocity | ||
| has slowed since 2024. | ||
| 3. **Custom in-house emitter** (rejected). A Roslyn source generator in | ||
| `backend/analyzers/` emitting the four-artefact set per `[Id]`-marked struct. | ||
| Maximum control, zero third-party trust, full alignment with LearnStack's | ||
| own conventions. | ||
|
|
||
| ## Decision | ||
|
|
||
| LearnStack uses **Vogen** as the source generator for strongly-typed IDs **and** | ||
| value objects. | ||
|
|
||
| - Every aggregate's ID type is declared as a partial `record struct` | ||
| annotated with `[ValueObject<Guid>(conversions: Conversions.EfCoreValueConverter | | ||
| Conversions.SystemTextJson | Conversions.TypeConverter)]`. The | ||
| `TypeConverter` flag is what makes ASP.NET Core route-parameter binding | ||
| work — Vogen does not ship a separate `AspNetCoreRouteParameter` flag. | ||
| OpenAPI schema customisation is wired separately (see Implementation Notes | ||
| § OpenAPI). | ||
| - The Vogen **build-time** generator (`Vogen` package) is referenced via | ||
| `PrivateAssets="all"` on **each project that hosts `[ValueObject<...>]` | ||
| declarations** — `LearnStack.SharedKernel` for cross-cutting value objects | ||
| (`Email`, `Slug`, `LocaleCode`, `Money`), and **each | ||
| `LearnStack.Modules.<X>.Domain`** for the module's aggregate-root IDs. The | ||
| reference is centralised via `Directory.Build.props` so adding a new module | ||
| picks the generator up automatically. The **runtime** assembly | ||
| `Vogen.SharedTypes` (which carries the `Conversions` enum and a handful of | ||
| helper types the generated code calls into) flows transitively to | ||
| consumers — this is a small (~10 KB) MIT dependency, not a heavyweight | ||
| runtime. | ||
| - Value objects with invariants (`Email`, `Slug`, `LocaleCode`, `Money`, …) | ||
| follow the same annotation pattern, with a `Validate` static method | ||
| enforcing the invariant. | ||
| - The `IStronglyTypedId<TKey>` marker interface ([Standards 02 | ||
| § Strongly-Typed Identifiers](../standards/02-backend-coding.md)) is | ||
| implemented by every Vogen-emitted ID struct. The interface stays; Vogen | ||
| is just the body. | ||
|
|
||
| The choice covers the four-artefact emission requirement, the value-object case, the | ||
| PostgreSQL 18 DB-side UUIDv7 path (Vogen can wrap any `Guid`, including those minted | ||
| by `gen_uuid_v7()`), and a `[Description]`/`[ReadOnly]` annotation surface Roslyn | ||
| analyzers can read for additional compile-time rules. | ||
|
|
||
| ## Context | ||
|
|
||
| ### Why Vogen over StronglyTypedId | ||
|
|
||
| StronglyTypedId was the field's first mover and would have worked for the ID case. | ||
| Three things pushed the choice: | ||
|
|
||
| - **Value-object coverage.** Standards 02 already lists `Email`, `Slug`, `LocaleCode` | ||
| as value objects with invariants. Vogen's `[ValueObject]` annotation generates the | ||
| same emitter set for these as for IDs — one generator covers both surfaces. With | ||
| StronglyTypedId, IDs use the library and value objects use a hand-rolled path, with | ||
| divergent EF/JSON conversion patterns. | ||
| - **OpenAPI schema customizer.** Vogen ships a Swashbuckle / Microsoft.OpenApi schema | ||
| filter that registers the underlying primitive (`format: uuid`) on every emitted | ||
| type. StronglyTypedId requires a manual `MapType<CourseId>(() => new | ||
| OpenApiSchema(...))` per ID type in the OpenAPI setup — across 60+ ID types | ||
| projected for Phase 02a–08, that is a real cost. | ||
| - **Maintenance velocity.** Vogen released 12 versions in 2024 and 3 in early 2025; | ||
| StronglyTypedId's release cadence has slowed. Both are MIT, both are forkable in | ||
| a worst case, but the active-maintenance signal favours Vogen. | ||
|
|
||
| ### Why not custom | ||
|
|
||
| A custom Roslyn source generator would have produced the same artefacts. We | ||
| considered it because: | ||
|
|
||
| - The IL it emits is small and well-understood; we could match Vogen's output by hand. | ||
| - We have no external pressure to ship Vogen's "Bogus customization", "Dapper | ||
| handler", or other peripheral surfaces. | ||
| - LearnStack already has a Roslyn analyzer project (`backend/analyzers/` per | ||
| ADR-0032's `LearnStackException-DomainExceptionThrow`); adding one more isn't | ||
| conceptually new. | ||
|
|
||
| Three things outweighed the appeal: | ||
|
|
||
| - **Vogen has 5+ years of community-found bugs already fixed.** Equality semantics, | ||
| EF Core conversion edge cases (nullable navigation), JSON deserialization of `null` | ||
| vs `0`, OpenAPI schema for nested generic types — Vogen's issue tracker is the | ||
| receipts. Rebuilding that from scratch costs months we should spend on the domain. | ||
| - **The maintenance interface is `git pull`, not "find the file we wrote in | ||
| 2026".** A custom generator is a forever-owned artefact; an MIT package is owned | ||
| externally with a clean exit (fork) if it stalls. | ||
| - **Roslyn source generator design has a steep learning curve.** Generator-author | ||
| experience (incremental generators, attribute discovery, cancellation tokens, | ||
| `IIncrementalGenerator` vs the older `ISourceGenerator`) is non-trivial; the | ||
| library represents real expertise we'd otherwise rediscover. | ||
|
|
||
| ### What would change our minds | ||
|
|
||
| - Vogen license shift away from MIT. | ||
| - Vogen archived / abandoned for > 12 months on .NET 11+ without a community fork | ||
| picking it up. | ||
| - A LearnStack-specific emission requirement Vogen cannot model via a custom | ||
| `Conversions` enum entry or a `[Description]`-style annotation pair — for instance, | ||
| a regulatory ID format we needed to enforce at compile time that Vogen's | ||
| validation hook could not express. | ||
|
|
||
| ### What we explicitly punted on | ||
|
|
||
| - **UUIDv7 source.** Both DB-side (`gen_uuid_v7()`) and app-side | ||
| (`Guid.CreateVersion7()`) are valid; the choice between them is per-aggregate (high- | ||
| volume insert paths like `audit_log` / `outbox_messages` prefer DB-side, aggregates | ||
| that need the ID before flush prefer app-side). This stays a Standards 05 (database) | ||
| micro-rule, not an ADR. | ||
| - **Vogen-emitted type comparison semantics.** Default record-struct equality is | ||
| by-value, which is correct for IDs; we accept Vogen's defaults rather than tuning | ||
| them. | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
|
|
||
| - Compile-time strongly-typed IDs across the codebase from the first aggregate | ||
| onwards; no raw-`Guid` slippage at the boundary. | ||
| - EF Core, JSON, OpenAPI, ASP.NET route binding all "just work" per ID type — no | ||
| per-ID boilerplate. | ||
| - Value objects (`Email`, `Slug`, …) reuse the same annotation pattern; one shape | ||
| to teach the team. | ||
| - OpenAPI spec generation knows the underlying primitive; the SDK generator (per | ||
| ADR-0024) emits clean wrapper types for SDK consumers without manual schema | ||
| hints. | ||
| - Roslyn-analyzer-friendly: future architecture tests can read Vogen's | ||
| `[ValueObject]` attribute to enforce "every aggregate root ID uses Vogen, not | ||
| raw `Guid`". | ||
|
|
||
| ### Negative | ||
|
|
||
| - One more compile-time dependency on a third-party generator. Bumping Vogen major | ||
| versions can break emission; we pin the version in `Directory.Packages.props` and | ||
| treat upgrades as deliberate ADR-adjacent changes. | ||
| - Source generators slow incremental build slightly. Measured impact in Phase 02a's | ||
| scaffold is < 200ms on a clean build; reassess if the codebase grows to a point | ||
| where the generator dominates compile time. | ||
| - Diagnostics on emitted code reference Vogen-generated source files — IDE | ||
| "go to definition" lands on generated `obj/` files; team has to know to | ||
| navigate to the partial declaration instead. | ||
|
|
||
| ### Neutral | ||
|
|
||
| - The `IStronglyTypedId<TKey>` interface in `LearnStack.SharedKernel` stays as a | ||
| type-system contract; Vogen-generated structs implement it. | ||
| - Consumer projects gain a small (~10 KB) **runtime** dependency on | ||
| `Vogen.SharedTypes` (which carries the `Conversions` enum and a handful of | ||
| helper types referenced by generated code). The Vogen generator package | ||
| itself (`Vogen`) stays build-time-only via `PrivateAssets="all"`. | ||
|
|
||
| ## Implementation Notes | ||
|
|
||
| - **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). | ||
| - **Naming convention (per Standards 02):** ID type names end in `Id` | ||
| (`TenantId`, `OrganizationId`, `CourseId`, …); value object types are named | ||
| for the concept (`Email`, not `EmailValueObject`). | ||
| - **Default conversions enum:** every ID + value object opts into the same | ||
| `Conversions` mask: `EfCoreValueConverter | SystemTextJson | TypeConverter`. The | ||
| `TypeConverter` member carries ASP.NET Core minimal-API + MVC route-parameter | ||
| binding (Vogen does not expose a separate `AspNetCoreRouteParameter` flag). | ||
| A `LearnStack.SharedKernel.VogenDefaults` const captures the mask so the | ||
| annotation reads `[ValueObject<Guid>(LearnStackVogenDefaults.IdMask)]`. | ||
| - **OpenAPI schema mapping:** Vogen does **not** ship a `Conversions.SwaggerSchemaFilter`-style | ||
| flag. Schema customisation is wired one of two ways: (a) an assembly-level | ||
| `[VogenDefaults(openApiSchemaCustomizations: ...)]` attribute in | ||
| `LearnStack.SharedKernel` so every emitted type advertises its primitive | ||
| shape, or (b) a custom `IOpenApiSchemaTransformer` in `LearnStack.Api` | ||
| (Microsoft.AspNetCore.OpenApi) that detects Vogen-generated wrappers via the | ||
| generated `IVogenValueObject<T>`-marker interface and emits the underlying | ||
| primitive (`format: uuid`, `format: int64`, …). Packet 4 picks one when API | ||
| conventions wiring lands; both paths are documented in Vogen's upstream docs. | ||
| - **EF Core registration:** the `EfCoreValueConverter` Vogen flag **generates** | ||
| the converter type per ID; it does not auto-register it. Each module's | ||
| `DbContext.OnConfiguring` (or a shared `IModelCustomizer`) calls | ||
| `configurationBuilder.Properties<TId>().HaveConversion<TId.EfCoreValueConverter>()` | ||
| for each Vogen-emitted ID. A `LearnStack.SharedKernel.Infrastructure` | ||
| helper `ModelConfigurationBuilder.RegisterVogenIds(Assembly[])` reflects | ||
| over the Domain assemblies and applies the registration in one call. | ||
| - **Architecture test (lands in Phase 02a Packet 2):** | ||
| `Aggregate_Roots_Use_StronglyTypedId` — every type implementing | ||
| `IAggregateRoot<TId>` has `TId : IStronglyTypedId<Guid>`, and every such | ||
| `TId` carries `[ValueObject<Guid>]`. Catalogued under | ||
| [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) | ||
| when the first ID lands. | ||
| - **UUIDv7 minting:** | ||
| - DB-side (`gen_uuid_v7()` per ADR-0031) for `audit_log`, `outbox_messages`, | ||
| `inbox_messages`, `idempotency_keys` — high-volume append-only tables. | ||
| - App-side (`Guid.CreateVersion7()`) for aggregates that need the ID before | ||
| `SaveChangesAsync()` to emit domain events / outbox writes referencing the | ||
| new aggregate ID. | ||
| - **PostgreSQL 18 alignment:** Vogen-emitted `Guid` wrapper types are wire-compatible | ||
| with `uuid` columns; the `gen_uuid_v7()` `DEFAULT` is a server-side concern, | ||
| invisible to Vogen. | ||
|
|
||
| ## Amendments | ||
|
|
||
| _(none yet)_ | ||
|
|
||
| ## References | ||
|
|
||
| - [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) | ||
| - [ADR-0031 PostgreSQL — Start on 18.x](0031-postgresql-major-version.md) — native | ||
| UUIDv7 widens the design space; this ADR commits to Vogen-emitted wrappers on | ||
| that primitive. | ||
| - [ADR-0032 Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) | ||
| — establishes the `backend/analyzers/` Roslyn analyzer location; future ID-shape | ||
| analyzers live alongside. | ||
| - [Vogen on GitHub](https://github.com/SteveDunn/Vogen) — upstream project (MIT). |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.