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
25 changes: 14 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,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 +42,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-0009). 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 +100,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 +125,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) |
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />

<!-- ServiceDefaults -->
<!-- Observability + outgoing HTTP -->
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.8.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
Expand Down
4 changes: 1 addition & 3 deletions ProductReviews.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@
<Folder Name="/src/">
<Project Path="src/ProductReviews.Api/ProductReviews.Api.csproj" />
<Project Path="src/ProductReviews.AppHost/ProductReviews.AppHost.csproj" />
<Project Path="src/ProductReviews.Domain/ProductReviews.Domain.csproj" />
<Project Path="src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ProductReviews.Api.IntegrationTests/ProductReviews.Api.IntegrationTests.csproj" />
<Project Path="tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj" />
<Project Path="tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj" />
</Folder>
</Solution>
12 changes: 6 additions & 6 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 Down
2 changes: 1 addition & 1 deletion docs/adr/0001-vertical-slices-and-project-layout.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0001 — Vertical feature slices in two projects; controllers, no MediatR

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

## Context

Expand Down
51 changes: 51 additions & 0 deletions docs/adr/0009-single-api-project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR-0009 — One API project owns the domain; a feature folder owns everything

**Status:** Accepted (2026-07-16) — supersedes [ADR-0001](0001-vertical-slices-and-project-layout.md)

## Context

ADR-0001 split the backend into `ProductReviews.Api` and `ProductReviews.Domain`
(plus a `ServiceDefaults` library), using the project reference to enforce
"domain never sees HTTP". In practice the split fought the vertical slices it
was meant to serve: every feature lived in two half-folders — entities and
handlers in one project, controller and DTOs in another — so the slice that
owned a feature was always missing its key domain objects. For a kick-start
template, two extra projects are ceremony that buries the StrongTypes story.

## Decision

One backend project, `ProductReviews.Api`, next to the AppHost:

