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 69%
rename from src/ProductReviews.Domain/Reviews/ReviewAuthor.cs
rename to src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs
index fa4800c..247c697 100644
--- a/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs
+++ b/src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs
@@ -1,8 +1,8 @@
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
+/// (derived from the identity provider's subject, see ADR-0003) and the display
/// name snapshotted onto anything they write.
public sealed record ReviewAuthor(Guid AuthorId, NonEmptyString DisplayName);
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..5944271 100644
--- a/src/ProductReviews.Api/Infrastructure/CurrentUser.cs
+++ b/src/ProductReviews.Api/Infrastructure/CurrentUser.cs
@@ -1,14 +1,14 @@
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;
/// Maps the authenticated principal to the domain's .
/// AuthorId is the SHA-256 of the OIDC sub claim (first 16 bytes as a Guid), so the
-/// database stays Guid-keyed whatever shape the identity provider's ids have (ADR-0005).
+/// database stays Guid-keyed whatever shape the identity provider's ids have (ADR-0003).
public static class CurrentUser
{
public static ReviewAuthor Author(this ClaimsPrincipal user)
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/ErrorHandling.cs b/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs
index ceab5d3..a1d858d 100644
--- a/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs
+++ b/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs
@@ -1,7 +1,7 @@
namespace ProductReviews.Api.Infrastructure;
/// Unhandled exceptions become RFC 7807 responses. Business failures never get
-/// here — they travel as Result error enums and are mapped by controllers (ADR-0003).
+/// here — they travel as Result error enums and are mapped by controllers (ADR-0001).
public static class ErrorHandling
{
public static void Configure(WebApplicationBuilder builder)
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..79a24f1 100644
--- a/src/ProductReviews.Api/Infrastructure/OpenApi.cs
+++ b/src/ProductReviews.Api/Infrastructure/OpenApi.cs
@@ -1,12 +1,12 @@
using Microsoft.OpenApi;
-using ProductReviews.Domain.Reviews;
+using ProductReviews.Api.Features.Reviews;
using StrongTypes.OpenApi.Swashbuckle;
namespace ProductReviews.Api.Infrastructure;
/// Swashbuckle with AddStrongTypes(), so the document carries the real
/// constraints (email format, minLength, exclusiveMinimum). The generated TypeScript
-/// client is built from this document — it is the frontend contract (ADR-0004).
+/// client is built from this document — it is the frontend contract (ADR-0002).
public static class OpenApi
{
public static void Configure(WebApplicationBuilder builder)
diff --git a/src/ProductReviews.Api/Infrastructure/Persistence.cs b/src/ProductReviews.Api/Infrastructure/Persistence.cs
index ec319e8..0899de3 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,9 +13,11 @@ 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
+ /// Development-only convenience (ADR-0004): production applies migrations
/// from the deploy pipeline and never seeds.
public static async Task MigrateAndSeedAsync(WebApplication app)
{
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 92%
rename from src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs
rename to src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs
index 43ab4ed..677e83c 100644
--- a/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs
+++ b/src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs
@@ -1,12 +1,12 @@
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
+/// Idempotent demo seeding (ADR-0004): runs after migrations, does nothing
/// once products exist, and builds everything through the domain entities so seed
/// data satisfies the same invariants as user input.
public static class DemoDataSeeder
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.AppHost/AppHost.cs b/src/ProductReviews.AppHost/AppHost.cs
index 21f22da..bb8e07b 100644
--- a/src/ProductReviews.AppHost/AppHost.cs
+++ b/src/ProductReviews.AppHost/AppHost.cs
@@ -20,7 +20,7 @@
// "zitadeldb", not "zitadel" — the container resource already claims that name.
var zitadelDatabase = postgres.AddDatabase("zitadeldb");
-// --- Identity provider: self-hosted Zitadel (ADR-0005) -----------------------
+// --- Identity provider: self-hosted Zitadel (ADR-0003) -----------------------
// Zitadel writes its first-instance machine-user PATs into this bind-mounted dir on first
// init; the AppHost reads them to drive provisioning. Gitignored. E2E gets a unique temp
// dir: its Postgres is throwaway, so a PAT left over from a previous run would belong to a
@@ -86,7 +86,7 @@
}
});
-// --- Frontend: Vue 3 + Vite SPA (ADR-0008) -------------------------------------
+// --- Frontend: Vue 3 + Vite SPA (ADR-0006) -------------------------------------
// AddNpmApp only runs the script — install once when node_modules is absent and gate on it.
var frontendDirectory = Path.Combine(repositoryRoot, "frontend");
IResourceBuilder? frontendInstall = null;
diff --git a/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs
index bdacff3..7142a91 100644
--- a/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs
+++ b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs
@@ -1,6 +1,6 @@
namespace ProductReviews.AppHost.Zitadel;
-/// The self-hosted Zitadel containers (ADR-0005): the core (API + Console) and the
+/// The self-hosted Zitadel containers (ADR-0003): the core (API + Console) and the
/// separate Login V2 app the core redirects browsers to for the hosted sign-in page.
internal static class ZitadelHosting
{
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.Api.IntegrationTests/SharedApiContext.cs b/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs
index e0724ae..4e9c89d 100644
--- a/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs
+++ b/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs
@@ -13,7 +13,7 @@ public sealed class ReviewsApiFactory(string connectionString) : WebApplicationF
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
- // Development runs the real migrate + seed path at startup (ADR-0006) —
+ // Development runs the real migrate + seed path at startup (ADR-0004) —
// the tests exercise it instead of preparing the schema themselves.
builder.UseEnvironment("Development");
builder.UseSetting("ConnectionStrings:productreviews", connectionString);
@@ -34,7 +34,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
}
}
-/// One Postgres container + one API host for the whole run (ADR-0007);
+/// One Postgres container + one API host for the whole run (ADR-0005);
/// tests isolate through unique authors/products, never by resetting the database.
public sealed class SharedApiContext : IAsyncLifetime
{
diff --git a/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs b/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs
index 3e179e0..402660c 100644
--- a/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs
+++ b/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs
@@ -5,7 +5,7 @@
namespace ProductReviews.Api.IntegrationTests;
/// Mints JWTs against a test-owned symmetric key. The API's real JwtBearer
-/// pipeline validates them (ADR-0007) — only the trust anchor is swapped, not the code path.
+/// pipeline validates them (ADR-0005) — only the trust anchor is swapped, not the code path.
public static class TestTokens
{
public const string Issuer = "https://tests.productreviews.local";
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
{