Skip to content

Commit aabaa2f

Browse files
authored
Merge pull request #5 from cemililik/feat/phase-02a-packet-1-foundation-decisions
docs(decisions): Phase 02a packet 1 — foundation decisions (ADR 0023/0024/0028)
2 parents 29adb7b + 9df097d commit aabaa2f

11 files changed

Lines changed: 951 additions & 60 deletions

‎README.md‎

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

‎docs/decisions/0002-initial-architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ pre-implementation, so the migration drag is zero):
7676
2. **PostgreSQL major pinned to 18.x** per
7777
[ADR-0031](0031-postgresql-major-version.md). 18 is the longest-
7878
runway LTS available (EOL 2030-11), brings native `gen_uuid_v7()`
79-
that the [ADR-0023 draft](README.md#open-adr-drafts) can adopt
79+
that [ADR-0023](0023-strongly-typed-id-source-generator.md) adopts
8080
without an extension, and async I/O for sequential scans helps the
8181
partitioned `audit_log`
8282
([ADR-0016](0016-audit-log-subsystem.md)) operator queries. RLS

‎docs/decisions/0016-audit-log-subsystem.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,8 @@ Three blocker-level architecture tests are added in Phase 02:
292292

293293
### Negative
294294

295-
- One more table partition to manage per month (`pg_partman` or custom Hangfire job).
295+
- One more table partition to manage per month (managed by a Hangfire recurring job
296+
per [ADR-0028](0028-audit-log-partition-management.md)).
296297
- Multi-entity command JSON can be large (>100 KB for bulk updates); truncation policy
297298
needed for very large operations.
298299
- The PII-redaction obligation (ADR-0026 in Nexora, equivalent rule in LearnStack) applies
@@ -314,8 +315,14 @@ Three blocker-level architecture tests are added in Phase 02:
314315
`Value` field so JSON is readable.
315316
- The `Changes` JSON shape: array of `{ entityType, entityId, field, old, new }` for
316317
multi-entity commands; flat object `{ field, old, new }` for single-entity commands.
317-
- Retention purge: `LearnStackJob` running monthly, dropping partitions older than the
318-
tenant's configured retention window (default 7y / 2y by operation class).
318+
- Retention follows the split defined in
319+
[ADR-0028](0028-audit-log-partition-management.md):
320+
partition lifecycle (creating monthly partitions, dropping them only at the
321+
platform-max horizon) is owned by the `learnstack:audit:partition-management`
322+
Hangfire job; per-tenant retention enforcement (default 7y / 2y by operation
323+
class) is a separate row-level delete job
324+
(`learnstack:audit:retention-purge`) operating *inside* the still-attached
325+
partitions — never by partition drop.
319326
- PII redaction handler: subscribes to `UserGdprDeletedIntegrationEvent`, runs a
320327
parameterised UPDATE on audit rows containing the matching user reference, replaces PII
321328
fields with `[REDACTED]` placeholder constant.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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

Comments
 (0)