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
20 changes: 14 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,23 @@ governs it:
| ---------------------------------- | ---------- |
| Anything at all | [docs/technical-requirements.md](docs/technical-requirements.md) |
| User-facing behavior | [docs/business-requirements.md](docs/business-requirements.md) |
| A decision that feels reversible | The matching ADR in [docs/adr/](docs/adr/README.md) — supersede, never edit |
| Validation, DTOs, domain types | [ADR-0002](docs/adr/0002-validation-lives-in-the-type-system.md), [ADR-0003](docs/adr/0003-business-errors-are-result-enums.md) |
| API surface / frontend types | [ADR-0004](docs/adr/0004-openapi-is-the-frontend-contract.md) |
| Auth | [ADR-0005](docs/adr/0005-auth-zitadel-oidc-pkce.md) |
| Tests | [ADR-0007](docs/adr/0007-tests-use-real-dependencies.md) |
| A decision that feels reversible | The matching ADR in [docs/adr/](docs/adr/README.md) |
| Validation, DTOs, domain types | [ADR-0002](docs/adr/0002-validation-lives-in-the-type-system.md), [ADR-0001](docs/adr/0001-vertical-slices-and-project-layout.md) |
| API surface / frontend types | [ADR-0002](docs/adr/0002-validation-lives-in-the-type-system.md) |
| Auth | [ADR-0003](docs/adr/0003-auth-zitadel-oidc-pkce.md) |
| Tests | [ADR-0005](docs/adr/0005-tests-use-real-dependencies.md) |

If a change contradicts a doc, change the doc in the same PR — or don't make
the change.

**ADRs are the current design, never a change history.** Every ADR describes
how the application works *now*. Changing or rolling back a decision means
rewriting the matching ADR to the new thinking, in the same PR — git holds
the history. Never mark an ADR superseded, and never add a new ADR for an
aspect an existing one covers; new numbers are only for new aspects. A
rolled-back approach usually earns a line under "Alternatives considered"
or "Previous implementation".

## Commands

```shell
Expand All @@ -44,7 +52,7 @@ restart the AppHost.

1. **Vertical slices.** A feature lives in `Features/<Feature>/` — one folder,
everything it owns inside: entities, handlers + error enums, controller,
DTOs, mapping (ADR-0009). This applies to infrastructure too: one concern
DTOs, mapping (ADR-0001). This applies to infrastructure too: one concern
per file (`Infrastructure/RateLimits.cs`, `Health.cs`, …) with
`Configure(...)`/`Use(...)` statics. No grab-bag `ServiceExtensions`.
Entities and handlers still never touch HTTP — `HttpContext`, action
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ npm --prefix frontend run test:e2e
```

Nothing is mocked anywhere — see
[ADR-0007](docs/adr/0007-tests-use-real-dependencies.md).
[ADR-0005](docs/adr/0005-tests-use-real-dependencies.md).

## The spec

Expand Down
97 changes: 64 additions & 33 deletions docs/adr/0001-vertical-slices-and-project-layout.md
Original file line number Diff line number Diff line change
@@ -1,69 +1,100 @@
# ADR-0001 — Vertical feature slices in two projects; controllers, no MediatR
# ADR-0001 — Vertical feature slices: one API project, controllers, `Result` error enums

**Status:** Superseded by [ADR-0009](0009-single-api-project.md) (2026-07-16)
**Status:** Accepted (2026-07-15, revised 2026-07-16)

## Context

The codebase has two jobs: demonstrate StrongTypes end to end, and serve as a
starter template. Both jobs punish accidental complexity. A classic onion
(`Api` → `Application` → `Domain` → `Infrastructure` + repositories +
MediatR) scatters one feature across four projects and buries the
StrongTypes story under ceremony; a single flat project loses the
compiler-enforced rule that HTTP concerns never leak into business code.
StrongTypes story under ceremony — and even a two-project API/domain split
keeps every slice half-complete, with the feature folder always missing its
key domain objects.

Within a slice, domain operations can fail for legitimate business reasons:
the product does not exist, the reviewer already reviewed it, you cannot vote
on your own review. Modeling those as exceptions hides them from the
compiler — a caller cannot see from the signature which failures exist, and
controllers end up with `try/catch` ladders kept in sync by hand.

## Decision

Two application projects with one compiler-enforced dependency edge, both
One backend project, `ProductReviews.Api` (next to the Aspire AppHost),
organized **by feature, not by layer**:

