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
43 changes: 27 additions & 16 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 @@ -31,7 +39,7 @@ npm --prefix frontend run test:unit # Vitest
npm --prefix frontend run test:e2e # Playwright against the full stack
npm --prefix frontend run typecheck # vue-tsc
npm --prefix frontend run refresh:api # refresh openapi.json + regenerate schema.d.ts (stack must be running)
dotnet ef migrations add <Name> --project src/ProductReviews.Domain --output-dir Persistence/Migrations
dotnet ef migrations add <Name> --project src/ProductReviews.Api --output-dir Persistence/Migrations
```

Demo sign-in: `demo@productreviews.local` / `ProductReviews123!` (second user:
Expand All @@ -42,11 +50,13 @@ restart the AppHost.

## Architecture rules (the non-negotiables)

1. **Vertical slices.** A feature lives in `Features/<Feature>/` (API) and
`<Feature>/` (Domain) — one folder each, everything it owns inside. This
applies to infrastructure too: one concern per file
(`Infrastructure/RateLimits.cs`, `Observability.cs`, …) with
1. **Vertical slices.** A feature lives in `Features/<Feature>/` — one folder,
everything it owns inside: entities, handlers + error enums, controller,
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
results, and DTOs stay in the controller.
2. **DDD for data and logic.** Entities own their behavior
(`Review.ApplyEdit`, `Product.RefreshRatingSummary`); handlers orchestrate,
never property-spray. One handler file per operation, containing its error
Expand Down Expand Up @@ -98,8 +108,9 @@ restart the AppHost.
commit `openapi.json` + `schema.d.ts` together with the API change — the
E2E contract test fails otherwise.
14. **Nothing blanket-global.** `[Authorize]` per action (no
`RequireAuthorization()` on the route table), ServiceDefaults in its own
namespace, no global route middleware in the frontend.
`RequireAuthorization()` on the route table), resilience per HTTP client
(no `ConfigureHttpClientDefaults`), no global route middleware in the
frontend.

## Naming and style

Expand All @@ -122,11 +133,11 @@ restart the AppHost.
| New… | Goes to |
| -------------------------- | ------- |
| Endpoint + DTOs | `src/ProductReviews.Api/Features/<Feature>/` |
| Business operation | `src/ProductReviews.Domain/<Feature>/<Verb><Noun>.cs` (handler + error enum) |
| Entity / value type | `src/ProductReviews.Domain/<Feature>/` |
| EF configuration | `src/ProductReviews.Domain/Persistence/Configurations/` |
| Business operation | `src/ProductReviews.Api/Features/<Feature>/<Verb><Noun>.cs` (handler + error enum) |
| Entity / value type | `src/ProductReviews.Api/Features/<Feature>/` |
| EF configuration | `src/ProductReviews.Api/Persistence/Configurations/` |
| Cross-cutting API concern | `src/ProductReviews.Api/Infrastructure/<Concern>.cs` |
| Orchestration resource | `src/ProductReviews.AppHost/` |
| Page / component | `frontend/src/pages/` / `frontend/src/components/` |
| Domain test | `tests/ProductReviews.Domain.Tests/` (prefer a property test) |
| Domain test | `tests/ProductReviews.Api.UnitTests/` (prefer a property test) |
| API behavior test | `tests/ProductReviews.Api.IntegrationTests/` (wire-level) |
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ Where to click:
| The point | Where |
| --- | --- |
| DTOs with strong types, zero annotations | [ReviewContracts.cs](src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs) |
| `Maybe<T>` three-state PATCH (omit = keep, `{}` = clear, `{"Value": …}` = set) | [EditReviewRequest](src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs), [Review.ApplyEdit](src/ProductReviews.Domain/Reviews/Review.cs), [EditReview.cs](src/ProductReviews.Domain/Reviews/EditReview.cs) |
| `Result<T, TError>` + error enums instead of exceptions | [SubmitReview.cs](src/ProductReviews.Domain/Reviews/SubmitReview.cs) → mapped exhaustively in [ReviewsController.cs](src/ProductReviews.Api/Features/Reviews/ReviewsController.cs) |
| Declaring your **own** strong type (`[NumericWrapper]`) | [Rating.cs](src/ProductReviews.Domain/Reviews/Rating.cs) |
| EF Core storing strong types directly (`UseStrongTypes()`) | [Persistence.cs](src/ProductReviews.Api/Infrastructure/Persistence.cs), [ReviewsDbContext.cs](src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs), the [migration](src/ProductReviews.Domain/Persistence/Migrations/) (plain `varchar`/`int` columns) |
| `Maybe<T>` three-state PATCH (omit = keep, `{}` = clear, `{"Value": …}` = set) | [EditReviewRequest](src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs), [Review.ApplyEdit](src/ProductReviews.Api/Features/Reviews/Review.cs), [EditReview.cs](src/ProductReviews.Api/Features/Reviews/EditReview.cs) |
| `Result<T, TError>` + error enums instead of exceptions | [SubmitReview.cs](src/ProductReviews.Api/Features/Reviews/SubmitReview.cs) → mapped exhaustively in [ReviewsController.cs](src/ProductReviews.Api/Features/Reviews/ReviewsController.cs) |
| Declaring your **own** strong type (`[NumericWrapper]`) | [Rating.cs](src/ProductReviews.Api/Features/Reviews/Rating.cs) |
| EF Core storing strong types directly (`UseStrongTypes()`) | [Persistence.cs](src/ProductReviews.Api/Infrastructure/Persistence.cs), [ReviewsDbContext.cs](src/ProductReviews.Api/Persistence/ReviewsDbContext.cs), the [migration](src/ProductReviews.Api/Persistence/Migrations/) (plain `varchar`/`int` columns) |
| OpenAPI carrying the real constraints (`AddStrongTypes()`) | [OpenApi.cs](src/ProductReviews.Api/Infrastructure/OpenApi.cs), snapshot in [frontend/openapi.json](frontend/openapi.json) |
| Constraints flowing into TypeScript (generated client) | [schema.d.ts](frontend/src/api/schema.d.ts) (generated), [client.ts](frontend/src/api/client.ts), [editReviewBody.ts](frontend/src/api/editReviewBody.ts) |
| Property-based tests with generated strong-typed values | [RatingTests.cs](tests/ProductReviews.Domain.Tests/RatingTests.cs), [ReviewEditTests.cs](tests/ProductReviews.Domain.Tests/ReviewEditTests.cs) |
| Property-based tests with generated strong-typed values | [RatingTests.cs](tests/ProductReviews.Api.UnitTests/RatingTests.cs), [ReviewEditTests.cs](tests/ProductReviews.Api.UnitTests/ReviewEditTests.cs) |
| The OpenAPI claims verified against the running API | [OpenApiDocumentTests.cs](tests/ProductReviews.Api.IntegrationTests/OpenApiDocumentTests.cs) |
| Parse-don't-validate at the query boundary (`Positive<int>` paging, `Rating[]` filters) | [ReviewsController.cs](src/ProductReviews.Api/Features/Reviews/ReviewsController.cs), [GetReviewsPage.cs](src/ProductReviews.Domain/Reviews/GetReviewsPage.cs) |
| Parse-don't-validate at the query boundary (`Positive<int>` paging, `Rating[]` filters) | [ReviewsController.cs](src/ProductReviews.Api/Features/Reviews/ReviewsController.cs), [GetReviewsPage.cs](src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs) |

## Tests

Expand All @@ -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:** Accepted (2026-07-15)
**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
Loading