Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ LiveKit OSS + Coturn, Kafka + kafka-ui, Vault, Dapr sidecar + placement, APISIX
`.env.example` single source of truth, `.githooks/pre-commit` formatter,
`infra/compose/e2e.yml` ephemeral overlay, `.github/workflows/ci.yml`, and
`scripts/seed.sh`. See [docs/roadmap/phase-01-repository-tooling.md](docs/roadmap/phase-01-repository-tooling.md)
for the per-packet history. Phase 02a (Platform Kernel + Multi-Tenancy) has
kicked off; see [Phase 02a Status & Packets](docs/roadmap/phase-02a-kernel-tenancy.md)
for the 11-packet breakdown. Packet 0 — Kickoff has shipped; Packet 1 —
Foundation decisions (ADR-0023 / ADR-0024 / ADR-0028 to Accepted) is next.
for the per-packet history. Phase 02a (Platform Kernel + Multi-Tenancy) is
underway; see [Phase 02a Status & Packets](docs/roadmap/phase-02a-kernel-tenancy.md)
for the 11-packet breakdown. Packets 0 (Kickoff) and 1 (Foundation decisions
— [ADR-0023](docs/decisions/0023-strongly-typed-id-source-generator.md) Vogen,
[ADR-0024](docs/decisions/0024-api-versioning-policy.md) API versioning,
[ADR-0028](docs/decisions/0028-audit-log-partition-management.md) audit
partition mgmt) have shipped; Packet 2 — Shared Kernel core is next.
Phase 02c (Hub Foundation, parallel, separate repo) starts once the 02a
sockets it depends on are in place.

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/0002-initial-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ pre-implementation, so the migration drag is zero):
2. **PostgreSQL major pinned to 18.x** per
[ADR-0031](0031-postgresql-major-version.md). 18 is the longest-
runway LTS available (EOL 2030-11), brings native `gen_uuid_v7()`
that the [ADR-0023 draft](README.md#open-adr-drafts) can adopt
that [ADR-0023](0023-strongly-typed-id-source-generator.md) adopts
without an extension, and async I/O for sequential scans helps the
partitioned `audit_log`
([ADR-0016](0016-audit-log-subsystem.md)) operator queries. RLS
Expand Down
13 changes: 10 additions & 3 deletions docs/decisions/0016-audit-log-subsystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ Three blocker-level architecture tests are added in Phase 02:

### Negative

- One more table partition to manage per month (`pg_partman` or custom Hangfire job).
- One more table partition to manage per month (managed by a Hangfire recurring job
per [ADR-0028](0028-audit-log-partition-management.md)).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Multi-entity command JSON can be large (>100 KB for bulk updates); truncation policy
needed for very large operations.
- The PII-redaction obligation (ADR-0026 in Nexora, equivalent rule in LearnStack) applies
Expand All @@ -314,8 +315,14 @@ Three blocker-level architecture tests are added in Phase 02:
`Value` field so JSON is readable.
- The `Changes` JSON shape: array of `{ entityType, entityId, field, old, new }` for
multi-entity commands; flat object `{ field, old, new }` for single-entity commands.
- Retention purge: `LearnStackJob` running monthly, dropping partitions older than the
tenant's configured retention window (default 7y / 2y by operation class).
- Retention follows the split defined in
[ADR-0028](0028-audit-log-partition-management.md):
partition lifecycle (creating monthly partitions, dropping them only at the
platform-max horizon) is owned by the `learnstack:audit:partition-management`
Hangfire job; per-tenant retention enforcement (default 7y / 2y by operation
class) is a separate row-level delete job
(`learnstack:audit:retention-purge`) operating *inside* the still-attached
partitions — never by partition drop.
- PII redaction handler: subscribes to `UserGdprDeletedIntegrationEvent`, runs a
parameterised UPDATE on audit rows containing the matching user reference, replaces PII
fields with `[REDACTED]` placeholder constant.
Expand Down
258 changes: 258 additions & 0 deletions docs/decisions/0023-strongly-typed-id-source-generator.md
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).
Loading
Loading