- **`ProductReviews.Api`** — the HTTP boundary. `Features/<Feature>/` holds
the controller, its request/response DTOs, and the mapping between DTOs and
domain models. `Infrastructure/` holds one file per cross-cutting concern
(`Authentication.cs`, `OpenApi.cs`, `RateLimits.cs`, …), each a static class
with `Configure(...)`/`Use(...)` — `Program.cs` is a thin orchestrator and
there is no grab-bag `ServiceCollectionExtensions`.
- **`ProductReviews.Domain`** — everything else. `<Feature>/` folders hold the
entities and one file per operation (`SubmitReview.cs` = handler class +
its error enum). `Persistence/` holds the `DbContext`, entity
configurations, migrations, and the seeder. EF Core is used directly as a
library — no repository interfaces wrapping it.
- **Controllers, not minimal APIs** — controller classes group a feature's
endpoints, give Swashbuckle first-class metadata, and keep binding,
auth attributes, and `ProducesResponseType` declarations in one reviewable
place per feature.
- **No MediatR / no dispatch layer.** Controllers inject concrete handler
classes. A handler is a class with one public method; DI registration is a
per-project `AddDomain()` extension.
- **`Features/<Feature>/`** holds the whole slice: entities, handlers with
their error enums, the controller, request/response DTOs, and the mapping
between them.
- **`Persistence/`** holds the `DbContext`, entity configurations, migrations,
and the seeder. EF Core is used directly as a library — no repository
interfaces wrapping it.
- **`Infrastructure/`** holds one file per cross-cutting concern
(`Authentication.cs`, `OpenApi.cs`, `RateLimits.cs`, `Observability.cs`,
`Health.cs`, …), each a static class with `Configure(...)`/`Use(...)` —
`Program.cs` is a thin orchestrator and there is no grab-bag
`ServiceCollectionExtensions`.
- **HTTP stays out of the domain by review, not by compiler.** Entities and
handlers never touch `HttpContext`, action results, or DTOs; those live
only in controllers.
- **Handlers return `Result<TSuccess, TError>`, never throw for business
outcomes.** `TError` is a per-operation enum (`SubmitReviewError`,
`CastVoteError`, …); success and error are returned by implicit conversion
(`return review;` / `return SubmitReviewError.ProductNotFound;`).
Exceptions are reserved for bugs and infrastructure faults.
- **Controllers own the HTTP translation.** A controller consumes the result
with `result.Error is { } error` and maps each enum value in an exhaustive
`switch` (no `default` arm — a new enum value must break the build) to the
proper response: `ValidationProblem` with a field-scoped `ModelState` error
for input-shaped failures, `NotFound`/`Forbid`/`Conflict` where those
semantics fit.
- **DTOs are a dedicated API-layer contract.** Domain entities and read
models are never serialized. Handlers return domain models
(`Result<Review, SubmitReviewError>`); the controller maps success models
to response DTOs and error enums to HTTP — both directions live in the API
slice.
models are never serialized. Handlers return domain models; the controller
maps success models to response DTOs and error enums to HTTP.
- **Read paths return proof-of-loading models, not entities.** A query
handler owns a single query method with its `Include` chain and projects
into an immutable record whose constructor requires every loaded
relationship (e.g. `ReviewWithViewerContext`). If it isn't loaded, the
relationship (e.g. `ReviewWithVotes`). If it isn't loaded, the
model cannot be constructed — no lazy-loading surprises, no
possibly-null navigations downstream.
- **Controllers, not minimal APIs** — controller classes group a feature's
endpoints, give Swashbuckle first-class metadata, and keep binding,
auth attributes, and `ProducesResponseType` declarations in one reviewable
place per feature.
- **No MediatR / no dispatch layer.** Controllers inject concrete handler
classes. A handler is a class with one public method; DI registration is
`Infrastructure/DomainServices.cs`.

## Consequences

- A feature is one folder in each of two projects; deleting a feature is
deleting two folders. Review diffs stay feature-local.
- The `Api → Domain` project reference makes "domain never sees HTTP"
structural; the missing reverse reference makes "HTTP never bypasses the
domain" reviewable at a glance.
- A feature is one folder; deleting a feature is deleting one folder plus its
EF configuration. Review diffs stay feature-local.
- The set of possible failures of an operation is part of its signature, and
each error enum value appears in exactly one HTTP mapping — the API's error
behavior is reviewable in one file per feature. No `try/catch` in
controllers, no custom exception types in the domain.
- "Domain never sees HTTP" needs review attention: no project edge enforces
it, only the rule that DTO mapping happens exclusively in controllers.
- No runtime dispatch magic: navigation is F12, stack traces are honest, and
the template stays approachable for readers new to the stack.
- The cost of not abstracting EF: swapping the persistence technology means
touching domain handlers. Accepted — the demo's point is EF storing strong
types directly.
- Minor duplication: each feature carries its own small error enum instead of
a shared error catalogue — deliberate, so slices stay independent.

## Alternatives considered

- **Onion/Clean Architecture with repositories** — rejected: doubles the file
count per feature and hides the `UseStrongTypes()` EF integration behind
interfaces, which is the opposite of a showcase.
- **A separate domain project with a compiler-enforced dependency edge** —
tried first and rolled back: every feature lived in two half-folders, the
slice never owned its domain objects, and the edge protected a boundary
that review can hold.
- **Exceptions for business failures** — rejected: invisible in signatures,
and the compiler cannot force a controller to handle a newly added failure
the way an exhaustive `switch` over an enum does.
- **Minimal APIs** — rejected: endpoint metadata and per-route filters get
re-invented per endpoint, and the controller + `ModelState` +
`ValidationProblem` pipeline is exactly what the StrongTypes ASP.NET
Expand Down
52 changes: 38 additions & 14 deletions docs/adr/0002-validation-lives-in-the-type-system.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
# ADR-0002 — Validation lives in the type system, not in annotations or guards
# ADR-0002 — Validation lives in the type system and flows end to end via OpenAPI

