From cf9ccd920c4525af6860d08b7494e1d73262ff02 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 17:19:29 +0200 Subject: [PATCH 1/2] Restructure: one API project owns domain, infrastructure, and health Merge ProductReviews.Domain into ProductReviews.Api so a feature folder owns its whole slice (entities, handlers, controller, DTOs), and fold ProductReviews.ServiceDefaults into Infrastructure/ (Observability.cs = OTel, Health.cs = health checks). Resilience moves onto the userinfo client instead of ConfigureHttpClientDefaults. Health follows the house pattern: /alive = commit-hash check only, no dependencies; /health = EF Core DbContext check + Zitadel discovery probe (Degraded, not Unhealthy). Both answer " " from the assembly SourceRevisionId. Wire-level tests cover both endpoints. Domain.Tests becomes Api.UnitTests. Migration IDs are untouched. Co-Authored-By: Claude Fable 5 --- Directory.Packages.props | 4 +- ProductReviews.slnx | 4 +- .../Features/Catalog/CatalogContracts.cs | 1 - .../Features/Catalog/CatalogController.cs | 1 - .../Features}/Catalog/GetCatalog.cs | 4 +- .../Features}/Catalog/GetProductDetail.cs | 4 +- .../Features}/Catalog/Product.cs | 4 +- .../Features}/Catalog/ProductWithReviews.cs | 4 +- .../Features}/Reviews/DeleteReview.cs | 4 +- .../Features}/Reviews/EditReview.cs | 4 +- .../Features}/Reviews/GetReviewsPage.cs | 4 +- .../Features}/Reviews/Rating.cs | 2 +- .../Features}/Reviews/Review.cs | 4 +- .../Features}/Reviews/ReviewAuthor.cs | 2 +- .../Features/Reviews/ReviewContracts.cs | 1 - .../Features}/Reviews/ReviewSort.cs | 2 +- .../Features/Reviews/ReviewsController.cs | 1 - .../Features}/Reviews/SubmitReview.cs | 4 +- .../Features}/Votes/CastVote.cs | 4 +- .../Features}/Votes/RemoveVote.cs | 4 +- .../Features}/Votes/ReviewVote.cs | 2 +- .../Features}/Votes/ReviewWithVotes.cs | 4 +- .../Features/Votes/VoteContracts.cs | 1 - .../Features}/Votes/VoteSummary.cs | 2 +- .../Features/Votes/VotesController.cs | 1 - .../Infrastructure/AppVersion.cs | 18 +++ .../Infrastructure/Authentication.cs | 3 +- .../Infrastructure/CommitHashHealthCheck.cs | 12 ++ .../Infrastructure/CurrentUser.cs | 2 +- .../Infrastructure}/DomainServices.cs | 9 +- .../Infrastructure/Health.cs | 45 ++++++++ .../Infrastructure/Observability.cs | 34 +++++- .../Infrastructure/OpenApi.cs | 2 +- .../Infrastructure/Persistence.cs | 6 +- .../Infrastructure/ZitadelHealthCheck.cs | 33 ++++++ .../Persistence/CompleteQueries.cs | 6 +- .../Configurations/ProductConfiguration.cs | 4 +- .../Configurations/ReviewConfiguration.cs | 6 +- .../Configurations/ReviewVoteConfiguration.cs | 6 +- .../Persistence/Migrations/.editorconfig | 0 .../20260715061407_InitialCreate.Designer.cs | 24 ++-- .../20260715061407_InitialCreate.cs | 4 +- .../ReviewsDbContextModelSnapshot.cs | 24 ++-- .../Persistence/ReviewsDbContext.cs | 8 +- .../ReviewsDbContextDesignTimeFactory.cs | 2 +- .../Persistence/Seeding/DemoCatalog.cs | 2 +- .../Persistence/Seeding/DemoDataSeeder.cs | 8 +- .../ProductReviews.Api.csproj | 21 +++- src/ProductReviews.Api/Program.cs | 9 +- .../ProductReviews.Domain.csproj | 16 --- .../Extensions.cs | 106 ------------------ .../ProductReviews.ServiceDefaults.csproj | 19 ---- .../HealthApiTests.cs | 33 ++++++ .../DomainGenerators.cs | 11 +- .../ProductRatingSummaryTests.cs | 6 +- .../ProductReviews.Api.UnitTests.csproj} | 2 +- .../RatingTests.cs | 4 +- .../ReviewEditTests.cs | 4 +- .../ReviewTestData.cs | 4 +- .../ScoreTests.cs | 4 +- 60 files changed, 298 insertions(+), 271 deletions(-) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Catalog/GetCatalog.cs (80%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Catalog/GetProductDetail.cs (93%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Catalog/Product.cs (94%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Catalog/ProductWithReviews.cs (86%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/DeleteReview.cs (93%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/EditReview.cs (95%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/GetReviewsPage.cs (97%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/Rating.cs (98%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/Review.cs (97%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/ReviewAuthor.cs (87%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/ReviewSort.cs (66%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Reviews/SubmitReview.cs (94%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Votes/CastVote.cs (94%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Votes/RemoveVote.cs (93%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Votes/ReviewVote.cs (94%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Votes/ReviewWithVotes.cs (85%) rename src/{ProductReviews.Domain => ProductReviews.Api/Features}/Votes/VoteSummary.cs (82%) create mode 100644 src/ProductReviews.Api/Infrastructure/AppVersion.cs create mode 100644 src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs rename src/{ProductReviews.Domain => ProductReviews.Api/Infrastructure}/DomainServices.cs (78%) create mode 100644 src/ProductReviews.Api/Infrastructure/Health.cs create mode 100644 src/ProductReviews.Api/Infrastructure/ZitadelHealthCheck.cs rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/CompleteQueries.cs (92%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Configurations/ProductConfiguration.cs (87%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Configurations/ReviewConfiguration.cs (92%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Configurations/ReviewVoteConfiguration.cs (78%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Migrations/.editorconfig (100%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs (87%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Migrations/20260715061407_InitialCreate.cs (98%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs (86%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/ReviewsDbContext.cs (84%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/ReviewsDbContextDesignTimeFactory.cs (93%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Seeding/DemoCatalog.cs (99%) rename src/{ProductReviews.Domain => ProductReviews.Api}/Persistence/Seeding/DemoDataSeeder.cs (94%) delete mode 100644 src/ProductReviews.Domain/ProductReviews.Domain.csproj delete mode 100644 src/ProductReviews.ServiceDefaults/Extensions.cs delete mode 100644 src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj create mode 100644 tests/ProductReviews.Api.IntegrationTests/HealthApiTests.cs rename tests/{ProductReviews.Domain.Tests => ProductReviews.Api.UnitTests}/DomainGenerators.cs (74%) rename tests/{ProductReviews.Domain.Tests => ProductReviews.Api.UnitTests}/ProductRatingSummaryTests.cs (92%) rename tests/{ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj => ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj} (83%) rename tests/{ProductReviews.Domain.Tests => ProductReviews.Api.UnitTests}/RatingTests.cs (91%) rename tests/{ProductReviews.Domain.Tests => ProductReviews.Api.UnitTests}/ReviewEditTests.cs (96%) rename tests/{ProductReviews.Domain.Tests => ProductReviews.Api.UnitTests}/ReviewTestData.cs (89%) rename tests/{ProductReviews.Domain.Tests => ProductReviews.Api.UnitTests}/ScoreTests.cs (91%) diff --git a/Directory.Packages.props b/Directory.Packages.props index ee0eeed..000682c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,12 +20,12 @@ + - + - diff --git a/ProductReviews.slnx b/ProductReviews.slnx index 4b02a2a..42a6a7c 100644 --- a/ProductReviews.slnx +++ b/ProductReviews.slnx @@ -2,11 +2,9 @@ - - - + diff --git a/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs b/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs index 42dcc46..eab8f16 100644 --- a/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs +++ b/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs @@ -1,4 +1,3 @@ -using ProductReviews.Domain.Catalog; using StrongTypes; namespace ProductReviews.Api.Features.Catalog; diff --git a/src/ProductReviews.Api/Features/Catalog/CatalogController.cs b/src/ProductReviews.Api/Features/Catalog/CatalogController.cs index 5bf19c3..c707d37 100644 --- a/src/ProductReviews.Api/Features/Catalog/CatalogController.cs +++ b/src/ProductReviews.Api/Features/Catalog/CatalogController.cs @@ -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; diff --git a/src/ProductReviews.Domain/Catalog/GetCatalog.cs b/src/ProductReviews.Api/Features/Catalog/GetCatalog.cs similarity index 80% rename from src/ProductReviews.Domain/Catalog/GetCatalog.cs rename to src/ProductReviews.Api/Features/Catalog/GetCatalog.cs index b5bc1bd..23203d7 100644 --- a/src/ProductReviews.Domain/Catalog/GetCatalog.cs +++ b/src/ProductReviews.Api/Features/Catalog/GetCatalog.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; public sealed class GetCatalogHandler(ReviewsDbContext dbContext) { diff --git a/src/ProductReviews.Domain/Catalog/GetProductDetail.cs b/src/ProductReviews.Api/Features/Catalog/GetProductDetail.cs similarity index 93% rename from src/ProductReviews.Domain/Catalog/GetProductDetail.cs rename to src/ProductReviews.Api/Features/Catalog/GetProductDetail.cs index 823bc20..5476052 100644 --- a/src/ProductReviews.Domain/Catalog/GetProductDetail.cs +++ b/src/ProductReviews.Api/Features/Catalog/GetProductDetail.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; /// ViewerReviewId is the signed-in viewer's own review of this product, when they have one. public sealed record ProductDetailModel(Product Product, Guid? ViewerReviewId); diff --git a/src/ProductReviews.Domain/Catalog/Product.cs b/src/ProductReviews.Api/Features/Catalog/Product.cs similarity index 94% rename from src/ProductReviews.Domain/Catalog/Product.cs rename to src/ProductReviews.Api/Features/Catalog/Product.cs index 7bee746..b118dec 100644 --- a/src/ProductReviews.Domain/Catalog/Product.cs +++ b/src/ProductReviews.Api/Features/Catalog/Product.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; public sealed class Product { diff --git a/src/ProductReviews.Domain/Catalog/ProductWithReviews.cs b/src/ProductReviews.Api/Features/Catalog/ProductWithReviews.cs similarity index 86% rename from src/ProductReviews.Domain/Catalog/ProductWithReviews.cs rename to src/ProductReviews.Api/Features/Catalog/ProductWithReviews.cs index 3a8449d..42cd440 100644 --- a/src/ProductReviews.Domain/Catalog/ProductWithReviews.cs +++ b/src/ProductReviews.Api/Features/Catalog/ProductWithReviews.cs @@ -1,6 +1,6 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; /// Proof-of-loading aggregate: it can only be constructed with the product's /// reviews in hand, so code holding one never meets an unloaded navigation. diff --git a/src/ProductReviews.Domain/Reviews/DeleteReview.cs b/src/ProductReviews.Api/Features/Reviews/DeleteReview.cs similarity index 93% rename from src/ProductReviews.Domain/Reviews/DeleteReview.cs rename to src/ProductReviews.Api/Features/Reviews/DeleteReview.cs index 74343b3..110cfda 100644 --- a/src/ProductReviews.Domain/Reviews/DeleteReview.cs +++ b/src/ProductReviews.Api/Features/Reviews/DeleteReview.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum DeleteReviewError { diff --git a/src/ProductReviews.Domain/Reviews/EditReview.cs b/src/ProductReviews.Api/Features/Reviews/EditReview.cs similarity index 95% rename from src/ProductReviews.Domain/Reviews/EditReview.cs rename to src/ProductReviews.Api/Features/Reviews/EditReview.cs index d18f866..8a07382 100644 --- a/src/ProductReviews.Domain/Reviews/EditReview.cs +++ b/src/ProductReviews.Api/Features/Reviews/EditReview.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum EditReviewError { diff --git a/src/ProductReviews.Domain/Reviews/GetReviewsPage.cs b/src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs similarity index 97% rename from src/ProductReviews.Domain/Reviews/GetReviewsPage.cs rename to src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs index cc64511..b2333ba 100644 --- a/src/ProductReviews.Domain/Reviews/GetReviewsPage.cs +++ b/src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; /// A review as one specific viewer sees it: with whether it is their own /// and which way they voted on it. Constructed only by the page query, so both diff --git a/src/ProductReviews.Domain/Reviews/Rating.cs b/src/ProductReviews.Api/Features/Reviews/Rating.cs similarity index 98% rename from src/ProductReviews.Domain/Reviews/Rating.cs rename to src/ProductReviews.Api/Features/Reviews/Rating.cs index ffc0af9..079522f 100644 --- a/src/ProductReviews.Domain/Reviews/Rating.cs +++ b/src/ProductReviews.Api/Features/Reviews/Rating.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; /// A whole-star product rating, 1–5. Declared with the same three-line /// recipe the library's own numeric wrappers use: [NumericWrapper] plus a diff --git a/src/ProductReviews.Domain/Reviews/Review.cs b/src/ProductReviews.Api/Features/Reviews/Review.cs similarity index 97% rename from src/ProductReviews.Domain/Reviews/Review.cs rename to src/ProductReviews.Api/Features/Reviews/Review.cs index 7024881..7256446 100644 --- a/src/ProductReviews.Domain/Reviews/Review.cs +++ b/src/ProductReviews.Api/Features/Reviews/Review.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Votes; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public sealed class Review { diff --git a/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs b/src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs similarity index 87% rename from src/ProductReviews.Domain/Reviews/ReviewAuthor.cs rename to src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs index fa4800c..1324499 100644 --- a/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs @@ -1,6 +1,6 @@ using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; /// The acting reviewer, as the domain sees them: an opaque stable id /// (derived from the identity provider's subject, see ADR-0005) and the display diff --git a/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs b/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs index 46e9fa8..6114cf9 100644 --- a/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using ProductReviews.Domain.Reviews; using StrongTypes; namespace ProductReviews.Api.Features.Reviews; diff --git a/src/ProductReviews.Domain/Reviews/ReviewSort.cs b/src/ProductReviews.Api/Features/Reviews/ReviewSort.cs similarity index 66% rename from src/ProductReviews.Domain/Reviews/ReviewSort.cs rename to src/ProductReviews.Api/Features/Reviews/ReviewSort.cs index 0669670..0219c5e 100644 --- a/src/ProductReviews.Domain/Reviews/ReviewSort.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewSort.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum ReviewSort { diff --git a/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs b/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs index ace115a..2c88998 100644 --- a/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using ProductReviews.Api.Infrastructure; -using ProductReviews.Domain.Reviews; using StrongTypes; namespace ProductReviews.Api.Features.Reviews; diff --git a/src/ProductReviews.Domain/Reviews/SubmitReview.cs b/src/ProductReviews.Api/Features/Reviews/SubmitReview.cs similarity index 94% rename from src/ProductReviews.Domain/Reviews/SubmitReview.cs rename to src/ProductReviews.Api/Features/Reviews/SubmitReview.cs index c5c1282..558b5aa 100644 --- a/src/ProductReviews.Domain/Reviews/SubmitReview.cs +++ b/src/ProductReviews.Api/Features/Reviews/SubmitReview.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum SubmitReviewError { diff --git a/src/ProductReviews.Domain/Votes/CastVote.cs b/src/ProductReviews.Api/Features/Votes/CastVote.cs similarity index 94% rename from src/ProductReviews.Domain/Votes/CastVote.cs rename to src/ProductReviews.Api/Features/Votes/CastVote.cs index 0789feb..327caf3 100644 --- a/src/ProductReviews.Domain/Votes/CastVote.cs +++ b/src/ProductReviews.Api/Features/Votes/CastVote.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; public enum CastVoteError { diff --git a/src/ProductReviews.Domain/Votes/RemoveVote.cs b/src/ProductReviews.Api/Features/Votes/RemoveVote.cs similarity index 93% rename from src/ProductReviews.Domain/Votes/RemoveVote.cs rename to src/ProductReviews.Api/Features/Votes/RemoveVote.cs index 4aecd73..1cdef4e 100644 --- a/src/ProductReviews.Domain/Votes/RemoveVote.cs +++ b/src/ProductReviews.Api/Features/Votes/RemoveVote.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; public enum RemoveVoteError { diff --git a/src/ProductReviews.Domain/Votes/ReviewVote.cs b/src/ProductReviews.Api/Features/Votes/ReviewVote.cs similarity index 94% rename from src/ProductReviews.Domain/Votes/ReviewVote.cs rename to src/ProductReviews.Api/Features/Votes/ReviewVote.cs index 9d75882..8e2c61a 100644 --- a/src/ProductReviews.Domain/Votes/ReviewVote.cs +++ b/src/ProductReviews.Api/Features/Votes/ReviewVote.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; /// One reviewer's helpful/not-helpful verdict on one review. /// The composite key (ReviewId, VoterId) makes "one vote per reviewer per review" structural. diff --git a/src/ProductReviews.Domain/Votes/ReviewWithVotes.cs b/src/ProductReviews.Api/Features/Votes/ReviewWithVotes.cs similarity index 85% rename from src/ProductReviews.Domain/Votes/ReviewWithVotes.cs rename to src/ProductReviews.Api/Features/Votes/ReviewWithVotes.cs index b396cf2..f9d91f8 100644 --- a/src/ProductReviews.Domain/Votes/ReviewWithVotes.cs +++ b/src/ProductReviews.Api/Features/Votes/ReviewWithVotes.cs @@ -1,6 +1,6 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; /// Proof-of-loading aggregate: a review together with all its votes, so the /// helpfulness score can be recomputed without trusting a lazily-loaded navigation. diff --git a/src/ProductReviews.Api/Features/Votes/VoteContracts.cs b/src/ProductReviews.Api/Features/Votes/VoteContracts.cs index f45fc0f..db9ed4a 100644 --- a/src/ProductReviews.Api/Features/Votes/VoteContracts.cs +++ b/src/ProductReviews.Api/Features/Votes/VoteContracts.cs @@ -1,4 +1,3 @@ -using ProductReviews.Domain.Votes; namespace ProductReviews.Api.Features.Votes; diff --git a/src/ProductReviews.Domain/Votes/VoteSummary.cs b/src/ProductReviews.Api/Features/Votes/VoteSummary.cs similarity index 82% rename from src/ProductReviews.Domain/Votes/VoteSummary.cs rename to src/ProductReviews.Api/Features/Votes/VoteSummary.cs index decf477..49a446b 100644 --- a/src/ProductReviews.Domain/Votes/VoteSummary.cs +++ b/src/ProductReviews.Api/Features/Votes/VoteSummary.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; /// The state a voter sees after a vote operation: the review's recomputed /// score and their own current vote (null = no vote). diff --git a/src/ProductReviews.Api/Features/Votes/VotesController.cs b/src/ProductReviews.Api/Features/Votes/VotesController.cs index 9cf781a..05018cf 100644 --- a/src/ProductReviews.Api/Features/Votes/VotesController.cs +++ b/src/ProductReviews.Api/Features/Votes/VotesController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using ProductReviews.Api.Infrastructure; -using ProductReviews.Domain.Votes; using StrongTypes; namespace ProductReviews.Api.Features.Votes; diff --git a/src/ProductReviews.Api/Infrastructure/AppVersion.cs b/src/ProductReviews.Api/Infrastructure/AppVersion.cs new file mode 100644 index 0000000..f8f3a2f --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/AppVersion.cs @@ -0,0 +1,18 @@ +using System.Reflection; + +namespace ProductReviews.Api.Infrastructure; + +public static class AppVersion +{ + // The SDK embeds the git commit as the SourceRevisionId ("+") when + // building from a checkout; a deploy gate reads it back out of /health. + public static readonly string InformationalVersion = + Assembly.GetEntryAssembly() + ?.GetCustomAttribute() + ?.InformationalVersion ?? "unknown"; + + public static readonly string CommitHash = + InformationalVersion.Contains('+') + ? InformationalVersion[(InformationalVersion.IndexOf('+') + 1)..] + : InformationalVersion; +} diff --git a/src/ProductReviews.Api/Infrastructure/Authentication.cs b/src/ProductReviews.Api/Infrastructure/Authentication.cs index 7c7b800..9511814 100644 --- a/src/ProductReviews.Api/Infrastructure/Authentication.cs +++ b/src/ProductReviews.Api/Infrastructure/Authentication.cs @@ -20,7 +20,8 @@ public static void Configure(WebApplicationBuilder builder) var audience = builder.Configuration["Oidc:Audience"]; builder.Services.AddMemoryCache(); - builder.Services.AddHttpClient(UserinfoClientName); + // Per-client resilience, not ConfigureHttpClientDefaults — nothing blanket-global. + builder.Services.AddHttpClient(UserinfoClientName).AddStandardResilienceHandler(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs b/src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs new file mode 100644 index 0000000..0119f61 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ProductReviews.Api.Infrastructure; + +internal sealed class CommitHashHealthCheck : IHealthCheck +{ + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + => Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, data: new Dictionary + { + ["commit"] = AppVersion.CommitHash, + })); +} diff --git a/src/ProductReviews.Api/Infrastructure/CurrentUser.cs b/src/ProductReviews.Api/Infrastructure/CurrentUser.cs index 722815d..13e003a 100644 --- a/src/ProductReviews.Api/Infrastructure/CurrentUser.cs +++ b/src/ProductReviews.Api/Infrastructure/CurrentUser.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; namespace ProductReviews.Api.Infrastructure; diff --git a/src/ProductReviews.Domain/DomainServices.cs b/src/ProductReviews.Api/Infrastructure/DomainServices.cs similarity index 78% rename from src/ProductReviews.Domain/DomainServices.cs rename to src/ProductReviews.Api/Infrastructure/DomainServices.cs index ff159b3..edc34fe 100644 --- a/src/ProductReviews.Domain/DomainServices.cs +++ b/src/ProductReviews.Api/Infrastructure/DomainServices.cs @@ -1,10 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; -namespace ProductReviews.Domain; +namespace ProductReviews.Api.Infrastructure; public static class DomainServices { diff --git a/src/ProductReviews.Api/Infrastructure/Health.cs b/src/ProductReviews.Api/Infrastructure/Health.cs new file mode 100644 index 0000000..809aab7 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/Health.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ProductReviews.Api.Persistence; + +namespace ProductReviews.Api.Infrastructure; + +/// /alive = process up + which build, no dependencies — a shared Postgres or +/// Zitadel outage must never read as a dead process. /health = full readiness, every +/// dependency included. Both bodies carry the running commit for a deploy gate to grep. +public static class Health +{ + public const string HealthEndpointPath = "/health"; + public const string AlivenessEndpointPath = "/alive"; + + public static void Configure(WebApplicationBuilder builder) + { + builder.Services.AddHttpClient(ZitadelHealthCheck.ClientName, client => client.Timeout = TimeSpan.FromSeconds(5)); + + builder.Services.AddHealthChecks() + .AddCheck("version", tags: ["live"]) + .AddDbContextCheck("database") + .AddCheck("zitadel"); + } + + public static void Use(WebApplication app) + { + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live"), + ResponseWriter = WriteStatusAndCommitAsync, + }); + + app.MapHealthChecks(HealthEndpointPath, new HealthCheckOptions + { + ResponseWriter = WriteStatusAndCommitAsync, + }); + } + + // " " — greppable by a deploy gate, no per-check detail to leak. + private static Task WriteStatusAndCommitAsync(HttpContext context, HealthReport report) + { + context.Response.ContentType = "text/plain"; + return context.Response.WriteAsync($"{report.Status} {AppVersion.CommitHash}", context.RequestAborted); + } +} diff --git a/src/ProductReviews.Api/Infrastructure/Observability.cs b/src/ProductReviews.Api/Infrastructure/Observability.cs index 4797000..dc214af 100644 --- a/src/ProductReviews.Api/Infrastructure/Observability.cs +++ b/src/ProductReviews.Api/Infrastructure/Observability.cs @@ -1,11 +1,37 @@ -using ProductReviews.Domain.Persistence; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; namespace ProductReviews.Api.Infrastructure; -/// API-specific health checks. Tracing, metrics, and the /health endpoints -/// themselves come from ServiceDefaults. +/// OpenTelemetry logging, metrics, and tracing, exported over OTLP when an +/// endpoint is configured (the Aspire dashboard sets one). public static class Observability { public static void Configure(WebApplicationBuilder builder) - => builder.Services.AddHealthChecks().AddDbContextCheck("database"); + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation()) + .WithTracing(tracing => tracing + .AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(options => + options.Filter = context => + !context.Request.Path.StartsWithSegments(Health.HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(Health.AlivenessEndpointPath)) + .AddHttpClientInstrumentation()); + + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + } } diff --git a/src/ProductReviews.Api/Infrastructure/OpenApi.cs b/src/ProductReviews.Api/Infrastructure/OpenApi.cs index 6e2be9e..4b12caa 100644 --- a/src/ProductReviews.Api/Infrastructure/OpenApi.cs +++ b/src/ProductReviews.Api/Infrastructure/OpenApi.cs @@ -1,5 +1,5 @@ using Microsoft.OpenApi; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes.OpenApi.Swashbuckle; namespace ProductReviews.Api.Infrastructure; diff --git a/src/ProductReviews.Api/Infrastructure/Persistence.cs b/src/ProductReviews.Api/Infrastructure/Persistence.cs index ec319e8..4920f60 100644 --- a/src/ProductReviews.Api/Infrastructure/Persistence.cs +++ b/src/ProductReviews.Api/Infrastructure/Persistence.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; -using ProductReviews.Domain.Persistence.Seeding; +using ProductReviews.Api.Persistence; +using ProductReviews.Api.Persistence.Seeding; using StrongTypes.EfCore; namespace ProductReviews.Api.Infrastructure; @@ -13,6 +13,8 @@ public static class Persistence public static void Configure(WebApplicationBuilder builder) => builder.AddNpgsqlDbContext( DatabaseResourceName, + // The explicit "database" check in Health.cs is the one source of DB health. + configureSettings: settings => settings.DisableHealthChecks = true, configureDbContextOptions: options => options.UseStrongTypes()); /// Development-only convenience (ADR-0006): production applies migrations diff --git a/src/ProductReviews.Api/Infrastructure/ZitadelHealthCheck.cs b/src/ProductReviews.Api/Infrastructure/ZitadelHealthCheck.cs new file mode 100644 index 0000000..d1f3ac5 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/ZitadelHealthCheck.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ProductReviews.Api.Infrastructure; + +/// Probes the OIDC discovery document so a dead or misconfigured Zitadel surfaces +/// in /health rather than as sign-in failures. Degraded, not Unhealthy: reads keep working +/// without the identity provider — only sign-in and writes are affected. +internal sealed class ZitadelHealthCheck(IHttpClientFactory httpClientFactory, IConfiguration configuration) : IHealthCheck +{ + public const string ClientName = "zitadel-health"; + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var authority = configuration["Oidc:Authority"]?.TrimEnd('/'); + if (string.IsNullOrEmpty(authority)) + { + return HealthCheckResult.Degraded("Oidc:Authority is not configured; sign-in is unavailable."); + } + + try + { + var client = httpClientFactory.CreateClient(ClientName); + using var response = await client.GetAsync($"{authority}/.well-known/openid-configuration", cancellationToken); + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy() + : HealthCheckResult.Degraded($"OIDC discovery returned {(int)response.StatusCode}."); + } + catch (Exception exception) + { + return HealthCheckResult.Degraded("OIDC authority unreachable.", exception); + } + } +} diff --git a/src/ProductReviews.Domain/Persistence/CompleteQueries.cs b/src/ProductReviews.Api/Persistence/CompleteQueries.cs similarity index 92% rename from src/ProductReviews.Domain/Persistence/CompleteQueries.cs rename to src/ProductReviews.Api/Persistence/CompleteQueries.cs index 4f77977..2009be2 100644 --- a/src/ProductReviews.Domain/Persistence/CompleteQueries.cs +++ b/src/ProductReviews.Api/Persistence/CompleteQueries.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Votes; using StrongTypes; -namespace ProductReviews.Domain.Persistence; +namespace ProductReviews.Api.Persistence; /// The single source of truth for what a "complete" aggregate loads. /// Each method pairs an Include chain with the record that proves it ran. diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs b/src/ProductReviews.Api/Persistence/Configurations/ProductConfiguration.cs similarity index 87% rename from src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs rename to src/ProductReviews.Api/Persistence/Configurations/ProductConfiguration.cs index 7482018..36e77c4 100644 --- a/src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs +++ b/src/ProductReviews.Api/Persistence/Configurations/ProductConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductReviews.Domain.Catalog; +using ProductReviews.Api.Features.Catalog; -namespace ProductReviews.Domain.Persistence.Configurations; +namespace ProductReviews.Api.Persistence.Configurations; internal sealed class ProductConfiguration : IEntityTypeConfiguration { diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs b/src/ProductReviews.Api/Persistence/Configurations/ReviewConfiguration.cs similarity index 92% rename from src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs rename to src/ProductReviews.Api/Persistence/Configurations/ReviewConfiguration.cs index 4818fa7..5a73fae 100644 --- a/src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs +++ b/src/ProductReviews.Api/Persistence/Configurations/ReviewConfiguration.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; -namespace ProductReviews.Domain.Persistence.Configurations; +namespace ProductReviews.Api.Persistence.Configurations; internal sealed class ReviewConfiguration : IEntityTypeConfiguration { diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs b/src/ProductReviews.Api/Persistence/Configurations/ReviewVoteConfiguration.cs similarity index 78% rename from src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs rename to src/ProductReviews.Api/Persistence/Configurations/ReviewVoteConfiguration.cs index 4674243..d59f782 100644 --- a/src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs +++ b/src/ProductReviews.Api/Persistence/Configurations/ReviewVoteConfiguration.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; -namespace ProductReviews.Domain.Persistence.Configurations; +namespace ProductReviews.Api.Persistence.Configurations; internal sealed class ReviewVoteConfiguration : IEntityTypeConfiguration { diff --git a/src/ProductReviews.Domain/Persistence/Migrations/.editorconfig b/src/ProductReviews.Api/Persistence/Migrations/.editorconfig similarity index 100% rename from src/ProductReviews.Domain/Persistence/Migrations/.editorconfig rename to src/ProductReviews.Api/Persistence/Migrations/.editorconfig diff --git a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs similarity index 87% rename from src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs rename to src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs index a32e785..bccfb81 100644 --- a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs +++ b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs @@ -1,15 +1,15 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; #nullable disable -namespace ProductReviews.Domain.Persistence.Migrations +namespace ProductReviews.Api.Persistence.Migrations { [DbContext(typeof(ReviewsDbContext))] [Migration("20260715061407_InitialCreate")] @@ -25,7 +25,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Property("Id") .HasColumnType("bigint"); @@ -66,7 +66,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Products"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Property("Id") .HasColumnType("uuid"); @@ -130,7 +130,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { b.Property("ReviewId") .HasColumnType("uuid"); @@ -149,30 +149,30 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ReviewVotes"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { - b.HasOne("ProductReviews.Domain.Catalog.Product", null) + b.HasOne("ProductReviews.Api.Features.Catalog.Product", null) .WithMany("Reviews") .HasForeignKey("ProductId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { - b.HasOne("ProductReviews.Domain.Reviews.Review", null) + b.HasOne("ProductReviews.Api.Features.Reviews.Review", null) .WithMany("Votes") .HasForeignKey("ReviewId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Navigation("Reviews"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Navigation("Votes"); }); diff --git a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.cs similarity index 98% rename from src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs rename to src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.cs index 2276383..0ddc05c 100644 --- a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs +++ b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.cs @@ -1,9 +1,9 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace ProductReviews.Domain.Persistence.Migrations +namespace ProductReviews.Api.Persistence.Migrations { /// public partial class InitialCreate : Migration diff --git a/src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs b/src/ProductReviews.Api/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs similarity index 86% rename from src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs rename to src/ProductReviews.Api/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs index 44ccd5a..52827d5 100644 --- a/src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs +++ b/src/ProductReviews.Api/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs @@ -1,14 +1,14 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; #nullable disable -namespace ProductReviews.Domain.Persistence.Migrations +namespace ProductReviews.Api.Persistence.Migrations { [DbContext(typeof(ReviewsDbContext))] partial class ReviewsDbContextModelSnapshot : ModelSnapshot @@ -22,7 +22,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Property("Id") .HasColumnType("bigint"); @@ -63,7 +63,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Products"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Property("Id") .HasColumnType("uuid"); @@ -127,7 +127,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { b.Property("ReviewId") .HasColumnType("uuid"); @@ -146,30 +146,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ReviewVotes"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { - b.HasOne("ProductReviews.Domain.Catalog.Product", null) + b.HasOne("ProductReviews.Api.Features.Catalog.Product", null) .WithMany("Reviews") .HasForeignKey("ProductId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { - b.HasOne("ProductReviews.Domain.Reviews.Review", null) + b.HasOne("ProductReviews.Api.Features.Reviews.Review", null) .WithMany("Votes") .HasForeignKey("ReviewId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Navigation("Reviews"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Navigation("Votes"); }); diff --git a/src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs b/src/ProductReviews.Api/Persistence/ReviewsDbContext.cs similarity index 84% rename from src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs rename to src/ProductReviews.Api/Persistence/ReviewsDbContext.cs index 48e24bb..96d8b48 100644 --- a/src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs +++ b/src/ProductReviews.Api/Persistence/ReviewsDbContext.cs @@ -1,10 +1,10 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; using StrongTypes.EfCore; -namespace ProductReviews.Domain.Persistence; +namespace ProductReviews.Api.Persistence; public sealed class ReviewsDbContext(DbContextOptions options) : DbContext(options) { diff --git a/src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs b/src/ProductReviews.Api/Persistence/ReviewsDbContextDesignTimeFactory.cs similarity index 93% rename from src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs rename to src/ProductReviews.Api/Persistence/ReviewsDbContextDesignTimeFactory.cs index 2f8a097..d473949 100644 --- a/src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs +++ b/src/ProductReviews.Api/Persistence/ReviewsDbContextDesignTimeFactory.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore.Design; using StrongTypes.EfCore; -namespace ProductReviews.Domain.Persistence; +namespace ProductReviews.Api.Persistence; /// For `dotnet ef` only. The connection string is never opened during /// `migrations add` — it just satisfies the provider registration. diff --git a/src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs b/src/ProductReviews.Api/Persistence/Seeding/DemoCatalog.cs similarity index 99% rename from src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs rename to src/ProductReviews.Api/Persistence/Seeding/DemoCatalog.cs index 3b82592..a1aecdc 100644 --- a/src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs +++ b/src/ProductReviews.Api/Persistence/Seeding/DemoCatalog.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Persistence.Seeding; +namespace ProductReviews.Api.Persistence.Seeding; internal sealed record SeedAuthor(Guid Id, string Name); diff --git a/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs b/src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs similarity index 94% rename from src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs rename to src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs index 43ab4ed..c41f1d5 100644 --- a/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs +++ b/src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs @@ -1,10 +1,10 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; using StrongTypes; -namespace ProductReviews.Domain.Persistence.Seeding; +namespace ProductReviews.Api.Persistence.Seeding; /// Idempotent demo seeding (ADR-0006): runs after migrations, does nothing /// once products exist, and builds everything through the domain entities so seed diff --git a/src/ProductReviews.Api/ProductReviews.Api.csproj b/src/ProductReviews.Api/ProductReviews.Api.csproj index c54f982..2a65088 100644 --- a/src/ProductReviews.Api/ProductReviews.Api.csproj +++ b/src/ProductReviews.Api/ProductReviews.Api.csproj @@ -2,18 +2,27 @@ + + - + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + - - - - - diff --git a/src/ProductReviews.Api/Program.cs b/src/ProductReviews.Api/Program.cs index 94a0aa2..3ce218c 100644 --- a/src/ProductReviews.Api/Program.cs +++ b/src/ProductReviews.Api/Program.cs @@ -1,18 +1,15 @@ using ProductReviews.Api.Infrastructure; -using ProductReviews.Domain; -using ProductReviews.ServiceDefaults; using StrongTypes.AspNetCore; var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults(); - +Observability.Configure(builder); +Health.Configure(builder); Persistence.Configure(builder); Authentication.Configure(builder); RateLimits.Configure(builder); ErrorHandling.Configure(builder); OpenApi.Configure(builder); -Observability.Configure(builder); builder.Services.AddControllers(); builder.Services.AddStrongTypes(options => options.JsonErrorKeyCasing = JsonErrorKeyCasing.CamelCase); @@ -24,8 +21,8 @@ Authentication.Use(app); RateLimits.Use(app); OpenApi.Use(app); +Health.Use(app); -app.MapDefaultEndpoints(); app.MapControllers(); await Persistence.MigrateAndSeedAsync(app); diff --git a/src/ProductReviews.Domain/ProductReviews.Domain.csproj b/src/ProductReviews.Domain/ProductReviews.Domain.csproj deleted file mode 100644 index ec7702e..0000000 --- a/src/ProductReviews.Domain/ProductReviews.Domain.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - diff --git a/src/ProductReviews.ServiceDefaults/Extensions.cs b/src/ProductReviews.ServiceDefaults/Extensions.cs deleted file mode 100644 index 436f8a8..0000000 --- a/src/ProductReviews.ServiceDefaults/Extensions.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace ProductReviews.ServiceDefaults; - -// Common Aspire service wiring: service discovery, resilience, health checks, -// and OpenTelemetry. Deliberately in its own namespace — consumers opt in with -// an explicit using instead of the extensions leaking through a framework -// namespace. -public static class Extensions -{ - private const string HealthEndpointPath = "/health"; - private const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - http.AddStandardResilienceHandler(); - http.AddServiceDiscovery(); - }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(tracing => - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) - ) - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Health endpoints are Development-only; exposing them publicly has - // security implications — see https://aka.ms/dotnet/aspire/healthchecks. - if (app.Environment.IsDevelopment()) - { - app.MapHealthChecks(HealthEndpointPath); - - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; - } -} diff --git a/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj b/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj deleted file mode 100644 index c567fa5..0000000 --- a/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - true - - - - - - - - - - - - - - - diff --git a/tests/ProductReviews.Api.IntegrationTests/HealthApiTests.cs b/tests/ProductReviews.Api.IntegrationTests/HealthApiTests.cs new file mode 100644 index 0000000..fc6fe42 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/HealthApiTests.cs @@ -0,0 +1,33 @@ +using System.Net; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +[Collection(nameof(SharedApiCollection))] +public sealed class HealthApiTests(SharedApiContext context) +{ + [Fact] + public async Task Alive_ReportsHealthyAndTheRunningCommit_WithoutTouchingDependencies() + { + var response = await context.AnonymousClient.GetAsync("/alive", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + // "Healthy ": liveness carries the build identity but no dependency state — + // Zitadel is not configured here, yet the process still reports alive. + Assert.StartsWith("Healthy ", body); + Assert.False(string.IsNullOrWhiteSpace(body["Healthy ".Length..])); + } + + [Fact] + public async Task Health_ReportsDegradedWhenTheIdentityProviderIsMissing_ButTheDatabaseCheckPasses() + { + var response = await context.AnonymousClient.GetAsync("/health", TestContext.Current.CancellationToken); + + // Degraded (not Unhealthy): the database is reachable and reads keep working; only + // sign-in is unavailable without an OIDC authority. Degraded still returns 200. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.StartsWith("Degraded ", body); + } +} diff --git a/tests/ProductReviews.Domain.Tests/DomainGenerators.cs b/tests/ProductReviews.Api.UnitTests/DomainGenerators.cs similarity index 74% rename from tests/ProductReviews.Domain.Tests/DomainGenerators.cs rename to tests/ProductReviews.Api.UnitTests/DomainGenerators.cs index 416643e..082a056 100644 --- a/tests/ProductReviews.Domain.Tests/DomainGenerators.cs +++ b/tests/ProductReviews.Api.UnitTests/DomainGenerators.cs @@ -1,20 +1,21 @@ using FsCheck; using FsCheck.Fluent; -using ProductReviews.Domain.Reviews; using StrongTypes; // FsCheck ships its own NonEmptyString test wrapper; ours is the StrongTypes one. using NonEmptyString = StrongTypes.NonEmptyString; +// Aliased because the arbitrary property below is itself named Rating. +using RatingValue = ProductReviews.Api.Features.Reviews.Rating; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; /// Project-specific arbitraries. Register together with the library's: /// [Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] /// (StrongTypes.FsCheck.Generators covers NonEmptyString, Maybe, and friends). public static class DomainGenerators { - public static Arbitrary Rating { get; } = - Gen.Choose(Reviews.Rating.MinimumStars, Reviews.Rating.MaximumStars) - .Select(stars => Reviews.Rating.Create(stars)) + public static Arbitrary Rating { get; } = + Gen.Choose(RatingValue.MinimumStars, RatingValue.MaximumStars) + .Select(stars => RatingValue.Create(stars)) .ToArbitrary(); /// The three-state edit parameter: ~30% skip (null), ~20% clear (None), ~50% set. diff --git a/tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs b/tests/ProductReviews.Api.UnitTests/ProductRatingSummaryTests.cs similarity index 92% rename from tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs rename to tests/ProductReviews.Api.UnitTests/ProductRatingSummaryTests.cs index c06dc5c..e774e15 100644 --- a/tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs +++ b/tests/ProductReviews.Api.UnitTests/ProductRatingSummaryTests.cs @@ -1,11 +1,11 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; using StrongTypes; using StrongTypes.FsCheck; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; [Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] public sealed class ProductRatingSummaryTests diff --git a/tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj b/tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj similarity index 83% rename from tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj rename to tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj index a63d511..a1a71e3 100644 --- a/tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj +++ b/tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj @@ -14,7 +14,7 @@ - + diff --git a/tests/ProductReviews.Domain.Tests/RatingTests.cs b/tests/ProductReviews.Api.UnitTests/RatingTests.cs similarity index 91% rename from tests/ProductReviews.Domain.Tests/RatingTests.cs rename to tests/ProductReviews.Api.UnitTests/RatingTests.cs index 5a66da1..1604ae8 100644 --- a/tests/ProductReviews.Domain.Tests/RatingTests.cs +++ b/tests/ProductReviews.Api.UnitTests/RatingTests.cs @@ -1,9 +1,9 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes.FsCheck; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; [Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] public sealed class RatingTests diff --git a/tests/ProductReviews.Domain.Tests/ReviewEditTests.cs b/tests/ProductReviews.Api.UnitTests/ReviewEditTests.cs similarity index 96% rename from tests/ProductReviews.Domain.Tests/ReviewEditTests.cs rename to tests/ProductReviews.Api.UnitTests/ReviewEditTests.cs index fee4567..8519a38 100644 --- a/tests/ProductReviews.Domain.Tests/ReviewEditTests.cs +++ b/tests/ProductReviews.Api.UnitTests/ReviewEditTests.cs @@ -1,10 +1,10 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; using StrongTypes.FsCheck; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; /// The PATCH contract of Review.ApplyEdit: null leaves a field unchanged; /// for the clearable optionals, Maybe.None clears and Maybe.Some replaces. diff --git a/tests/ProductReviews.Domain.Tests/ReviewTestData.cs b/tests/ProductReviews.Api.UnitTests/ReviewTestData.cs similarity index 89% rename from tests/ProductReviews.Domain.Tests/ReviewTestData.cs rename to tests/ProductReviews.Api.UnitTests/ReviewTestData.cs index 31d4a11..c3dc5f9 100644 --- a/tests/ProductReviews.Domain.Tests/ReviewTestData.cs +++ b/tests/ProductReviews.Api.UnitTests/ReviewTestData.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; internal static class ReviewTestData { diff --git a/tests/ProductReviews.Domain.Tests/ScoreTests.cs b/tests/ProductReviews.Api.UnitTests/ScoreTests.cs similarity index 91% rename from tests/ProductReviews.Domain.Tests/ScoreTests.cs rename to tests/ProductReviews.Api.UnitTests/ScoreTests.cs index 17e5966..13f416d 100644 --- a/tests/ProductReviews.Domain.Tests/ScoreTests.cs +++ b/tests/ProductReviews.Api.UnitTests/ScoreTests.cs @@ -1,8 +1,8 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Votes; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; public sealed class ScoreTests { From b3d8677780132c053fd194daa9e8eb9be6946601 Mon Sep 17 00:00:00 2001 From: KaliCZ Date: Thu, 16 Jul 2026 17:19:29 +0200 Subject: [PATCH 2/2] Docs: ADR-0009 single-project layout; health-check pattern Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 25 +++++---- README.md | 12 ++-- ...0001-vertical-slices-and-project-layout.md | 2 +- docs/adr/0009-single-api-project.md | 51 +++++++++++++++++ docs/adr/README.md | 3 +- docs/technical-requirements.md | 55 ++++++++++--------- 6 files changed, 102 insertions(+), 46 deletions(-) create mode 100644 docs/adr/0009-single-api-project.md diff --git a/CLAUDE.md b/CLAUDE.md index 37cb8a4..5b8f55f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --project src/ProductReviews.Domain --output-dir Persistence/Migrations +dotnet ef migrations add --project src/ProductReviews.Api --output-dir Persistence/Migrations ``` Demo sign-in: `demo@productreviews.local` / `ProductReviews123!` (second user: @@ -42,11 +42,13 @@ restart the AppHost. ## Architecture rules (the non-negotiables) -1. **Vertical slices.** A feature lives in `Features//` (API) and - `/` (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//` — 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 @@ -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 @@ -122,11 +125,11 @@ restart the AppHost. | New… | Goes to | | -------------------------- | ------- | | Endpoint + DTOs | `src/ProductReviews.Api/Features//` | -| Business operation | `src/ProductReviews.Domain//.cs` (handler + error enum) | -| Entity / value type | `src/ProductReviews.Domain//` | -| EF configuration | `src/ProductReviews.Domain/Persistence/Configurations/` | +| Business operation | `src/ProductReviews.Api/Features//.cs` (handler + error enum) | +| Entity / value type | `src/ProductReviews.Api/Features//` | +| EF configuration | `src/ProductReviews.Api/Persistence/Configurations/` | | Cross-cutting API concern | `src/ProductReviews.Api/Infrastructure/.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) | diff --git a/README.md b/README.md index 67a44d8..2be6dfb 100644 --- a/README.md +++ b/README.md @@ -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` 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` + 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` 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` + 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` 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` paging, `Rating[]` filters) | [ReviewsController.cs](src/ProductReviews.Api/Features/Reviews/ReviewsController.cs), [GetReviewsPage.cs](src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs) | ## Tests diff --git a/docs/adr/0001-vertical-slices-and-project-layout.md b/docs/adr/0001-vertical-slices-and-project-layout.md index 906f856..fc05ce5 100644 --- a/docs/adr/0001-vertical-slices-and-project-layout.md +++ b/docs/adr/0001-vertical-slices-and-project-layout.md @@ -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 diff --git a/docs/adr/0009-single-api-project.md b/docs/adr/0009-single-api-project.md new file mode 100644 index 0000000..3f8eab1 --- /dev/null +++ b/docs/adr/0009-single-api-project.md @@ -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//`** 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7f9ec3b..68335e6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,7 +12,7 @@ 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 | @@ -20,3 +20,4 @@ never by editing an old one. | 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 | diff --git a/docs/technical-requirements.md b/docs/technical-requirements.md index 307b987..a19a1e9 100644 --- a/docs/technical-requirements.md +++ b/docs/technical-requirements.md @@ -34,12 +34,11 @@ flowchart LR end subgraph Aspire AppHost FE[Vite dev server
proxies /api] - API[ProductReviews.Api
controllers + DTOs] - DOM[ProductReviews.Domain
entities + handlers + EF Core] + API[ProductReviews.Api
feature slices: controllers + DTOs
+ entities + handlers + EF Core] PG[(PostgreSQL)] ZIT[Zitadel
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 @@ -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 @@ -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` + 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) @@ -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 `.cs`, +One file per operation in its feature folder, named `.cs`, containing the handler class and, when the operation can fail, its error enum (ADR-0003): @@ -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 `" "`, so a +deploy gate can grep the running build. ## 10. Local development & orchestration @@ -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) |