- **`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.
- **`Infrastructure/`** holds one file per cross-cutting concern — including
`Observability.cs` (OpenTelemetry) and `Health.cs` (health checks), which
absorbed everything `ProductReviews.ServiceDefaults` used to provide. That
project is gone.

Everything else in ADR-0001 carries forward unchanged: organized by feature
rather than layer, controllers not minimal APIs, no MediatR, DTOs as a
dedicated API-layer contract, proof-of-loading read models.

## Consequences

- A feature is one folder; deleting a feature is deleting one folder plus its
EF configuration. The slice is complete — nothing about a feature lives at
arm's length.
- "Domain never sees HTTP" is now a review rule, not a compiler rule: entities
and handlers must not touch `HttpContext`, action results, or DTOs. The
DTO mapping still happens only in controllers.
- Unit tests reference the API project directly
(`tests/ProductReviews.Api.UnitTests`, formerly `ProductReviews.Domain.Tests`).

## Alternatives considered

- **Keeping the two-project split** — rejected: the compiler-enforced edge
protected a boundary that review can hold, at the cost of scattering every
slice across projects.
- **Architecture tests to re-enforce the boundary** — rejected: an extra
harness is exactly the ceremony this ADR removes; the DTO rule plus review
suffice at this scale.
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ never by editing an old one.

| # | Decision | Status |
| ---- | ------------------------------------------------------------------------------------------ | -------- |
| 0001 | [Vertical feature slices in two projects; controllers, no MediatR](0001-vertical-slices-and-project-layout.md) | Accepted |
| 0001 | [Vertical feature slices in two projects; controllers, no MediatR](0001-vertical-slices-and-project-layout.md) | Superseded by 0009 |
| 0002 | [Validation lives in the type system, not in annotations or guards](0002-validation-lives-in-the-type-system.md) | Accepted |
| 0003 | [Business failures are enum values in `Result`, not exceptions](0003-business-errors-are-result-enums.md) | Accepted |
| 0004 | [The OpenAPI document is the frontend contract](0004-openapi-is-the-frontend-contract.md) | Accepted |
| 0005 | [Auth is Zitadel OIDC + PKCE; author identity is a hash of `sub`](0005-auth-zitadel-oidc-pkce.md) | Accepted |
| 0006 | [Seed data runs at startup, never inside migrations](0006-seeding-at-startup-not-migrations.md) | Accepted |
| 0007 | [Tests exercise real dependencies; nothing is mocked](0007-tests-use-real-dependencies.md) | Accepted |
| 0008 | [The frontend is a Vue 3 + Vite SPA, not a server-rendered app](0008-frontend-vue-spa.md) | Accepted |
| 0009 | [One API project owns the domain; a feature folder owns everything](0009-single-api-project.md) | Accepted |
55 changes: 28 additions & 27 deletions docs/technical-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@ flowchart LR
end
subgraph Aspire AppHost
FE[Vite dev server<br/>proxies /api]
API[ProductReviews.Api<br/>controllers + DTOs]
DOM[ProductReviews.Domain<br/>entities + handlers + EF Core]
API[ProductReviews.Api<br/>feature slices: controllers + DTOs<br/>+ entities + handlers + EF Core]
PG[(PostgreSQL)]
ZIT[Zitadel<br/>OIDC provider]
end
SPA -->|/api/*| FE -->|proxy| API --> DOM --> PG
SPA -->|/api/*| FE -->|proxy| API --> PG
SPA -->|OIDC redirect + token| ZIT
API -->|JWKS / discovery| ZIT
ZIT --> PG
Expand All @@ -52,22 +51,17 @@ flowchart LR
├── docs/ # this spec: requirements + ADRs
├── src/
│ ├── ProductReviews.AppHost/ # Aspire orchestration + Zitadel provisioning
│ ├── ProductReviews.ServiceDefaults/ # OTel, health checks, resilience (own namespace)
│ ├── ProductReviews.Api/
│ │ ├── Features/
│ │ │ ├── Catalog/ # controller + response DTOs + mapping
│ │ │ ├── Reviews/ # controller + request/response DTOs + mapping
│ │ │ ├── Votes/
│ │ │ └── Profile/ # GET /api/me
│ │ ├── Infrastructure/ # one concern per file (§9)
│ │ └── Program.cs # thin orchestrator, calls the slices
│ └── ProductReviews.Domain/
│ ├── Catalog/ # Product entity + catalog queries
│ ├── Reviews/ # Review, Rating, submit/edit/delete handlers
│ ├── Votes/ # ReviewVote + cast/remove handlers
│ └── Persistence/ # DbContext, configurations, migrations, seeding
│ └── ProductReviews.Api/ # the whole backend (ADR-0009)
│ ├── Features/ # a folder owns its entire slice
│ │ ├── Catalog/ # Product entity + queries + controller + DTOs
│ │ ├── Reviews/ # Review, Rating, handlers + controller + DTOs
│ │ ├── Votes/ # ReviewVote + cast/remove + controller + DTOs
│ │ └── Profile/ # GET /api/me
│ ├── Persistence/ # DbContext, configurations, migrations, seeding
│ ├── Infrastructure/ # one concern per file (§9)
│ └── Program.cs # thin orchestrator, calls the slices
├── tests/
│ ├── ProductReviews.Domain.Tests/ # unit + FsCheck property tests
│ ├── ProductReviews.Api.UnitTests/ # unit + FsCheck property tests
│ └── ProductReviews.Api.IntegrationTests/ # Testcontainers Postgres, wire-level
├── frontend/ # Vue 3 + Vite SPA
│ ├── src/api/ # generated schema types + typed client
Expand All @@ -82,8 +76,9 @@ flowchart LR

Each of these is an ADR; the numbered file is the authority:

- Feature slices in two projects, controllers, no MediatR, DTO layer owned by
the API, proof-of-loading read models — [ADR-0001](adr/0001-vertical-slices-and-project-layout.md)
- Feature slices in one API project, controllers, no MediatR, DTO layer owned
by the API, proof-of-loading read models — [ADR-0009](adr/0009-single-api-project.md)
(supersedes [ADR-0001](adr/0001-vertical-slices-and-project-layout.md))
- Strong types are the only validation; no annotations, no guards — [ADR-0002](adr/0002-validation-lives-in-the-type-system.md)
- `Result<T, TError>` + error enums; controllers map to HTTP — [ADR-0003](adr/0003-business-errors-are-result-enums.md)
- OpenAPI is the frontend contract; client generated + drift-checked — [ADR-0004](adr/0004-openapi-is-the-frontend-contract.md)
Expand Down Expand Up @@ -155,7 +150,7 @@ wrappers with the same three-line recipe the library uses internally.

## 5. Domain operations

One file per operation in `ProductReviews.Domain`, named `<Verb><Noun>.cs`,
One file per operation in its feature folder, named `<Verb><Noun>.cs`,
containing the handler class and, when the operation can fail, its error enum
(ADR-0003):

Expand Down Expand Up @@ -256,11 +251,17 @@ class with `Configure(builder)` and, where middleware exists, `Use(app)`:
| `OpenApi.cs` | Swagger UI + `AddStrongTypes()` + `Rating` schema mapping |
| `RateLimits.cs` | Fixed-window limiter on write endpoints |
| `ErrorHandling.cs` | Consistent RFC 7807 output for unhandled failures |
| `Observability.cs` | Health checks (`/health`, `/alive`, DbContext check) |

OpenTelemetry, service discovery, and HTTP resilience come from
`ProductReviews.ServiceDefaults` — which lives in its **own** namespace
(`ProductReviews.ServiceDefaults`), not a hijacked framework namespace.
| `Observability.cs` | OpenTelemetry logging/metrics/tracing, OTLP export |
| `Health.cs` | Health checks and the `/health` + `/alive` endpoints |

Health follows the house pattern: `/alive` is liveness only — the
commit-hash check (`version`, from the assembly's `SourceRevisionId`) and no
dependencies, so a shared Postgres or Zitadel outage never reads as a dead
process. `/health` is full readiness: the EF Core `DbContext` check
(`database`) plus every transitive dependency (`zitadel`, probing the OIDC
discovery document — Degraded, not Unhealthy, because reads survive an
identity-provider outage). Both endpoints answer `"<status> <commit>"`, so a
deploy gate can grep the running build.

## 10. Local development & orchestration

Expand All @@ -280,7 +281,7 @@ OpenTelemetry, service discovery, and HTTP resilience come from

| Suite | Project / tool | What it proves |
| --- | --- | --- |
| Domain unit | `ProductReviews.Domain.Tests` (xUnit) | Entity behavior and invariants — pure, no I/O |
| Domain unit | `ProductReviews.Api.UnitTests` (xUnit) | Entity behavior and invariants — pure, no I/O |
| Property | same project, FsCheck + `Kalicz.StrongTypes.FsCheck` | Invariants hold across generated strong-typed inputs (score arithmetic, edit semantics, rating bounds) |
| API integration | `ProductReviews.Api.IntegrationTests` (xUnit, Testcontainers) | Wire-level behavior against real Postgres: contracts, constraints, error mapping, OpenAPI content |
| Frontend unit | Vitest + Vue Test Utils | Component logic; PATCH body construction (three-state semantics) |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using ProductReviews.Domain.Catalog;
using StrongTypes;

namespace ProductReviews.Api.Features.Catalog;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProductReviews.Api.Infrastructure;
using ProductReviews.Domain.Catalog;
using StrongTypes;

namespace ProductReviews.Api.Features.Catalog;
Expand Down
Loading