**Status:** Accepted (2026-07-15)
**Status:** Accepted (2026-07-15, revised 2026-07-16)

## Context

The same input rule ("rating is 1–5", "title is not blank") tends to get
written three times: as a data annotation on the DTO, as a guard clause in the
controller, and again in the business layer because it can be called from
elsewhere. The three copies drift, and none of them changes the *type* — after
the check passes, the value is still a bare `string` or `int` and every layer
below has to trust or re-check it.
written many times over: as a data annotation on the DTO, as a guard clause in
the controller, again in the business layer, and once more as a hand-written
TypeScript interface that silently drifts from the API. The copies drift, and
none of them changes the *type* — after the check passes, the value is still a
bare `string` or `int` and every layer below has to trust or re-check it.

This application exists to demonstrate the alternative:
[Kalicz.StrongTypes](https://github.com/KaliCZ/StrongTypes) types that make
the invalid state unrepresentable ("parse, don't validate").
the invalid state unrepresentable ("parse, don't validate") — and a pipeline
that carries the constraint to every consumer instead of restating it.

## Decision

Every constrained value is a strong type from the boundary inward, and the
constraint is expressed **nowhere else**:
Every constrained value is a strong type from the boundary inward, the
constraint is expressed **nowhere else**, and every other representation is
**generated** from it:

- Request and response DTOs use `Email`, `NonEmptyString`, `Positive<int>`,
`NonNegative<int>`, `Rating` (our own `[NumericWrapper]` type), … — never a
Expand All @@ -34,18 +36,40 @@ constraint is expressed **nowhere else**:
- Construction follows the library's intent split: `input.AsNonEmpty()`
(nullable result) for external input where invalid means "reject", and
`input.ToNonEmpty()` (throws) for internal values where invalid means "bug".
- The API renders its OpenAPI document via **Swashbuckle** with
`options.AddStrongTypes()` from `Kalicz.StrongTypes.OpenApi.Swashbuckle`, so
the schema carries the real constraints: `Email` → `format: email` +
`maxLength: 254`, `NonEmptyString` → `minLength: 1`, `Positive<int>` →
`minimum: 0, exclusiveMinimum: true`, our `Rating` → `minimum: 1, maximum: 5`.
- **Nullability is truth.** A property is `nullable`/optional in the schema if
and only if it is optional in the C# DTO. Required DTO properties are
non-nullable and `required` in the schema; no information is lost between
C#, the schema, and TypeScript.
- The frontend's client types are **generated** from that document with
`openapi-typescript` and consumed through `openapi-fetch` — no hand-written
request/response types anywhere in the frontend.
- The generated file is committed. A test regenerates it against the running
API and fails on any diff, so contract drift breaks the build instead of
breaking users.
- Rules the type system cannot carry (uniqueness, ownership, cross-entity
rules) are business logic and follow ADR-0003.
rules) are business logic and travel as `Result` error enums (ADR-0001).

## Consequences

- A rule exists in exactly one place — the type — and holds in the API,
the domain, the database, and (via ADR-0004) the generated frontend client.
- A rule exists in exactly one place — the type — and holds in the API, the
domain, the database, the OpenAPI document, and the generated frontend
client: change a DTO and the compiler + the drift test walk the change all
the way into the frontend.
- Method signatures are the documentation: `Task<Result<Review, SubmitReviewError>>
Handle(ProductId, AuthorId, Rating, NonEmptyString title, …)` states every
precondition.
precondition. Frontend code gets compile-time errors for wrong paths, wrong
verbs, missing parameters, and mis-typed bodies.
- Unit tests for "what if the title is empty" disappear; the case does not
type-check. Tests that remain are about behavior.
- Constraints are visible to API consumers in Swagger UI — the demo's payoff.
The OpenAPI document must stay accurate; anything that would degrade it
(untyped `IActionResult`, missing `ProducesResponseType`) is a review
blocker.
- The cost: contributors must learn the small StrongTypes API surface
(`Create` throws / `TryCreate` returns null / `As…`–`To…` extensions), and
LINQ-to-SQL occasionally needs an explicit `.Unwrap()`.
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# ADR-0005 — Auth is Zitadel OIDC + PKCE; author identity is a hash of `sub`
# ADR-0003 — Auth is Zitadel OIDC + PKCE; author identity is a hash of `sub`

**Status:** Accepted (2026-07-15)

## Context

Writing reviews and voting require a signed-in user (business requirement
"Access"), so the demo needs a real identity provider that runs locally with
zero manual setup. The frontend is a browser SPA (ADR-0008) with no backend of
zero manual setup. The frontend is a browser SPA (ADR-0006) with no backend of
its own, so it cannot keep a client secret or host a BFF token exchange.

## Decision
Expand Down
38 changes: 0 additions & 38 deletions docs/adr/0003-business-errors-are-result-enums.md

This file was deleted.

40 changes: 0 additions & 40 deletions docs/adr/0004-openapi-is-the-frontend-contract.md

This file was deleted.

Loading