diff --git a/.editorconfig b/.editorconfig
index 5ffa7edd..c85871ca 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -2,180 +2,180 @@ root = true
# ─── All files ────────────────────────────────────────────────────────────────
[*]
-indent_style = space
-indent_size = 4
-end_of_line = crlf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
+indent_style = space
+indent_size = 4
+end_of_line = crlf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
# ─── C# ───────────────────────────────────────────────────────────────────────
[*.cs]
# Namespace style
-csharp_style_namespace_declarations = file_scoped:warning
+csharp_style_namespace_declarations = file_scoped:warning
# Indentation & new-lines (Allman / ReSharper defaults)
-csharp_new_line_before_open_brace = all
-csharp_new_line_before_else = true
-csharp_new_line_before_catch = true
-csharp_new_line_before_finally = true
-csharp_new_line_before_members_in_object_initializers = true
-csharp_new_line_before_members_in_anonymous_types = true
-csharp_new_line_between_query_expression_clauses = true
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
# Indentation
-csharp_indent_case_contents = true
-csharp_indent_switch_labels = true
-csharp_indent_labels = flush_left
-csharp_indent_block_contents = true
-csharp_indent_braces = false
-csharp_indent_case_contents_when_block = false
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = flush_left
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents_when_block = false
# Spacing
-csharp_space_after_cast = false
-csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_method_declaration_parameter_list_parentheses = false
-csharp_space_between_method_call_parameter_list_parentheses = false
-csharp_space_between_parentheses = false
-csharp_space_before_colon_in_inheritance_clause = true
-csharp_space_after_colon_in_inheritance_clause = true
-csharp_space_around_binary_operators = before_and_after
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_around_binary_operators = before_and_after
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
-csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
# Wrapping
-csharp_preserve_single_line_statements = false
-csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = false
+csharp_preserve_single_line_blocks = true
# var vs explicit type — explicit types everywhere (as seen in codebase)
-csharp_style_var_for_built_in_types = false:warning
-csharp_style_var_when_type_is_apparent = false:suggestion
-csharp_style_var_elsewhere = false:warning
+csharp_style_var_for_built_in_types = false:warning
+csharp_style_var_when_type_is_apparent = false:suggestion
+csharp_style_var_elsewhere = false:warning
# Braces — omit only for single-line statements (as in codebase)
-csharp_prefer_braces = when_multiline:warning
+csharp_prefer_braces = when_multiline:warning
# Expression-bodied members
-csharp_style_expression_bodied_methods = false:silent
-csharp_style_expression_bodied_constructors = false:silent
-csharp_style_expression_bodied_operators = false:silent
-csharp_style_expression_bodied_properties = when_on_single_line:suggestion
-csharp_style_expression_bodied_indexers = when_on_single_line:suggestion
-csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
-csharp_style_expression_bodied_lambdas = true:suggestion
-csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = when_on_single_line:suggestion
+csharp_style_expression_bodied_indexers = when_on_single_line:suggestion
+csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
+csharp_style_expression_bodied_lambdas = true:suggestion
+csharp_style_expression_bodied_local_functions = false:silent
# Pattern matching
-csharp_style_pattern_matching_over_is_with_cast_check = true:warning
-csharp_style_pattern_matching_over_as_with_null_check = true:warning
-csharp_style_prefer_switch_expression = true:suggestion
-csharp_style_prefer_pattern_matching = true:suggestion
-csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:warning
+csharp_style_pattern_matching_over_as_with_null_check = true:warning
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
# Null checks
-csharp_style_prefer_null_check_over_type_check = true:warning
-dotnet_style_null_propagation = true:warning
-dotnet_style_coalesce_expression = true:warning
-dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
+csharp_style_prefer_null_check_over_type_check = true:warning
+dotnet_style_null_propagation = true:warning
+dotnet_style_coalesce_expression = true:warning
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
# Modifiers
-csharp_prefer_static_local_function = true:suggestion
-dotnet_style_require_accessibility_modifiers = always:warning
-csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
+csharp_prefer_static_local_function = true:suggestion
+dotnet_style_require_accessibility_modifiers = always:warning
+csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async:suggestion
# this. qualification — never
-dotnet_style_qualification_for_field = false:warning
-dotnet_style_qualification_for_property = false:warning
-dotnet_style_qualification_for_method = false:warning
-dotnet_style_qualification_for_event = false:warning
+dotnet_style_qualification_for_field = false:warning
+dotnet_style_qualification_for_property = false:warning
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_qualification_for_event = false:warning
# Language keywords vs types
-dotnet_style_predefined_type_for_locals_parameters_members = true:warning
-dotnet_style_predefined_type_for_member_access = true:warning
+dotnet_style_predefined_type_for_locals_parameters_members = true:warning
+dotnet_style_predefined_type_for_member_access = true:warning
# Object / collection initializers
-dotnet_style_object_initializer = true:suggestion
-dotnet_style_collection_initializer = true:suggestion
-dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
# Tuple / anonymous types
-dotnet_style_explicit_tuple_names = true:warning
-dotnet_style_prefer_inferred_tuple_names = true:suggestion
-dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_explicit_tuple_names = true:warning
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
# Miscellaneous
-dotnet_style_prefer_auto_properties = true:warning
-dotnet_style_prefer_compound_assignment = true:suggestion
-dotnet_style_prefer_simplified_boolean_expressions = true:warning
-dotnet_style_prefer_simplified_interpolation = true:suggestion
-csharp_style_prefer_local_over_anonymous_function = true:suggestion
-csharp_style_prefer_range_operator = true:suggestion
-csharp_style_prefer_index_operator = true:suggestion
-csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
-csharp_style_prefer_tuple_swap = true:suggestion
-csharp_style_prefer_utf8_string_literals = true:suggestion
+dotnet_style_prefer_auto_properties = true:warning
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:warning
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
# Using directives
-dotnet_sort_system_directives_first = true
-dotnet_separate_import_directive_groups = false
-csharp_using_directive_placement = outside_namespace:warning
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+csharp_using_directive_placement = outside_namespace:warning
# Naming conventions ──────────────────────────────────────────────────────────
# Interfaces: IPascalCase
-dotnet_naming_rule.interface_should_be_pascal_case.severity = warning
-dotnet_naming_rule.interface_should_be_pascal_case.symbols = interface
-dotnet_naming_rule.interface_should_be_pascal_case.style = begins_with_i
+dotnet_naming_rule.interface_should_be_pascal_case.severity = warning
+dotnet_naming_rule.interface_should_be_pascal_case.symbols = interface
+dotnet_naming_rule.interface_should_be_pascal_case.style = begins_with_i
-dotnet_naming_symbols.interface.applicable_kinds = interface
-dotnet_naming_symbols.interface.applicable_accessibilities = public,internal,private,protected,protected_internal,private_protected
-dotnet_naming_style.begins_with_i.required_prefix = I
-dotnet_naming_style.begins_with_i.capitalization = pascal_case
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
# Type parameters: TPascalCase
-dotnet_naming_rule.type_parameters_should_begin_with_t.severity = warning
-dotnet_naming_rule.type_parameters_should_begin_with_t.symbols = type_parameter
-dotnet_naming_rule.type_parameters_should_begin_with_t.style = begins_with_t
+dotnet_naming_rule.type_parameters_should_begin_with_t.severity = warning
+dotnet_naming_rule.type_parameters_should_begin_with_t.symbols = type_parameter
+dotnet_naming_rule.type_parameters_should_begin_with_t.style = begins_with_t
-dotnet_naming_symbols.type_parameter.applicable_kinds = type_parameter
-dotnet_naming_style.begins_with_t.required_prefix = T
-dotnet_naming_style.begins_with_t.capitalization = pascal_case
+dotnet_naming_symbols.type_parameter.applicable_kinds = type_parameter
+dotnet_naming_style.begins_with_t.required_prefix = T
+dotnet_naming_style.begins_with_t.capitalization = pascal_case
# Types & members: PascalCase
-dotnet_naming_rule.types_should_be_pascal_case.severity = warning
-dotnet_naming_rule.types_should_be_pascal_case.symbols = types
-dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case_style
+dotnet_naming_rule.types_should_be_pascal_case.severity = warning
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case_style
-dotnet_naming_symbols.types.applicable_kinds = class,struct,interface,enum,delegate
-dotnet_naming_style.pascal_case_style.capitalization = pascal_case
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum, delegate
+dotnet_naming_style.pascal_case_style.capitalization = pascal_case
# Private fields: _camelCase
-dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
-dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
-dotnet_naming_rule.private_fields_should_be_camel_case.style = underscore_camel_case
+dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
+dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
+dotnet_naming_rule.private_fields_should_be_camel_case.style = underscore_camel_case
-dotnet_naming_symbols.private_fields.applicable_kinds = field
-dotnet_naming_symbols.private_fields.applicable_accessibilities = private,protected,private_protected
-dotnet_naming_style.underscore_camel_case.required_prefix = _
-dotnet_naming_style.underscore_camel_case.capitalization = camel_case
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
+dotnet_naming_style.underscore_camel_case.required_prefix = _
+dotnet_naming_style.underscore_camel_case.capitalization = camel_case
# Constants: PascalCase
-dotnet_naming_rule.constants_should_be_pascal_case.severity = warning
-dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
-dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case_style
+dotnet_naming_rule.constants_should_be_pascal_case.severity = warning
+dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
+dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case_style
-dotnet_naming_symbols.constants.applicable_kinds = field,local
-dotnet_naming_symbols.constants.required_modifiers = const
+dotnet_naming_symbols.constants.applicable_kinds = field, local
+dotnet_naming_symbols.constants.required_modifiers = const
# Parameters & locals: camelCase
-dotnet_naming_rule.parameters_should_be_camel_case.severity = warning
-dotnet_naming_rule.parameters_should_be_camel_case.symbols = parameters
-dotnet_naming_rule.parameters_should_be_camel_case.style = camel_case_style
+dotnet_naming_rule.parameters_should_be_camel_case.severity = warning
+dotnet_naming_rule.parameters_should_be_camel_case.symbols = parameters
+dotnet_naming_rule.parameters_should_be_camel_case.style = camel_case_style
-dotnet_naming_symbols.parameters.applicable_kinds = parameter,local
-dotnet_naming_style.camel_case_style.capitalization = camel_case
+dotnet_naming_symbols.parameters.applicable_kinds = parameter, local
+dotnet_naming_style.camel_case_style.capitalization = camel_case
# ─── XML / CSPROJ ─────────────────────────────────────────────────────────────
[*.{xml,csproj,props,targets,resx}]
diff --git a/APITemplate.slnx b/APITemplate.slnx
index 252da88e..33d45f4c 100644
--- a/APITemplate.slnx
+++ b/APITemplate.slnx
@@ -1,47 +1,47 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 5a161a05..a6ca4ef4 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -8,11 +8,11 @@
StandardOutputImportance="Low"
StandardErrorImportance="High"
WorkingDirectory="$(MSBuildThisFileDirectory)"
- ContinueOnError="true" />
+ ContinueOnError="true"/>
+ ContinueOnError="true"/>
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 876fee04..bcf0c597 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,86 +3,86 @@
true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 7573b00a..e4a385dc 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,17 @@
[](https://github.com/zribktad/API-Template/actions/workflows/pr-validation.yml)
-A scalable, clean, and modern template designed to jumpstart **.NET 10** Web API and Data-Driven applications. By providing a curated set of industry-standard libraries and combining modern **REST** APIs side-by-side with a robust **GraphQL** backend, it bridges the gap between typical monolithic development speed and Clean Architecture principles within a single maintainable repository.
+A scalable, clean, and modern template designed to jumpstart **.NET 10** Web API and Data-Driven applications. By
+providing a curated set of industry-standard libraries and combining modern **REST** APIs side-by-side with a robust *
+*GraphQL** backend, it bridges the gap between typical monolithic development speed and Clean Architecture principles
+within a single maintainable repository.
## 📚 How-To Guides
Step-by-step guides for the most common workflows in this project:
| Guide | Description |
-| ---------------------------------------------------- | --------------------------------------------------------------------------- |
+|------------------------------------------------------|-----------------------------------------------------------------------------|
| [GraphQL Endpoint](docs/graphql-endpoint.md) | Add a type, query, mutation, and optional DataLoader |
| [REST Endpoint](docs/rest-endpoint.md) | Full workflow: entity → DTO → validator → Wolverine handler → controller |
| [EF Core Migration](docs/ef-migration.md) | Create and apply PostgreSQL schema migrations |
@@ -31,35 +34,59 @@ Step-by-step guides for the most common workflows in this project:
## 🚀 Key Features
-* **Architecture Pattern:** Clean mapping of concerns inside a monolithic solution (emulating Clean Architecture). `Domain` rules and interfaces are isolated from `Application` logic and `Infrastructure`.
-* **Dual API Modalities:**
- * **REST API:** Clean HTTP endpoints using versioned controllers (`Asp.Versioning.Mvc`).
- * **GraphQL API:** Complex query batching via `HotChocolate`, integrated Mutations and DataLoaders to eliminate the N+1 problem.
-* **Modern Interactive Documentation:** Native `.NET 10` OpenAPI integrations displayed smoothly in the browser using **Scalar** `/scalar`. Includes **Nitro UI** `/graphql/ui` for testing queries natively.
-* **Dual Database Architecture:**
- * **PostgreSQL + EF Core 10:** Relational entities (Products, Categories, Reviews, Tenants, Users) with the Repository + Unit of Work pattern.
- * **MongoDB:** Semi-structured media metadata (ProductData) with a polymorphic document model and BSON discriminators.
-* **Multi-Tenancy:** Every relational entity implements `IAuditableTenantEntity`. `AppDbContext` enforces per-tenant read isolation via global query filters (`TenantId == currentTenant && !IsDeleted`). New rows are automatically stamped with the current tenant from the request JWT.
-* **Soft Delete with Cascade:** Delete operations are converted to soft-delete updates in `AppDbContext.SaveChangesAsync`. Cascade rules (e.g. `ProductSoftDeleteCascadeRule`) propagate soft-deletes to dependent entities without relying on database-level cascades.
-* **Audit Fields:** All entities carry `AuditInfo` (owned EF type) with `CreatedAtUtc`, `CreatedBy`, `UpdatedAtUtc`, `UpdatedBy`. Fields are stamped automatically in `SaveChangesAsync`.
-* **Optimistic Concurrency:** PostgreSQL native `xmin` system column configured as a concurrency token. `DbUpdateConcurrencyException` is mapped to HTTP 409 by `ApiExceptionHandler`.
-* **Rate Limiting:** Fixed-window per-client rate limiter (`100 req/min` default). Partition key priority: JWT username → remote IP → `"anonymous"`. Returns HTTP 429 on breach. Limits are configurable via `RateLimiting:Fixed`.
-* **Output Caching:** Tenant-isolated ASP.NET Core output cache backed by **DragonFly** (Redis-compatible). Policies: `Products` (30 s), `Categories` (60 s), `Reviews` (30 s). Mutations evict affected tags. Falls back to in-memory when `Dragonfly:ConnectionString` is absent.
-* **Domain Filtering:** Seamless filtering, sorting, and paging powered by `Ardalis.Specification` to decouple query models from infrastructural EF abstractions.
-* **Enterprise-Grade Utilities:**
- * **Validation:** Pipelined model validation using `FluentValidation.AspNetCore`.
- * **Cross-Cutting Concerns:** Unified configuration via `Serilog` (structured logging with `MachineName` and `ThreadId` enrichers) and centralized exception handling via `IExceptionHandler` + RFC 7807 `ProblemDetails`.
- * **Data Redaction:** Sensitive log properties (PII, secrets) are classified with `Microsoft.Extensions.Compliance` (`[PersonalData]`, `[SensitiveData]`) and HMAC-redacted before writing.
- * **Authentication:** Pre-configured Keycloak JWT + BFF Cookie dual-auth with production hardening: secure-only cookies in production, server-side session store (`DragonflyTicketStore`) backed by DragonFly, silent token refresh before expiry, and CSRF protection (`X-CSRF: 1` header required for cookie-authenticated mutations).
- * **Observability:** Health Checks (`/health`) natively tracking PostgreSQL, MongoDB, and DragonFly state.
-* **Role-Based Access Control:** Three-tier role model (`PlatformAdmin`, `TenantAdmin`, `User`) enforced via Keycloak claims and ASP.NET Core policy-based authorization. `PermissionRequirement` handlers gate controller actions and GraphQL mutations by role.
-* **Robust Testing Engine:** Provides isolated `Integration` tests using `UseInMemoryDatabase` combined with `WebApplicationFactory` for fast feedback, **Testcontainers PostgreSQL** for high-fidelity tenant isolation and transaction tests, plus a comprehensive `Unit` test suite (Moq, Shouldly, FluentValidation.TestHelper).
+* **Architecture Pattern:** Clean mapping of concerns inside a monolithic solution (emulating Clean Architecture).
+ `Domain` rules and interfaces are isolated from `Application` logic and `Infrastructure`.
+* **Dual API Modalities:**
+ * **REST API:** Clean HTTP endpoints using versioned controllers (`Asp.Versioning.Mvc`).
+ * **GraphQL API:** Complex query batching via `HotChocolate`, integrated Mutations and DataLoaders to eliminate the
+ N+1 problem.
+* **Modern Interactive Documentation:** Native `.NET 10` OpenAPI integrations displayed smoothly in the browser using *
+ *Scalar** `/scalar`. Includes **Nitro UI** `/graphql/ui` for testing queries natively.
+* **Dual Database Architecture:**
+ * **PostgreSQL + EF Core 10:** Relational entities (Products, Categories, Reviews, Tenants, Users) with the
+ Repository + Unit of Work pattern.
+ * **MongoDB:** Semi-structured media metadata (ProductData) with a polymorphic document model and BSON
+ discriminators.
+* **Multi-Tenancy:** Every relational entity implements `IAuditableTenantEntity`. `AppDbContext` enforces per-tenant
+ read isolation via global query filters (`TenantId == currentTenant && !IsDeleted`). New rows are automatically
+ stamped with the current tenant from the request JWT.
+* **Soft Delete with Cascade:** Delete operations are converted to soft-delete updates in
+ `AppDbContext.SaveChangesAsync`. Cascade rules (e.g. `ProductSoftDeleteCascadeRule`) propagate soft-deletes to
+ dependent entities without relying on database-level cascades.
+* **Audit Fields:** All entities carry `AuditInfo` (owned EF type) with `CreatedAtUtc`, `CreatedBy`, `UpdatedAtUtc`,
+ `UpdatedBy`. Fields are stamped automatically in `SaveChangesAsync`.
+* **Optimistic Concurrency:** PostgreSQL native `xmin` system column configured as a concurrency token.
+ `DbUpdateConcurrencyException` is mapped to HTTP 409 by `ApiExceptionHandler`.
+* **Rate Limiting:** Fixed-window per-client rate limiter (`100 req/min` default). Partition key priority: JWT
+ username → remote IP → `"anonymous"`. Returns HTTP 429 on breach. Limits are configurable via `RateLimiting:Fixed`.
+* **Output Caching:** Tenant-isolated ASP.NET Core output cache backed by **DragonFly** (Redis-compatible). Policies:
+ `Products` (30 s), `Categories` (60 s), `Reviews` (30 s). Mutations evict affected tags. Falls back to in-memory when
+ `Dragonfly:ConnectionString` is absent.
+* **Domain Filtering:** Seamless filtering, sorting, and paging powered by `Ardalis.Specification` to decouple query
+ models from infrastructural EF abstractions.
+* **Enterprise-Grade Utilities:**
+ * **Validation:** Pipelined model validation using `FluentValidation.AspNetCore`.
+ * **Cross-Cutting Concerns:** Unified configuration via `Serilog` (structured logging with `MachineName` and
+ `ThreadId` enrichers) and centralized exception handling via `IExceptionHandler` + RFC 7807 `ProblemDetails`.
+ * **Data Redaction:** Sensitive log properties (PII, secrets) are classified with
+ `Microsoft.Extensions.Compliance` (`[PersonalData]`, `[SensitiveData]`) and HMAC-redacted before writing.
+ * **Authentication:** Pre-configured Keycloak JWT + BFF Cookie dual-auth with production hardening: secure-only
+ cookies in production, server-side session store (`DragonflyTicketStore`) backed by DragonFly, silent token
+ refresh before expiry, and CSRF protection (`X-CSRF: 1` header required for cookie-authenticated mutations).
+ * **Observability:** Health Checks (`/health`) natively tracking PostgreSQL, MongoDB, and DragonFly state.
+* **Role-Based Access Control:** Three-tier role model (`PlatformAdmin`, `TenantAdmin`, `User`) enforced via Keycloak
+ claims and ASP.NET Core policy-based authorization. `PermissionRequirement` handlers gate controller actions and
+ GraphQL mutations by role.
+* **Robust Testing Engine:** Provides isolated `Integration` tests using `UseInMemoryDatabase` combined with
+ `WebApplicationFactory` for fast feedback, **Testcontainers PostgreSQL** for high-fidelity tenant isolation and
+ transaction tests, plus a comprehensive `Unit` test suite (Moq, Shouldly, FluentValidation.TestHelper).
---
## 🏗 Architecture Diagram
-The application leverages a single `.csproj` separated rationally via namespaces that conform to typical clean layer boundaries. The goal is friction-free deployments and dependency chains while ensuring long-term code organization.
+The application leverages a single `.csproj` separated rationally via namespaces that conform to typical clean layer
+boundaries. The goal is friction-free deployments and dependency chains while ensuring long-term code organization.
```mermaid
graph TD
@@ -243,22 +270,24 @@ classDiagram
## 🛠 Technology Stack
-* **Runtime:** `.NET 10.0` Web SDK
-* **Relational Database:** PostgreSQL 18 (`Npgsql`)
-* **Document Database:** MongoDB 8 (`MongoDB.Driver`)
-* **Cache / Rate Limit Backing Store:** DragonFly 1.27 (Redis-compatible, `StackExchange.Redis`)
-* **ORM:** Entity Framework Core (`Microsoft.EntityFrameworkCore.Design`, `10.0`)
-* **API Toolkit:** ASP.NET Core, Asp.Versioning, `Scalar.AspNetCore`
-* **GraphQL Core:** HotChocolate `15.1`
-* **Auth:** Keycloak 26 (JWT Bearer + BFF Cookie via OIDC)
-* **Utilities:** `Serilog.AspNetCore`, `FluentValidation`, `Ardalis.Specification`, `Kot.MongoDB.Migrations`
-* **Test Suite:** xUnit 3, `Microsoft.AspNetCore.Mvc.Testing`, Moq, Shouldly, `FluentValidation.TestHelper`, Testcontainers.PostgreSql, Respawn
+* **Runtime:** `.NET 10.0` Web SDK
+* **Relational Database:** PostgreSQL 18 (`Npgsql`)
+* **Document Database:** MongoDB 8 (`MongoDB.Driver`)
+* **Cache / Rate Limit Backing Store:** DragonFly 1.27 (Redis-compatible, `StackExchange.Redis`)
+* **ORM:** Entity Framework Core (`Microsoft.EntityFrameworkCore.Design`, `10.0`)
+* **API Toolkit:** ASP.NET Core, Asp.Versioning, `Scalar.AspNetCore`
+* **GraphQL Core:** HotChocolate `15.1`
+* **Auth:** Keycloak 26 (JWT Bearer + BFF Cookie via OIDC)
+* **Utilities:** `Serilog.AspNetCore`, `FluentValidation`, `Ardalis.Specification`, `Kot.MongoDB.Migrations`
+* **Test Suite:** xUnit 3, `Microsoft.AspNetCore.Mvc.Testing`, Moq, Shouldly, `FluentValidation.TestHelper`,
+ Testcontainers.PostgreSql, Respawn
---
## 📂 Project Structure
-The solution follows a strict **four-project Clean Architecture** split. Each project has a single, well-defined responsibility and a one-way dependency rule: outer layers depend on inner layers — never the reverse.
+The solution follows a strict **four-project Clean Architecture** split. Each project has a single, well-defined
+responsibility and a one-way dependency rule: outer layers depend on inner layers — never the reverse.
```
APITemplate.Domain ← APITemplate.Application ← APITemplate.Infrastructure
@@ -268,9 +297,9 @@ APITemplate.Domain ← APITemplate.Application ← APITemplate.Infrastructur
### Project responsibilities
| Project | Role | Key rule |
-| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
+|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| `APITemplate.Domain` | Core business model — entities, enums, domain exceptions, repository interfaces | No dependencies on any other project or NuGet package except .NET BCL |
-| `APITemplate.Application` | Use-case layer — Wolverine commands/queries/handlers, DTOs, FluentValidation validators, specifications | Depends only on Domain; never references EF Core, ASP.NET, or any infrastructure detail |
+| `APITemplate.Application` | Use-case layer — Wolverine commands/queries/handlers, DTOs, FluentValidation validators, specifications | Depends only on Domain; never references EF Core, ASP.NET, or any infrastructure detail |
| `APITemplate.Infrastructure` | Technical implementations — EF Core `AppDbContext`, MongoDB context, repository classes, Unit of Work, migrations, security services, observability | Depends on Domain (implements interfaces) and Application (reads options) |
| `APITemplate.Api` | Presentation entry point — REST controllers, GraphQL types/queries/mutations/DataLoaders, middleware, DI composition root, `Program.cs` | Depends on all other projects; owns `IMessageBus` dispatch and HTTP/GraphQL mapping |
| `APITemplate.Tests` | Test suite — unit tests (Moq), in-memory integration tests (`WebApplicationFactory`), Testcontainers PostgreSQL tests (Respawn) | References all production projects; never ships to production |
@@ -332,22 +361,29 @@ tests/APITemplate.Tests/
### Dependency rule in practice
-- A handler in `APITemplate.Application` calls `IProductRepository` (Domain interface) — it never imports `ProductRepository` (Infrastructure class).
-- `APITemplate.Infrastructure` implements `IProductRepository` and registers it in DI inside `APITemplate.Api`'s composition root.
-- `APITemplate.Api` controllers reference only `IMessageBus` (Wolverine) — they have no direct dependency on any Application service or Infrastructure class.
+- A handler in `APITemplate.Application` calls `IProductRepository` (Domain interface) — it never imports
+ `ProductRepository` (Infrastructure class).
+- `APITemplate.Infrastructure` implements `IProductRepository` and registers it in DI inside `APITemplate.Api`'s
+ composition root.
+- `APITemplate.Api` controllers reference only `IMessageBus` (Wolverine) — they have no direct dependency on any
+ Application service or Infrastructure class.
---
## 🌐 REST API Reference
-All versioned REST resource endpoints sit under the base path `api/v{version}`. JWT `Authorization: Bearer ` is required for these versioned API routes. Authentication is handled externally by Keycloak (see [Authentication](#-authentication) section). Utility endpoints such as `/health` and `/graphql/ui` are anonymous, and `/scalar` is only mapped in Development.
+All versioned REST resource endpoints sit under the base path `api/v{version}`. JWT `Authorization: Bearer ` is
+required for these versioned API routes. Authentication is handled externally by Keycloak (
+see [Authentication](#-authentication) section). Utility endpoints such as `/health` and `/graphql/ui` are anonymous,
+and `/scalar` is only mapped in Development.
-> **Rate limiting:** all controller routes require the `fixed` rate-limit policy (100 requests per minute per authenticated user or remote IP).
+> **Rate limiting:** all controller routes require the `fixed` rate-limit policy (100 requests per minute per
+> authenticated user or remote IP).
### Products
| Method | Path | Auth Required | Description |
-| -------- | ----------------------- | :-----------: | ---------------------------------------------- |
+|----------|-------------------------|:-------------:|------------------------------------------------|
| `GET` | `/api/v1/Products` | ✅ | List products with filtering, sorting & paging |
| `GET` | `/api/v1/Products/{id}` | ✅ | Get a single product by GUID |
| `POST` | `/api/v1/Products` | ✅ | Create a new product |
@@ -357,7 +393,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`.
### Categories
| Method | Path | Auth Required | Description |
-| -------- | ------------------------------- | :-----------: | ------------------------------------- |
+|----------|---------------------------------|:-------------:|---------------------------------------|
| `GET` | `/api/v1/Categories` | ✅ | List all categories |
| `GET` | `/api/v1/Categories/{id}` | ✅ | Get a category by GUID |
| `POST` | `/api/v1/Categories` | ✅ | Create a new category |
@@ -368,7 +404,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`.
### Product Reviews
| Method | Path | Auth Required | Description |
-| -------- | ----------------------------------------------- | :-----------: | ------------------------------------ |
+|----------|-------------------------------------------------|:-------------:|--------------------------------------|
| `GET` | `/api/v1/ProductReviews` | ✅ | List reviews with filtering & paging |
| `GET` | `/api/v1/ProductReviews/{id}` | ✅ | Get a review by GUID |
| `GET` | `/api/v1/ProductReviews/by-product/{productId}` | ✅ | All reviews for a given product |
@@ -378,7 +414,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`.
### Product Data (MongoDB)
| Method | Path | Auth Required | Description |
-| -------- | ---------------------------- | :-----------: | ------------------------------------------ |
+|----------|------------------------------|:-------------:|--------------------------------------------|
| `GET` | `/api/v1/product-data` | ✅ | List all or filter by `type` (image/video) |
| `GET` | `/api/v1/product-data/{id}` | ✅ | Get by MongoDB ObjectId |
| `POST` | `/api/v1/product-data/image` | ✅ | Create image media metadata |
@@ -388,7 +424,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`.
### Users
| Method | Path | Auth Required | Description |
-| ------ | ------------------------------- | :-----------: | ----------------------------------------------------- |
+|--------|---------------------------------|:-------------:|-------------------------------------------------------|
| `GET` | `/api/v1/Users` | ✅ | List all users (PlatformAdmin only) |
| `GET` | `/api/v1/Users/{id}` | ✅ | Get a user by GUID |
| `POST` | `/api/v1/Users/register` | ❌ | Register a new user |
@@ -399,7 +435,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`.
### Utility
| Method | Path | Auth Required | Description |
-| ------ | ------------- | :-----------: | ----------------------------------------------------------------------------- |
+|--------|---------------|:-------------:|-------------------------------------------------------------------------------|
| `GET` | `/health` | ❌ | JSON health status for PostgreSQL, MongoDB & DragonFly |
| `GET` | `/scalar` | ❌ | Interactive Scalar OpenAPI UI (**Development only** — disabled in Production) |
| `GET` | `/graphql/ui` | ❌ | HotChocolate Nitro GraphQL IDE |
@@ -408,21 +444,27 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`.
## ⚙️ Configuration Reference
-All configuration lives in `appsettings.json` (production defaults) and is overridden by `appsettings.Development.json` locally or by environment variables at runtime.
+All configuration lives in `appsettings.json` (production defaults) and is overridden by `appsettings.Development.json`
+locally or by environment variables at runtime.
**Override priority (highest → lowest):**
+
1. Environment variables (e.g. `ConnectionStrings__DefaultConnection=...`)
2. `appsettings.Development.json` (local development)
3. `appsettings.json` (production baseline — committed to source control, must not contain real secrets)
-> **Security note:** Never commit real secrets to `appsettings.json`. Supply `Keycloak:credentials:secret`, database passwords, and any other sensitive values via environment variables, Docker secrets, or a secret manager such as Azure Key Vault.
+> **Security note:** Never commit real secrets to `appsettings.json`. Supply `Keycloak:credentials:secret`, database
+> passwords, and any other sensitive values via environment variables, Docker secrets, or a secret manager such as Azure
+> Key Vault.
-Configuration sections are bound to strongly-typed `IOptions` classes registered in DI (e.g. `RateLimitingOptions`, `CachingOptions`, `BffOptions`), so every setting is validated at startup and injectable into any service without raw `IConfiguration` access.
+Configuration sections are bound to strongly-typed `IOptions` classes registered in DI (e.g. `RateLimitingOptions`,
+`CachingOptions`, `BffOptions`), so every setting is validated at startup and injectable into any service without raw
+`IConfiguration` access.
### Databases
| Key | Example Value | Description |
-| ------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|---------------------------------------|-------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ConnectionStrings:DefaultConnection` | `Host=localhost;Port=5432;Database=apitemplate;Username=postgres;Password=postgres` | Npgsql connection string for the primary PostgreSQL database. Used by EF Core `AppDbContext` for all relational data (tenants, users, products, categories, reviews). |
| `MongoDB:ConnectionString` | `mongodb://localhost:27017` | MongoDB connection string. Used by `MongoDbContext` for the `product_data` collection (polymorphic media metadata). |
| `MongoDB:DatabaseName` | `apitemplate` | Name of the MongoDB database. All MongoDB collections are created inside this database. |
@@ -430,13 +472,13 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe
### Cache & Session
| Key | Example Value | Description |
-| ---------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|------------------------------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Dragonfly:ConnectionString` | `localhost:6379` | StackExchange.Redis connection string pointing to a DragonFly instance. Used for three purposes: distributed output cache (GET responses), server-side BFF session store (`DragonflyTicketStore`), and shared DataProtection key ring. **Omit or leave empty** to fall back to in-memory cache — suitable for single-instance development only. |
### Authentication — Keycloak
| Key | Example Value | Description |
-| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+|-------------------------------|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Keycloak:auth-server-url` | `http://localhost:8180/` | Base URL of the Keycloak server. Used for JWT token validation (OIDC discovery endpoint) and BFF OIDC login flow. |
| `Keycloak:realm` | `api-template` | Name of the Keycloak realm that issues tokens for this application. |
| `Keycloak:resource` | `api-template` | Keycloak client ID. Must match the client configured in the realm. Used as the JWT `aud` (audience) claim. |
@@ -446,7 +488,7 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe
### BFF Cookie Session
| Key | Example Value | Description |
-| ---------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|------------------------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Bff:CookieName` | `.APITemplate.Auth` | Name of the `httpOnly` session cookie issued after a successful BFF login. The cookie contains only a session key — the actual auth ticket is stored in DragonFly. |
| `Bff:SessionTimeoutMinutes` | `60` | How long the BFF session cookie remains valid after the last activity. |
| `Bff:PostLogoutRedirectUri` | `/` | URI the browser is redirected to after `GET /api/v1/bff/logout` completes the Keycloak back-channel logout. |
@@ -456,14 +498,14 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe
### Rate Limiting
| Key | Example Value | Description |
-| ---------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+|------------------------------------|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `RateLimiting:Fixed:PermitLimit` | `100` | Maximum number of requests allowed per client within a single window. Partition key: JWT username → remote IP → `"anonymous"`. Exceeded requests receive HTTP 429. |
| `RateLimiting:Fixed:WindowMinutes` | `1` | Duration of the fixed rate-limit window in minutes. The counter resets at the end of each window. |
### Output Caching
| Key | Example Value | Description |
-| ------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|---------------------------------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Caching:ProductsExpirationSeconds` | `30` | Cache TTL for the `Products` output-cache policy applied to `GET /api/v1/Products` and `GET /api/v1/Products/{id}`. Entries are also evicted immediately when any product mutation publishes `ProductsChangedNotification`. |
| `Caching:CategoriesExpirationSeconds` | `60` | Cache TTL for the `Categories` output-cache policy. |
| `Caching:ReviewsExpirationSeconds` | `30` | Cache TTL for the `Reviews` output-cache policy. |
@@ -471,7 +513,7 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe
### Persistence & Transactions
| Key | Example Value | Description |
-| -------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|----------------------------------------------|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Persistence:Transactions:IsolationLevel` | `ReadCommitted` | Default SQL isolation level for explicit `IUnitOfWork.ExecuteInTransactionAsync(...)` calls. Accepted values: `ReadUncommitted`, `ReadCommitted`, `RepeatableRead`, `Serializable`. Per-call overrides are possible via `TransactionOptions`. |
| `Persistence:Transactions:TimeoutSeconds` | `30` | Command timeout applied to the database connection while an explicit transaction is active. Prevents long-running transactions from holding locks indefinitely. |
| `Persistence:Transactions:RetryEnabled` | `true` | Enables the Npgsql EF Core execution strategy that automatically retries the entire transaction block on transient failures (e.g. connection drops, deadlocks). |
@@ -481,7 +523,7 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe
### Bootstrap & Identity
| Key | Example Value | Description |
-| ------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+|---------------------------------|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| `Bootstrap:Tenant:Code` | `default` | Short code of the seed tenant created automatically on first startup if no tenants exist yet. Used as the default tenant for the seeded admin user. |
| `Bootstrap:Tenant:Name` | `Default Tenant` | Human-readable display name of the seed tenant. |
| `SystemIdentity:DefaultActorId` | `00000000-0000-0000-0000-000000000000` | Fallback `CreatedBy` / `UpdatedBy` GUID stamped in audit fields when no authenticated user is present (e.g. during startup seeding). |
@@ -489,26 +531,28 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe
### CORS
| Key | Example Value | Description |
-| --------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+|-----------------------|-----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Cors:AllowedOrigins` | `["http://localhost:3000","http://localhost:5173"]` | List of origins permitted by the default CORS policy. Add your SPA development server and production domain here. Requests from unlisted origins will be blocked by the browser preflight check. |
-> **Security note:** `Keycloak:credentials:secret` must be supplied via an environment variable or secret manager in production — never from a committed config file.
+> **Security note:** `Keycloak:credentials:secret` must be supplied via an environment variable or secret manager in
+> production — never from a committed config file.
---
## 🔐 Authentication
-Authentication is handled by **Keycloak** using a hybrid approach that supports both **JWT Bearer tokens** (for API clients and Scalar) and **BFF Cookie sessions** (for SPA frontends).
+Authentication is handled by **Keycloak** using a hybrid approach that supports both **JWT Bearer tokens** (for API
+clients and Scalar) and **BFF Cookie sessions** (for SPA frontends).
| Flow | Use Case | How it works |
-| -------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
+|----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------------------|
| **JWT Bearer** | Scalar UI, API clients, service-to-service | `Authorization: Bearer ` header |
| **BFF Cookie** | SPA frontend | `/api/v1/bff/login` → Keycloak login → session cookie → direct API calls with cookie + `X-CSRF: 1` header |
#### BFF Production Hardening
| Feature | Detail |
-| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Secure cookie** | `CookieSecurePolicy.Always` in production; `SameAsRequest` in development |
| **Server-side session store** | `DragonflyTicketStore` serialises the auth ticket to DragonFly — the cookie contains only a GUID key, keeping cookie size small and preventing token leakage |
| **Shared DataProtection keys** | Keys persisted to DragonFly under `DataProtection:Keys` so multiple instances can decrypt each other's cookies |
@@ -517,12 +561,12 @@ Authentication is handled by **Keycloak** using a hybrid approach that supports
### BFF Endpoints
-| Method | Path | Auth | Description |
-| ------ | -------------------- | :---: | ---------------------------------------------------------------- |
-| `GET` | `/api/v1/bff/login` | ❌ | Redirects to Keycloak login page |
-| `GET` | `/api/v1/bff/logout` | 🍪 | Signs out from both cookie and Keycloak |
-| `GET` | `/api/v1/bff/user` | 🍪 | Returns current user info (id, username, email, tenantId, roles) |
-| `GET` | `/api/v1/bff/csrf` | ❌ | Returns the required CSRF header name and value (`X-CSRF: 1`) |
+| Method | Path | Auth | Description |
+|--------|----------------------|:----:|------------------------------------------------------------------|
+| `GET` | `/api/v1/bff/login` | ❌ | Redirects to Keycloak login page |
+| `GET` | `/api/v1/bff/logout` | 🍪 | Signs out from both cookie and Keycloak |
+| `GET` | `/api/v1/bff/user` | 🍪 | Returns current user info (id, username, email, tenantId, roles) |
+| `GET` | `/api/v1/bff/csrf` | ❌ | Returns the required CSRF header name and value (`X-CSRF: 1`) |
### Manual Testing Guide
@@ -546,7 +590,8 @@ Authentication is handled by **Keycloak** using a hybrid approach that supports
1. Open `http://localhost:5174/api/v1/bff/login` in a browser
2. Log in with `admin` / `Admin123` on the Keycloak page
-3. After redirect, call API endpoints directly in the browser — the session cookie is sent automatically with every request
+3. After redirect, call API endpoints directly in the browser — the session cookie is sent automatically with every
+ request
4. Check your session: `http://localhost:5174/api/v1/bff/user`
#### Option C: Direct token via cURL
@@ -561,12 +606,17 @@ TOKEN=$(curl -s -X POST http://localhost:8180/realms/api-template/protocol/openi
curl -H "Authorization: Bearer $TOKEN" http://localhost:5174/api/v1/products
```
-> **Note:** Direct Access Grants (password grant) is disabled by default. Enable it in Keycloak Admin (`http://localhost:8180/admin` → api-template client → Settings) if needed.
+> **Note:** Direct Access Grants (password grant) is disabled by default. Enable it in Keycloak Admin (
+`http://localhost:8180/admin` → api-template client → Settings) if needed.
### ⚡ GraphQL DataLoaders (N+1 Problem Solved)
-By leveraging HotChocolate's built-in **DataLoaders** pipeline (`ProductReviewsByProductDataLoader`), fetching deeply nested parent-child relationships avoids querying the database `n` times. The framework collects IDs requested entirely within the GraphQL query, then queries the underlying EF Core PostgreSQL implementation precisely *once*.
+
+By leveraging HotChocolate's built-in **DataLoaders** pipeline (`ProductReviewsByProductDataLoader`), fetching deeply
+nested parent-child relationships avoids querying the database `n` times. The framework collects IDs requested entirely
+within the GraphQL query, then queries the underlying EF Core PostgreSQL implementation precisely *once*.
**Example GraphQL Query:**
+
```graphql
query {
products(input: { pageNumber: 1, pageSize: 10 }) {
@@ -588,6 +638,7 @@ query {
```
**Example GraphQL Mutation:**
+
```graphql
mutation {
createProducts(input: {
@@ -609,32 +660,46 @@ mutation {
## 🏆 Design Patterns & Best Practices
-This template deliberately applies a number of industry-accepted patterns. Understanding *why* each pattern is used helps when extending the solution.
+This template deliberately applies a number of industry-accepted patterns. Understanding *why* each pattern is used
+helps when extending the solution.
### 1 — Repository Pattern
-Every data-store interaction is hidden behind a typed interface defined in `Domain/Interfaces/`. Application services depend only on `IProductRepository`, `ICategoryRepository`, etc., while controllers depend on those services — never directly on `AppDbContext` or `IMongoCollection`.
+Every data-store interaction is hidden behind a typed interface defined in `Domain/Interfaces/`. Application services
+depend only on `IProductRepository`, `ICategoryRepository`, etc., while controllers depend on those services — never
+directly on `AppDbContext` or `IMongoCollection`.
**Benefits:**
+
- Database provider can be swapped without touching business logic.
- Repositories can be replaced with in-memory fakes or Moq mocks in tests.
### 2 — Unit of Work Pattern
-`IUnitOfWork` (implemented by `UnitOfWork`) is the only commit boundary for relational persistence. Repositories stage changes in EF Core's change tracker, but they never call `SaveChangesAsync` directly. Relational write services call `ExecuteInTransactionAsync(...)` directly when they need an explicit transaction boundary.
+`IUnitOfWork` (implemented by `UnitOfWork`) is the only commit boundary for relational persistence. Repositories stage
+changes in EF Core's change tracker, but they never call `SaveChangesAsync` directly. Relational write services call
+`ExecuteInTransactionAsync(...)` directly when they need an explicit transaction boundary.
**Rules:**
+
- Query services own API/read-model reads that return DTOs.
-- Paginated, filtered, cross-aggregate, and batching reads belong in query services, usually backed by specifications or projections.
+- Paginated, filtered, cross-aggregate, and batching reads belong in query services, usually backed by specifications or
+ projections.
- Command-side validation lookups stay in the write service and use repositories directly.
- Write services load entities they intend to mutate through repositories, not query services.
- `ExecuteInTransactionAsync(...)` is the explicit relational transaction entry point used by services.
-- Some single-write flows do not strictly require an explicit transaction; use `CommitAsync()` when a direct save is enough and `ExecuteInTransactionAsync(...)` when you want one explicit transaction shape.
-- `Persistence:Transactions` configures the default isolation level, timeout, and retry policy for explicit relational transactions.
-- Explicit transactional writes run inside EF Core's execution strategy so the full transaction block can be replayed on transient provider failures.
-- Nested transactional writes use savepoints inside the current `UnitOfWork` transaction instead of opening a second top-level transaction.
-- Per-call overrides use `ExecuteInTransactionAsync(action, ct, new TransactionOptions { ... })`; effective policy is `configured defaults + per-call override`.
-- Nested transaction calls inherit the active outer policy. Passing conflicting nested options fails fast instead of silently changing isolation, timeout, or retry behavior.
+- Some single-write flows do not strictly require an explicit transaction; use `CommitAsync()` when a direct save is
+ enough and `ExecuteInTransactionAsync(...)` when you want one explicit transaction shape.
+- `Persistence:Transactions` configures the default isolation level, timeout, and retry policy for explicit relational
+ transactions.
+- Explicit transactional writes run inside EF Core's execution strategy so the full transaction block can be replayed on
+ transient provider failures.
+- Nested transactional writes use savepoints inside the current `UnitOfWork` transaction instead of opening a second
+ top-level transaction.
+- Per-call overrides use `ExecuteInTransactionAsync(action, ct, new TransactionOptions { ... })`; effective policy is
+ `configured defaults + per-call override`.
+- Nested transaction calls inherit the active outer policy. Passing conflicting nested options fails fast instead of
+ silently changing isolation, timeout, or retry behavior.
```csharp
// Wraps two repository writes in a single database transaction
@@ -674,7 +739,8 @@ await _unitOfWork.ExecuteInTransactionAsync(async () =>
### 3 — Specification Pattern (Ardalis.Specification)
-Query logic — filtering, ordering, pagination — lives in reusable `Specification` classes rather than being scattered across services or repositories. A single `ProductSpecification` encapsulates all product-list query rules.
+Query logic — filtering, ordering, pagination — lives in reusable `Specification` classes rather than being
+scattered across services or repositories. A single `ProductSpecification` encapsulates all product-list query rules.
```csharp
// Application/Specifications/ProductSpecification.cs
@@ -692,13 +758,15 @@ public sealed class ProductSpecification : Specification : AbstractValidator
}
```
-Validator classes are auto-discovered via `AddValidatorsFromAssemblyContaining()` — no manual registration needed.
+Validator classes are auto-discovered via `AddValidatorsFromAssemblyContaining()` — no
+manual registration needed.
### 5 — Global Exception Handling (`IExceptionHandler` + ProblemDetails)
-`ApiExceptionHandler` sits in the ASP.NET exception pipeline (`UseExceptionHandler`) and converts typed `AppException` instances into RFC 7807 `ProblemDetails` responses. HTTP status/title are mapped by exception type (`ValidationException`, `NotFoundException`, `ConflictException`, `ForbiddenException`), while `ErrorCode` is resolved from `AppException.ErrorCode` or metadata fallback. `DbUpdateConcurrencyException` is mapped directly to HTTP 409.
+`ApiExceptionHandler` sits in the ASP.NET exception pipeline (`UseExceptionHandler`) and converts typed `AppException`
+instances into RFC 7807 `ProblemDetails` responses. HTTP status/title are mapped by exception type (
+`ValidationException`, `NotFoundException`, `ConflictException`, `ForbiddenException`), while `ErrorCode` is resolved
+from `AppException.ErrorCode` or metadata fallback. `DbUpdateConcurrencyException` is mapped directly to HTTP 409.
| Exception type | HTTP Status | Logged at |
-| ------------------------------ | ----------- | --------- |
+|--------------------------------|-------------|-----------|
| `NotFoundException` | 404 | Warning |
| `ValidationException` | 400 | Warning |
| `ForbiddenException` | 403 | Warning |
@@ -731,6 +803,7 @@ Validator classes are auto-discovered via `AddValidatorsFromAssemblyContaining` so integration tests can override them without rebuilding the host.
+Limits are configured in `appsettings.json` under `RateLimiting:Fixed` and resolved via `IOptions`
+so integration tests can override them without rebuilding the host.
### 9 — Output Caching (Tenant-Isolated, DragonFly-backed)
-GET endpoints on Products, Categories, and Reviews use `[OutputCache(PolicyName = ...)]` with the `TenantAwareOutputCachePolicy`. This policy:
+GET endpoints on Products, Categories, and Reviews use `[OutputCache(PolicyName = ...)]` with the
+`TenantAwareOutputCachePolicy`. This policy:
1. **Enables caching for authenticated requests** (ASP.NET Core's default skips Authorization-header requests).
2. **Varies the cache key by tenant ID** so one tenant never receives another tenant's cached response.
-When `Dragonfly:ConnectionString` is configured, all cache entries are stored in **DragonFly** so every application instance shares a single distributed cache. Without it, each instance maintains its own in-memory cache.
+When `Dragonfly:ConnectionString` is configured, all cache entries are stored in **DragonFly** so every application
+instance shares a single distributed cache. Without it, each instance maintains its own in-memory cache.
-Mutations (Create / Update / Delete) evict the relevant tag via `IOutputCacheStore.EvictByTagAsync()` so stale data is immediately invalidated.
+Mutations (Create / Update / Delete) evict the relevant tag via `IOutputCacheStore.EvictByTagAsync()` so stale data is
+immediately invalidated.
### 10 — GraphQL Security & Performance Guards
HotChocolate is configured with several safeguards:
| Guard | Setting | Purpose |
-| ----------------------------- | ---------------------- | ------------------------------------------------- |
+|-------------------------------|------------------------|---------------------------------------------------|
| `MaxPageSize` | 100 | Prevents unbounded result sets |
| `DefaultPageSize` | 20 | Sensible default for clients |
| `AddMaxExecutionDepthRule(5)` | depth ≤ 5 | Prevents deeply nested query attacks |
@@ -824,7 +905,9 @@ GraphQL query and mutation fields are protected with `[Authorize]`.
### 11 — Automatic Schema Migration at Startup
-`UseDatabaseAsync()` runs EF Core migrations, auth bootstrap seeding, and MongoDB migrations automatically on startup. This means a fresh container deployment is fully self-initialising — no manual `dotnet ef database update` step required in production.
+`UseDatabaseAsync()` runs EF Core migrations, auth bootstrap seeding, and MongoDB migrations automatically on startup.
+This means a fresh container deployment is fully self-initialising — no manual `dotnet ef database update` step required
+in production.
```csharp
// Extensions/ApplicationBuilderExtensions.cs
@@ -848,7 +931,7 @@ Only the compiled artefacts from Stage 2 are copied into the slim Stage 3 runtim
### 13 — Polyglot Persistence Decision Guide
| Data characteristic | Recommended store |
-| ----------------------------------- | ----------------------------- |
+|-------------------------------------|-------------------------------|
| Relational data with foreign keys | PostgreSQL |
| Fixed, well-defined schema | PostgreSQL |
| ACID transactions across tables | PostgreSQL |
@@ -859,7 +942,9 @@ Only the compiled artefacts from Stage 2 are copied into the slim Stage 3 runtim
### 14 — Message Dispatch + CQRS Pattern (WolverineFx)
-All application logic is dispatched through **WolverineFx**. Controllers and GraphQL resolvers never call services directly — they send a typed command or query object through `IMessageBus`, and Wolverine routes it to the correct handler by convention.
+All application logic is dispatched through **WolverineFx**. Controllers and GraphQL resolvers never call services
+directly — they send a typed command or query object through `IMessageBus`, and Wolverine routes it to the correct
+handler by convention.
```
Controller / GraphQL Resolver
@@ -876,7 +961,8 @@ Controller / GraphQL Resolver
#### Commands and Queries
-Each feature vertical defines commands/queries as plain records, each with a dedicated handler class containing a static `HandleAsync` method. Dependencies are injected as method parameters:
+Each feature vertical defines commands/queries as plain records, each with a dedicated handler class containing a static
+`HandleAsync` method. Dependencies are injected as method parameters:
```csharp
// Application/Features/Product/Queries/GetProductsQuery.cs
@@ -937,7 +1023,9 @@ GraphQL resolvers and DataLoaders follow the same pattern using `[Service] IMess
#### Cache invalidation via IMessageBus
-Write handlers publish cache invalidation events after a successful mutation using `IMessageBus.PublishAsync`. A dedicated handler listens and evicts the affected output-cache tags — keeping the mutation handler decoupled from any caching concern:
+Write handlers publish cache invalidation events after a successful mutation using `IMessageBus.PublishAsync`. A
+dedicated handler listens and evicts the affected output-cache tags — keeping the mutation handler decoupled from any
+caching concern:
```csharp
// Application/Common/Events/CacheInvalidationNotification.cs
@@ -949,7 +1037,9 @@ await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Products));
#### FluentValidation middleware
-Wolverine's `UseFluentValidation()` middleware runs before every handler. It collects all `FluentValidation` failures for the request and throws a domain `ValidationException` if any fail — so handler code never receives invalid input. No manual pipeline behavior registration is needed.
+Wolverine's `UseFluentValidation()` middleware runs before every handler. It collects all `FluentValidation` failures
+for the request and throws a domain `ValidationException` if any fail — so handler code never receives invalid input. No
+manual pipeline behavior registration is needed.
#### DI registration
@@ -966,9 +1056,13 @@ builder.Host.UseWolverine(opts =>
```
**Benefits:**
-- Controllers and GraphQL resolvers are free of business logic — they only translate HTTP/GraphQL inputs to commands/queries.
-- Handlers are simple static methods with no base class or interface ceremony — dependencies arrive as method parameters.
-- Adding a new cross-cutting concern (logging, authorisation checks, timing) requires only a new Wolverine middleware (Before/After conventions) — no changes to any handler.
+
+- Controllers and GraphQL resolvers are free of business logic — they only translate HTTP/GraphQL inputs to
+ commands/queries.
+- Handlers are simple static methods with no base class or interface ceremony — dependencies arrive as method
+ parameters.
+- Adding a new cross-cutting concern (logging, authorisation checks, timing) requires only a new Wolverine middleware (
+ Before/After conventions) — no changes to any handler.
- Each command or query is an explicit, named contract; the full request/response shape is visible at a glance.
- Handler classes are independently unit-testable by directly instantiating them with mocked repositories.
@@ -976,12 +1070,13 @@ builder.Host.UseWolverine(opts =>
## 🗄 Stored Procedure Pattern (EF Core + PostgreSQL)
-EF Core's `FromSql()` lets you call stored procedures while still getting full object materialisation and parameterised queries. The pattern below is used for the `GET /api/v1/categories/{id}/stats` endpoint.
+EF Core's `FromSql()` lets you call stored procedures while still getting full object materialisation and parameterised
+queries. The pattern below is used for the `GET /api/v1/categories/{id}/stats` endpoint.
### When to use a stored procedure
| Situation | Use LINQ | Use Stored Procedure |
-| ----------------------------------- | -------- | -------------------- |
+|-------------------------------------|----------|----------------------|
| Simple CRUD filtering / paging | ✅ | |
| Complex multi-table aggregations | | ✅ |
| Reusable DB-side business logic | | ✅ |
@@ -1089,12 +1184,13 @@ ProductCategoryStatsResponse (DTO returned to client)
## 🍃 MongoDB Polymorphic Pattern (ProductData)
-The `ProductData` feature demonstrates a **polymorphic document model** in MongoDB, where a single collection stores two distinct subtypes (`ImageProductData`, `VideoProductData`) using the BSON discriminator pattern.
+The `ProductData` feature demonstrates a **polymorphic document model** in MongoDB, where a single collection stores two
+distinct subtypes (`ImageProductData`, `VideoProductData`) using the BSON discriminator pattern.
### When to use MongoDB vs PostgreSQL
| Situation | Use PostgreSQL | Use MongoDB |
-| ----------------------------------- | -------------- | ----------- |
+|-------------------------------------|----------------|-------------|
| Relational data with foreign keys | ✅ | |
| Fixed, well-defined schema | ✅ | |
| ACID transactions across tables | ✅ | |
@@ -1139,14 +1235,15 @@ public sealed class VideoProductData : ProductData
}
```
-MongoDB stores a `_t` discriminator field automatically, enabling polymorphic queries against the single `product_data` collection.
+MongoDB stores a `_t` discriminator field automatically, enabling polymorphic queries against the single `product_data`
+collection.
### REST endpoints
Base route: `api/v{version}/product-data` — all endpoints require JWT authorization.
| Method | Endpoint | Request | Response | Purpose |
-| -------- | -------- | ------------------------------- | --------------------------- | -------------------------- |
+|----------|----------|---------------------------------|-----------------------------|----------------------------|
| `GET` | `/` | Query: `type` (optional) | `List` | List all or filter by type |
| `GET` | `/{id}` | MongoDB ObjectId string | `ProductDataResponse` / 404 | Get by ID |
| `POST` | `/image` | `CreateImageProductDataRequest` | `ProductDataResponse` 201 | Create image metadata |
@@ -1181,27 +1278,32 @@ ProductDataResponse (Type, Id, Title, Width, Height, Format, ...)
## 🚀 CI/CD & Deployments
-While not natively shipped via default configuration files, this structure allows simple portability across cloud ecosystems:
+While not natively shipped via default configuration files, this structure allows simple portability across cloud
+ecosystems:
**GitHub Actions / Azure Pipelines Structure:**
+
1. **Restore:** `dotnet restore APITemplate.slnx`
2. **Build:** `dotnet build --no-restore APITemplate.slnx`
3. **Test:** `dotnet test --no-build APITemplate.slnx`
4. **Publish Container:** `docker build -t apitemplate-image:1.0 -f src/APITemplate/Api/Dockerfile .`
5. **Push Registry:** `docker push /apitemplate-image:1.0`
-Because the application encompasses the database (natively via DI) and HTTP context fully self-contained using containerization, it scales efficiently behind Kubernetes Ingress (Nginx) or any App Service / Container Apps equivalent, maintaining state natively using PostgreSQL and MongoDB.
+Because the application encompasses the database (natively via DI) and HTTP context fully self-contained using
+containerization, it scales efficiently behind Kubernetes Ingress (Nginx) or any App Service / Container Apps
+equivalent, maintaining state natively using PostgreSQL and MongoDB.
---
## 🧪 Testing
-The repository maintains an inclusive combination of **Unit Tests** and **Integration Tests** executing over a seamless Test-Host infrastructure.
+The repository maintains an inclusive combination of **Unit Tests** and **Integration Tests** executing over a seamless
+Test-Host infrastructure.
### Test structure
| Folder | Technology | What it tests |
-| ------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------- |
+|---------------------------------------------------|-------------------------------------|----------------------------------------------------------------------------------------|
| `tests/APITemplate.Tests/Unit/Services/` | xUnit + Moq | Service business logic in isolation |
| `tests/APITemplate.Tests/Unit/Repositories/` | xUnit + Moq | Repository filtering/query logic |
| `tests/APITemplate.Tests/Unit/Validators/` | xUnit + FluentValidation.TestHelper | Validator rules per DTO |
@@ -1211,7 +1313,9 @@ The repository maintains an inclusive combination of **Unit Tests** and **Integr
### Integration test isolation
-`CustomWebApplicationFactory` replaces the Npgsql provider with `UseInMemoryDatabase`, removes `MongoDbContext`, and registers a mocked `IProductDataRepository` so DI validation passes. Each test class gets its own database name (a fresh `Guid`) so tests never share state.
+`CustomWebApplicationFactory` replaces the Npgsql provider with `UseInMemoryDatabase`, removes `MongoDbContext`, and
+registers a mocked `IProductDataRepository` so DI validation passes. Each test class gets its own database name (a fresh
+`Guid`) so tests never share state.
```csharp
// Each factory instance gets its own isolated in-memory database
@@ -1241,17 +1345,20 @@ dotnet test --filter "Category=Integration.Postgres"
## 🏃 Getting Started
### Prerequisites
-* [.NET 10 SDK installed locally](https://dotnet.microsoft.com/)
-* [Docker Desktop](https://www.docker.com/) (Optional, convenient for running infrastructure).
+
+* [.NET 10 SDK installed locally](https://dotnet.microsoft.com/)
+* [Docker Desktop](https://www.docker.com/) (Optional, convenient for running infrastructure).
### Quick Start (Using Docker Compose)
-The template consists of a ready-to-use Docker environment to spool up PostgreSQL, MongoDB, Keycloak, DragonFly, and the built API container:
+The template consists of a ready-to-use Docker environment to spool up PostgreSQL, MongoDB, Keycloak, DragonFly, and the
+built API container:
```bash
# Start up all services including the API container
docker compose up -d --build
```
+
> The API will bind natively to `http://localhost:8080`.
### Running Locally without Containerization
@@ -1274,6 +1381,7 @@ EF Core migrations and MongoDB migrations run automatically at startup — no ma
### Available Endpoints & User Interfaces
Once fully spun up under a Development environment, check out:
+
- **Interactive REST API Documentation (Scalar):** `http://localhost:/scalar`
- **Native GraphQL IDE (Nitro UI):** `http://localhost:/graphql/ui`
- **Environment & Database Health Check:** `http://localhost:/health`
diff --git a/TODO.md b/TODO.md
index dfab2ccf..73abfb38 100644
--- a/TODO.md
+++ b/TODO.md
@@ -4,57 +4,99 @@
### Critical
-- [x] **Authorization code duplication** — `PermissionAuthorizationHandler` and `PermissionPolicyProvider` exist in both `APITemplate.Api/Api/Authorization/` and `Identity.Api/Authorization/` with divergent implementations (different constructors, auth schemes, `[SensitiveData]` attributes). Move to SharedKernel or Contracts as the single source of truth.
+- [x] **Authorization code duplication** — `PermissionAuthorizationHandler` and `PermissionPolicyProvider` exist in both
+ `APITemplate.Api/Api/Authorization/` and `Identity.Api/Authorization/` with divergent implementations (different
+ constructors, auth schemes, `[SensitiveData]` attributes). Move to SharedKernel or Contracts as the single source of
+ truth.
### High Priority
-- [x] **Mixed error handling patterns** — unified on `ErrorOr` return pattern. Exception-based classes (`NotFoundException`, `ConflictException`, `ValidationException`, `AppException` hierarchy) removed. `ApiExceptionHandler` is now a safety net for `DbUpdateConcurrencyException` (409) and unhandled exceptions (500) only. `FluentValidationActionFilter` removed; validation runs through Wolverine middleware.
-- [x] **Options classes split between SharedKernel and modules** — `BffOptions`, `KeycloakOptions`, `CorsOptions`, `EmailOptions`, `SystemIdentityOptions` exist in both places. Module-specific options (`BackgroundJobsOptions`, `FileStorageOptions`) are in SharedKernel where they don't belong. Each module should own its options; SharedKernel should contain only truly shared types.
-- [ ] **Anemic domain models** — `Tenant`, `StoredFile` and others are pure data containers. Business logic (activate/deactivate, status transitions) leaks into application handlers. Add domain methods and enforce invariants in entity constructors.
+- [x] **Mixed error handling patterns** — unified on `ErrorOr` return pattern. Exception-based classes (
+ `NotFoundException`, `ConflictException`, `ValidationException`, `AppException` hierarchy) removed.
+ `ApiExceptionHandler` is now a safety net for `DbUpdateConcurrencyException` (409) and unhandled exceptions (500)
+ only. `FluentValidationActionFilter` removed; validation runs through Wolverine middleware.
+- [x] **Options classes split between SharedKernel and modules** — `BffOptions`, `KeycloakOptions`, `CorsOptions`,
+ `EmailOptions`, `SystemIdentityOptions` exist in both places. Module-specific options (`BackgroundJobsOptions`,
+ `FileStorageOptions`) are in SharedKernel where they don't belong. Each module should own its options; SharedKernel
+ should contain only truly shared types.
+- [ ] **Anemic domain models** — `Tenant`, `StoredFile` and others are pure data containers. Business logic (
+ activate/deactivate, status transitions) leaks into application handlers. Add domain methods and enforce invariants in
+ entity constructors.
### Medium Priority
-- [ ] **Business logic in handlers** — `CreateProductsCommand` creates entities and relationships directly in the handler. `CreateUserCommand` contains compensating transaction logic (Keycloak + DB rollback). Extract to factory methods on entities and domain services.
-- [ ] **Inconsistent logging** — only `ApiExceptionHandlerLogs.cs` and `UnitOfWorkLogs.cs` use source-generated `[LoggerMessage]` with event IDs. All other modules use inline `logger.LogXxx()`. Adopt source-generated logging with a per-module event ID range allocation strategy.
-- [x] **Incomplete health checks** — only PostgreSQL and Keycloak are covered. Missing: Redis/Dragonfly, MongoDB (used by ProductCatalog), Wolverine messaging. Add `AddDragonflyHealthCheck()`, `AddMongoDbHealthCheck()` using the existing helper extension pattern.
-- [ ] **Soft delete cascade via three mechanisms** — the same business rule (cascade deletes on soft-delete) is implemented via database cascade rules, infrastructure `SoftDeleteProcessor`, and Wolverine event handlers simultaneously. Consolidate to event-driven approach only.
-- [ ] **`ClearCategoryAsync` bypasses EF Core change tracker** — `ExecuteUpdateAsync` is a bulk SQL operation that skips the DbContext tracker. If products are tracked in the same session (e.g. loaded during validation), their in-memory `CategoryId` stays non-null while the DB has `null`; a subsequent `SaveChanges` would overwrite the DB back. Verify no tracked products overlap with the bulk update, or invalidate affected entries after the call.
-- [ ] **Missing `CategorySoftDeletedNotification`** — category soft-delete (both `DeleteCategoriesCommand` and `TenantCascadeDeleteHandler`) publishes no notification. Product soft-delete publishes `ProductSoftDeletedNotification` which Reviews consumes. Any future module needing to react to category deletion has no hook. Add a `CategorySoftDeletedNotification` and publish it from both delete paths.
+- [ ] **Business logic in handlers** — `CreateProductsCommand` creates entities and relationships directly in the
+ handler. `CreateUserCommand` contains compensating transaction logic (Keycloak + DB rollback). Extract to factory
+ methods on entities and domain services.
+- [ ] **Inconsistent logging** — only `ApiExceptionHandlerLogs.cs` and `UnitOfWorkLogs.cs` use source-generated
+ `[LoggerMessage]` with event IDs. All other modules use inline `logger.LogXxx()`. Adopt source-generated logging with
+ a per-module event ID range allocation strategy.
+- [x] **Incomplete health checks** — only PostgreSQL and Keycloak are covered. Missing: Redis/Dragonfly, MongoDB (used
+ by ProductCatalog), Wolverine messaging. Add `AddDragonflyHealthCheck()`, `AddMongoDbHealthCheck()` using the existing
+ helper extension pattern.
+- [ ] **Soft delete cascade via three mechanisms** — the same business rule (cascade deletes on soft-delete) is
+ implemented via database cascade rules, infrastructure `SoftDeleteProcessor`, and Wolverine event handlers
+ simultaneously. Consolidate to event-driven approach only.
+- [ ] **`ClearCategoryAsync` bypasses EF Core change tracker** — `ExecuteUpdateAsync` is a bulk SQL operation that skips
+ the DbContext tracker. If products are tracked in the same session (e.g. loaded during validation), their in-memory
+ `CategoryId` stays non-null while the DB has `null`; a subsequent `SaveChanges` would overwrite the DB back. Verify no
+ tracked products overlap with the bulk update, or invalidate affected entries after the call.
+- [ ] **Missing `CategorySoftDeletedNotification`** — category soft-delete (both `DeleteCategoriesCommand` and
+ `TenantCascadeDeleteHandler`) publishes no notification. Product soft-delete publishes
+ `ProductSoftDeletedNotification` which Reviews consumes. Any future module needing to react to category deletion has
+ no hook. Add a `CategorySoftDeletedNotification` and publish it from both delete paths.
### Low Priority
-- [ ] **Aggregate boundary violation** — `Product` entity has `Category? Category` navigation property — a direct reference to another aggregate root. Replace with `CategoryId`-only reference; load via query when needed.
-- [ ] **Missing value objects** — `Email` (string with no RFC validation), `Rating` (int with no range enforcement), `Price` (no currency/precision semantics), `TenantCode` (string with implicit format rules) should be strong value objects enforcing their invariants.
-- [ ] **Duplicate repository interfaces** — `IProductRepository` is defined in both `ProductCatalog.Domain/Interfaces/` and `ProductCatalog.Application/Features/Product/Repositories/`. Keep one definition in the Domain layer.
-- [ ] **Integration test gap — `ProductDataLinks` cascade not verified** — `PostgresTenantSoftDeleteCascadeTests` verifies products and categories are soft-deleted but does not assert `ProductDataLinks` are also soft-deleted in the same cascade. Add assertion to guard against silent regression.
-- [ ] **`ProductDataLink` unique constraint** — `Product.SyncProductDataLinks` was previously guarded with `GroupBy().First()` to survive duplicate `ProductDataId` entries; simplified to `ToDictionary()` which throws on duplicates. Verify a unique constraint on `(ProductId, ProductDataId)` exists in the schema; add the migration if missing.
+- [ ] **Aggregate boundary violation** — `Product` entity has `Category? Category` navigation property — a direct
+ reference to another aggregate root. Replace with `CategoryId`-only reference; load via query when needed.
+- [ ] **Missing value objects** — `Email` (string with no RFC validation), `Rating` (int with no range enforcement),
+ `Price` (no currency/precision semantics), `TenantCode` (string with implicit format rules) should be strong value
+ objects enforcing their invariants.
+- [ ] **Duplicate repository interfaces** — `IProductRepository` is defined in both `ProductCatalog.Domain/Interfaces/`
+ and `ProductCatalog.Application/Features/Product/Repositories/`. Keep one definition in the Domain layer.
+- [ ] **Integration test gap — `ProductDataLinks` cascade not verified** — `PostgresTenantSoftDeleteCascadeTests`
+ verifies products and categories are soft-deleted but does not assert `ProductDataLinks` are also soft-deleted in the
+ same cascade. Add assertion to guard against silent regression.
+- [ ] **`ProductDataLink` unique constraint** — `Product.SyncProductDataLinks` was previously guarded with
+ `GroupBy().First()` to survive duplicate `ProductDataId` entries; simplified to `ToDictionary()` which throws on
+ duplicates. Verify a unique constraint on `(ProductId, ProductDataId)` exists in the schema; add the migration if
+ missing.
---
## Wolverine Outbox & Durable Messaging
-- [ ] Enable `UseDurableOutboxOnAllSendingEndpoints()` and `UseDurableInboxOnAllListeners()` for reliable eventual consistency across modules.
+- [ ] Enable `UseDurableOutboxOnAllSendingEndpoints()` and `UseDurableInboxOnAllListeners()` for reliable eventual
+ consistency across modules.
- [x] Configure `PersistMessagesWithPostgresql()` for durable message persistence in PostgreSQL.
- [ ] Apply `DurabilityMode.Balanced` via shared Wolverine conventions (`ApplySharedConventions()`).
-- [x] Migrate handler return types to `(ErrorOr, OutgoingMessages)` tuples for transactional cascade messages instead of manual `bus.PublishAsync()`.
-- [ ] Extract `CacheInvalidationCascades` helper (`.ForTag()`, `.ForTags()`, `.None`) to eliminate cache invalidation boilerplate.
+- [x] Migrate handler return types to `(ErrorOr, OutgoingMessages)` tuples for transactional cascade messages instead
+ of manual `bus.PublishAsync()`.
+- [ ] Extract `CacheInvalidationCascades` helper (`.ForTag()`, `.ForTags()`, `.None`) to eliminate cache invalidation
+ boilerplate.
## Wolverine Validation Middleware
-- [x] Implement `ErrorOrValidationMiddleware` as Wolverine `Before` middleware — automatic FluentValidation for all commands without manual validation in handlers.
-- [x] Add `FluentValidationActionFilter` for MVC controller endpoints (validates action parameters via DI-resolved validators, returns 400 with `ValidationProblemDetails`).
+- [x] Implement `ErrorOrValidationMiddleware` as Wolverine `Before` middleware — automatic FluentValidation for all
+ commands without manual validation in handlers.
+- [x] Add `FluentValidationActionFilter` for MVC controller endpoints (validates action parameters via DI-resolved
+ validators, returns 400 with `ValidationProblemDetails`).
## Integration Events
-- [x] Define typed integration event contracts in `Contracts` project (e.g. `ProductCreatedIntegrationEvent`, `TenantDeactivatedIntegrationEvent`).
+- [x] Define typed integration event contracts in `Contracts` project (e.g. `ProductCreatedIntegrationEvent`,
+ `TenantDeactivatedIntegrationEvent`).
- [x] Add integration event handlers per module for cross-module cascade operations (soft-delete propagation, cleanup).
## Request Context & Observability Enhancements
-- [x] Enhance `RequestContextMiddleware` with tenant ID extraction from claims and Activity tag enrichment for distributed tracing.
+- [x] Enhance `RequestContextMiddleware` with tenant ID extraction from claims and Activity tag enrichment for
+ distributed tracing.
- [x] Add `IHttpMetricsTagsFeature` enrichment (api_surface, authenticated) for custom telemetry dimensions.
- [x] Return `X-Trace-Id` response header alongside existing `X-Correlation-Id` and `X-Elapsed-Ms`.
-- [x] Enhance Serilog request logging with intelligent log levels (499 client abort vs 5xx server error vs 4xx validation).
+- [x] Enhance Serilog request logging with intelligent log levels (499 client abort vs 5xx server error vs 4xx
+ validation).
- [x] Enrich Serilog diagnostic context with `RequestHost` and `RequestScheme`.
## Logging Redaction
@@ -67,52 +109,65 @@
- [x] Add tenant claim validation in JWT bearer configuration — require valid tenant claim or service account prefix.
- [x] Add `KeycloakClaimsPrincipalMapper.MapClaims()` for Keycloak claim transformation.
-- [x] Add `AuthorizationResponsesOperationTransformer` for OpenAPI — automatically document 401/403 on `[Authorize]` endpoints.
+- [x] Add `AuthorizationResponsesOperationTransformer` for OpenAPI — automatically document 401/403 on `[Authorize]`
+ endpoints.
- [x] Add `BearerSecuritySchemeDocumentTransformer` — dynamic Keycloak OAuth2 authorization code flow in OpenAPI.
## Exception Handling Enhancements
-- [x] Enhance `ApiExceptionHandler` with structured error metadata preservation in `ProblemDetails.Extensions["metadata"]`.
-- [x] Add error code fallback logic (check `exception.ErrorCode` then `metadata["errorCode"]` then `ErrorCatalog.General.Unknown`).
+- [x] Enhance `ApiExceptionHandler` with structured error metadata preservation in
+ `ProblemDetails.Extensions["metadata"]`.
+- [x] Add error code fallback logic (check `exception.ErrorCode` then `metadata["errorCode"]` then
+ `ErrorCatalog.General.Unknown`).
- [x] Differentiate logging by status code (LogError for 5xx, LogWarning for handled exceptions).
## Output Caching Enhancements
- [x] Add `TenantAwareOutputCachePolicy` — cache key isolation per tenant to prevent cross-tenant data leaks.
-- [x] Expand cache policies to cover all cacheable resources (Tenants, TenantInvitations, Users, Files alongside existing Products, Categories, Reviews, ProductData).
+- [x] Expand cache policies to cover all cacheable resources (Tenants, TenantInvitations, Users, Files alongside
+ existing Products, Categories, Reviews, ProductData).
## Controller Base Enhancements
-- [ ] Add helper methods to `ApiControllerBase`: `InvokeToActionResultAsync()`, `InvokeToBatchResultAsync()`, `InvokeToNoContentResultAsync()`, `InvokeToOkResultAsync()`, `InvokeToCreatedResultAsync()`.
+- [ ] Add helper methods to `ApiControllerBase`: `InvokeToActionResultAsync()`, `InvokeToBatchResultAsync()`,
+ `InvokeToNoContentResultAsync()`, `InvokeToOkResultAsync()`, `InvokeToCreatedResultAsync()`.
- [ ] Add `ErrorOrHttpExtensions` for minimal API ErrorOr-to-ProblemDetails mapping.
## Configuration Validation
-- [x] Implement `AddValidatedOptions()` extension — automatic DataAnnotations validation with early startup failure on invalid configuration.
+- [x] Implement `AddValidatedOptions()` extension — automatic DataAnnotations validation with early startup
+ failure on invalid configuration.
## Idempotency
-- [x] Implement `IdempotencyActionFilter` — at-most-once semantics via `Idempotency-Key` header with cached responses, configurable TTL, lock timeouts, and 409 Conflict on concurrent processing.
+- [x] Implement `IdempotencyActionFilter` — at-most-once semantics via `Idempotency-Key` header with cached responses,
+ configurable TTL, lock timeouts, and 409 Conflict on concurrent processing.
## Health Check Helpers
-- [x] Extract health check helper extensions: `AddPostgreSqlHealthCheck()`, `AddDragonflyHealthCheck()` with standardized tags and naming.
+- [x] Extract health check helper extensions: `AddPostgreSqlHealthCheck()`, `AddDragonflyHealthCheck()` with
+ standardized tags and naming.
## Infrastructure Generics
-- [x] Make `UnitOfWork` generic over `DbContext` instead of hardcoded to `AppDbContext` — enables reuse across per-module contexts.
+- [x] Make `UnitOfWork` generic over `DbContext` instead of hardcoded to `AppDbContext` — enables reuse across
+ per-module contexts.
- [x] Make `RepositoryBase` accept generic `DbContext` parameter instead of casting to `AppDbContext`.
-- [x] Extract `TenantAuditableDbContext` as abstract reusable base class with `TenantAuditableDbContextDependencies` record for dependency encapsulation. (ModuleDbContext already serves this role)
+- [x] Extract `TenantAuditableDbContext` as abstract reusable base class with `TenantAuditableDbContextDependencies`
+ record for dependency encapsulation. (ModuleDbContext already serves this role)
- [ ] Make `IEntityNormalizationService` optional (nullable) in DbContext — not all modules need normalization.
-- [ ] Improve `DesignTimeConnectionStringResolver` with dynamic path resolution (walk up directory tree) and environment-specific appsettings loading.
+- [ ] Improve `DesignTimeConnectionStringResolver` with dynamic path resolution (walk up directory tree) and
+ environment-specific appsettings loading.
## Entity Navigation Properties
-- [ ] Add explicit bidirectional navigation properties on aggregate roots (e.g. `Tenant.Users`, `AppUser.Tenant`) for better DDD modeling and EF Core relationship configuration.
+- [ ] Add explicit bidirectional navigation properties on aggregate roots (e.g. `Tenant.Users`, `AppUser.Tenant`) for
+ better DDD modeling and EF Core relationship configuration.
## Validation Metrics
-- [ ] Add `IValidationMetrics` interface for recording validation failures with telemetry (source, argument type, failure list) — separates observability from application logic.
+- [ ] Add `IValidationMetrics` interface for recording validation failures with telemetry (source, argument type,
+ failure list) — separates observability from application logic.
---
@@ -149,12 +204,14 @@
Implement real-time notifications and chat using ASP.NET Core SignalR.
**Architecture:**
+
- NotificationHub: job status, data updates, user status
- ChatHub: 1:1, groups, channels
- Redis backplane for multi-instance
- Optional persistence (flexible, add later if needed)
**Implementation:**
+
- [ ] Setup SignalR infrastructure (Hubs, backplane, middleware)
- [ ] NotificationHub: job/product/user status updates
- [ ] ChatHub: 1:1 messaging
@@ -180,8 +237,10 @@ Implement real-time notifications and chat using ASP.NET Core SignalR.
- [x] Add retry jobs for failed notifications.
- [x] Add periodic synchronization tasks for external integrations.
- [x] Cursor-based pagination for orphaned ProductData cleanup to bound memory usage at scale.
-- [x] Distributed locking (`SELECT ... FOR UPDATE SKIP LOCKED` or claim column) for email retry to prevent duplicate sends in multi-instance deployments.
-- [x] Migrate from `PeriodicTimer` to Quartz.NET (or TickerQ) for CRON scheduling, persistent job state, and distributed locking.
+- [x] Distributed locking (`SELECT ... FOR UPDATE SKIP LOCKED` or claim column) for email retry to prevent duplicate
+ sends in multi-instance deployments.
+- [x] Migrate from `PeriodicTimer` to Quartz.NET (or TickerQ) for CRON scheduling, persistent job state, and distributed
+ locking.
## Permissions
@@ -194,15 +253,17 @@ Implement real-time notifications and chat using ASP.NET Core SignalR.
- [ ] Add storage abstraction for local and S3-compatible backends.
- [ ] Add cleanup workflow for orphaned files.
-
## Soft delete and Data Retention
+
- [x] Hard delete for soft-deleted products after a configurable retention period.
- [x] Add workflow for permanently deleting soft-deleted products after retention period.
-- [ ] Wolverine durable outbox or CAP for reliable messaging and eventual consistency in data deletion across related entities. (WolverineFx is now integrated as the in-process mediator; durable outbox mode can be enabled when needed.)
+- [ ] Wolverine durable outbox or CAP for reliable messaging and eventual consistency in data deletion across related
+ entities. (WolverineFx is now integrated as the in-process mediator; durable outbox mode can be enabled when needed.)
## Result Pattern
-- [x] Introduce `Result` pattern (e.g. via `OneOf` or custom type) for expected failures instead of exceptions as flow control.
+- [x] Introduce `Result` pattern (e.g. via `OneOf` or custom type) for expected failures instead of exceptions as
+ flow control.
- [x] Migrate validation, not-found, and conflict scenarios from exceptions to explicit return types.
## Testing Improvements
@@ -210,12 +271,15 @@ Implement real-time notifications and chat using ASP.NET Core SignalR.
- [x] Migrate key integration tests from in-memory EF Core to Testcontainers PostgreSQL for realistic database behavior.
- [x] Add tests covering PostgreSQL-specific behavior: migrations, `xmin` concurrency tokens, full-text search queries.
- [ ] Add infrastructure smoke tests (startup validation, OpenAPI parity across modules).
-- [ ] Extract shared test utilities into `Tests.Common` library (`AsyncPoll` for eventual consistency, `TestDatabaseLifecycle`, `TestDataHelper`).
-- [ ] Implement abstract `ServiceFactoryBase` for consistent `WebApplicationFactory` configuration across module tests.
+- [ ] Extract shared test utilities into `Tests.Common` library (`AsyncPoll` for eventual consistency,
+ `TestDatabaseLifecycle`, `TestDataHelper`).
+- [ ] Implement abstract `ServiceFactoryBase` for consistent `WebApplicationFactory` configuration across
+ module tests.
## Modularization (Phase 1)
-- [x] Split `AppDbContext` into per-module contexts (ProductCatalogDbContext, ReviewsDbContext, IdentityDbContext, etc.).
+- [x] Split `AppDbContext` into per-module contexts (ProductCatalogDbContext, ReviewsDbContext, IdentityDbContext,
+ etc.).
- [x] Replace direct cross-module calls (soft-delete cascade rules) with Wolverine integration events.
- [ ] Add ArchUnitNET or NetArchTest architecture tests to enforce module boundaries.
- [ ] See `TODO-Architecture.md` for full modular monolith plan.
@@ -224,40 +288,70 @@ Implement real-time notifications and chat using ASP.NET Core SignalR.
### High Priority
-**Tenant Management** — Tenant creation and removal workflows are core functionality for a multi-tenant system. Without them, tenants cannot be fully managed — currently only a bootstrap tenant exists via configuration. Includes tenant creation, admin assignment, deactivation, and complete removal with cascading cleanup of all related data (users, products, categories).
+**Tenant Management** — Tenant creation and removal workflows are core functionality for a multi-tenant system. Without
+them, tenants cannot be fully managed — currently only a bootstrap tenant exists via configuration. Includes tenant
+creation, admin assignment, deactivation, and complete removal with cascading cleanup of all related data (users,
+products, categories).
-**Notifications** — Email infrastructure is fully in place (SMTP client, FailedEmail entity, retry jobs with distributed locking). Only business logic is missing — email templates and handlers for registration, tenant invitation, password reset, and role changes. Minimal effort with high UX impact.
+**Notifications** — Email infrastructure is fully in place (SMTP client, FailedEmail entity, retry jobs with distributed
+locking). Only business logic is missing — email templates and handlers for registration, tenant invitation, password
+reset, and role changes. Minimal effort with high UX impact.
-**Wolverine Outbox & Handler Tuples** — Enable durable outbox with PostgreSQL persistence and migrate handlers to `(ErrorOr, OutgoingMessages)` return types. Provides transactional message delivery guarantees without external message broker. Foundation for reliable cross-module communication.
+**Wolverine Outbox & Handler Tuples** — Enable durable outbox with PostgreSQL persistence and migrate handlers to
+`(ErrorOr, OutgoingMessages)` return types. Provides transactional message delivery guarantees without external
+message broker. Foundation for reliable cross-module communication.
-**Wolverine Validation Middleware** — `ErrorOrValidationMiddleware` eliminates manual FluentValidation calls in every handler. Automatic, consistent validation across all commands with proper ErrorOr integration. Low effort, high consistency impact.
+**Wolverine Validation Middleware** — `ErrorOrValidationMiddleware` eliminates manual FluentValidation calls in every
+handler. Automatic, consistent validation across all commands with proper ErrorOr integration. Low effort, high
+consistency impact.
### Medium Priority
-**Modularization (Phase 1)** — Split the monolith into isolated modules (ProductCatalog, Reviews, Identity, Notifications, FileStorage, BackgroundJobs, Webhooks). Includes splitting `AppDbContext` into per-module contexts, replacing direct cross-module calls with Wolverine integration events, and adding architecture tests to enforce boundaries. Prepares the project for future extraction without changing business logic. See `TODO-Architecture.md` for the full plan.
+**Modularization (Phase 1)** — Split the monolith into isolated modules (ProductCatalog, Reviews, Identity,
+Notifications, FileStorage, BackgroundJobs, Webhooks). Includes splitting `AppDbContext` into per-module contexts,
+replacing direct cross-module calls with Wolverine integration events, and adding architecture tests to enforce
+boundaries. Prepares the project for future extraction without changing business logic. See `TODO-Architecture.md` for
+the full plan.
-**Request Context & Observability** — Enhance middleware with tenant tracing, metrics enrichment, and intelligent Serilog log levels. Improves debugging, monitoring, and distributed trace correlation with minimal code changes.
+**Request Context & Observability** — Enhance middleware with tenant tracing, metrics enrichment, and intelligent
+Serilog log levels. Improves debugging, monitoring, and distributed trace correlation with minimal code changes.
-**Exception Handling & Logging Redaction** — Structured error metadata in ProblemDetails, differentiated log levels by status code, and data classification for log redaction (HMAC for sensitive, erase for personal). Security and observability improvement.
+**Exception Handling & Logging Redaction** — Structured error metadata in ProblemDetails, differentiated log levels by
+status code, and data classification for log redaction (HMAC for sensitive, erase for personal). Security and
+observability improvement.
-**Authentication Enhancements** — Tenant claim validation in JWT, Keycloak claims mapping, and OpenAPI security transformers. Strengthens multi-tenant security and improves API documentation accuracy.
+**Authentication Enhancements** — Tenant claim validation in JWT, Keycloak claims mapping, and OpenAPI security
+transformers. Strengthens multi-tenant security and improves API documentation accuracy.
-**Testing Improvements** — Migrate key integration tests from in-memory EF Core to Testcontainers PostgreSQL for realistic database behavior. The in-memory provider does not capture PostgreSQL-specific behavior — `xmin` concurrency tokens, full-text search, migrations, JSON operators. Testcontainers setup already exists in the project and needs to be extended to critical test suites.
+**Testing Improvements** — Migrate key integration tests from in-memory EF Core to Testcontainers PostgreSQL for
+realistic database behavior. The in-memory provider does not capture PostgreSQL-specific behavior — `xmin` concurrency
+tokens, full-text search, migrations, JSON operators. Testcontainers setup already exists in the project and needs to be
+extended to critical test suites.
-**Infrastructure Generics** — Make `UnitOfWork` and `RepositoryBase` generic over `DbContext`. Required for per-module context split (Modularization Phase 1) and eliminates tight coupling to `AppDbContext`.
+**Infrastructure Generics** — Make `UnitOfWork` and `RepositoryBase` generic over `DbContext`. Required for per-module
+context split (Modularization Phase 1) and eliminates tight coupling to `AppDbContext`.
### Lower Priority
-**Controller Base Helpers** — Reduce controller boilerplate with `InvokeToActionResultAsync()` and similar methods. Quality-of-life improvement.
+**Controller Base Helpers** — Reduce controller boilerplate with `InvokeToActionResultAsync()` and similar methods.
+Quality-of-life improvement.
-**Configuration Validation** — `AddValidatedOptions()` catches invalid configuration at startup instead of runtime. Prevents production configuration bugs.
+**Configuration Validation** — `AddValidatedOptions()` catches invalid configuration at startup instead of
+runtime. Prevents production configuration bugs.
-**Output Caching** — Tenant-aware cache policy and expanded coverage. Prevents cross-tenant data leaks and improves cache hit rates.
+**Output Caching** — Tenant-aware cache policy and expanded coverage. Prevents cross-tenant data leaks and improves
+cache hit rates.
-**Idempotency** — `IdempotencyActionFilter` for at-most-once semantics on mutation endpoints. Important for webhook receivers and external API integrations.
+**Idempotency** — `IdempotencyActionFilter` for at-most-once semantics on mutation endpoints. Important for webhook
+receivers and external API integrations.
-**Result Pattern** — Gradually migrate from exceptions (`ValidationException`, `NotFoundException`) to explicit return types for expected failures. Removes exception throwing overhead in common scenarios and makes method signatures more transparent. Best introduced incrementally, starting with new features.
+**Result Pattern** — Gradually migrate from exceptions (`ValidationException`, `NotFoundException`) to explicit
+return types for expected failures. Removes exception throwing overhead in common scenarios and makes method signatures
+more transparent. Best introduced incrementally, starting with new features.
-**Contracts NuGet Package** — Extract request/response DTOs into a standalone package. Allows clients to reference only contracts without depending on the Application layer. Essential for sharing types with frontend clients.
+**Contracts NuGet Package** — Extract request/response DTOs into a standalone package. Allows clients to reference only
+contracts without depending on the Application layer. Essential for sharing types with frontend clients.
-**Permissions** — Extend the 3-tier role model (PlatformAdmin, TenantAdmin, User) with finer-grained policy-based access control. Per-action and per-resource permissions enable more granular access control without needing to create new roles for every combination of privileges.
+**Permissions** — Extend the 3-tier role model (PlatformAdmin, TenantAdmin, User) with finer-grained policy-based access
+control. Per-action and per-resource permissions enable more granular access control without needing to create new roles
+for every combination of privileges.
diff --git a/absolute/README.md b/absolute/README.md
deleted file mode 100644
index c44e2c1f..00000000
--- a/absolute/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Absolute Reference
-
-This folder contains a snapshot of the legacy monolith `src` tree from the `main` branch.
-
-- `absolute/src` is reference-only.
-- Active development continues in the root `src` folder.
-- The modular monolith rewrite, including Unit 0 Foundation (`SharedKernel` + `Contracts`), stays in the root `src`.
diff --git a/absolute/src/APITemplate.Api/APITemplate.Api.csproj b/absolute/src/APITemplate.Api/APITemplate.Api.csproj
deleted file mode 100644
index 0aa18fb4..00000000
--- a/absolute/src/APITemplate.Api/APITemplate.Api.csproj
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
- net10.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/absolute/src/APITemplate.Api/APITemplate.http b/absolute/src/APITemplate.Api/APITemplate.http
deleted file mode 100644
index 0461c7d3..00000000
--- a/absolute/src/APITemplate.Api/APITemplate.http
+++ /dev/null
@@ -1,117 +0,0 @@
-@HostAddress = http://localhost:5174
-@ApiBase = {{HostAddress}}/api/v1
-@ContentType = application/json
-
-# Set this after login
-@Token = YOUR_TOKEN_HERE
-
-### =====================
-### AUTH
-### =====================
-
-### Login
-# @name login
-POST {{ApiBase}}/auth/login
-Content-Type: {{ContentType}}
-
-{
- "username": "admin",
- "password": "admin"
-}
-
-###
-
-### =====================
-### PRODUCTS
-### =====================
-
-### Get all products
-GET {{ApiBase}}/products
-Authorization: Bearer {{Token}}
-Accept: {{ContentType}}
-
-###
-
-### Get product by ID
-GET {{ApiBase}}/products/00000000-0000-0000-0000-000000000001
-Authorization: Bearer {{Token}}
-Accept: {{ContentType}}
-
-###
-
-### Create product
-POST {{ApiBase}}/products
-Authorization: Bearer {{Token}}
-Content-Type: {{ContentType}}
-
-{
- "name": "Sample Product",
- "description": "Product description",
- "price": 19.99
-}
-
-###
-
-### Update product
-PUT {{ApiBase}}/products/00000000-0000-0000-0000-000000000001
-Authorization: Bearer {{Token}}
-Content-Type: {{ContentType}}
-
-{
- "name": "Updated Product",
- "description": "Updated description",
- "price": 29.99
-}
-
-###
-
-### Delete product
-DELETE {{ApiBase}}/products/00000000-0000-0000-0000-000000000001
-Authorization: Bearer {{Token}}
-
-###
-
-### =====================
-### PRODUCT REVIEWS
-### =====================
-
-### Get all reviews
-GET {{ApiBase}}/productreviews
-Authorization: Bearer {{Token}}
-Accept: {{ContentType}}
-
-###
-
-### Get review by ID
-GET {{ApiBase}}/productreviews/00000000-0000-0000-0000-000000000001
-Authorization: Bearer {{Token}}
-Accept: {{ContentType}}
-
-###
-
-### Get reviews by product
-GET {{ApiBase}}/productreviews/by-product/00000000-0000-0000-0000-000000000001
-Authorization: Bearer {{Token}}
-Accept: {{ContentType}}
-
-###
-
-### Create review
-POST {{ApiBase}}/productreviews
-Authorization: Bearer {{Token}}
-Content-Type: {{ContentType}}
-
-{
- "productId": "00000000-0000-0000-0000-000000000001",
- "userId": "00000000-0000-0000-0000-000000000002",
- "comment": "Great product!",
- "rating": 5
-}
-
-###
-
-### Delete review
-DELETE {{ApiBase}}/productreviews/00000000-0000-0000-0000-000000000001
-Authorization: Bearer {{Token}}
-
-###
diff --git a/absolute/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs b/absolute/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs
deleted file mode 100644
index 653a5d03..00000000
--- a/absolute/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Security.Claims;
-using APITemplate.Application.Common.Security;
-using Microsoft.AspNetCore.Authorization;
-
-namespace APITemplate.Api.Authorization;
-
-///
-/// ASP.NET Core authorization handler that evaluates a
-/// by checking the current user's role claims against the application's role-permission map.
-///
-public sealed class PermissionAuthorizationHandler : AuthorizationHandler
-{
- private readonly IRolePermissionMap _rolePermissionMap;
-
- public PermissionAuthorizationHandler(IRolePermissionMap rolePermissionMap)
- {
- _rolePermissionMap = rolePermissionMap;
- }
-
- ///
- /// Succeeds the requirement when at least one of the user's role claims grants the required permission.
- ///
- protected override Task HandleRequirementAsync(
- AuthorizationHandlerContext context,
- PermissionRequirement requirement
- )
- {
- var roleClaims = context.User.FindAll(ClaimTypes.Role);
-
- foreach (var roleClaim in roleClaims)
- {
- if (_rolePermissionMap.HasPermission(roleClaim.Value, requirement.Permission))
- {
- context.Succeed(requirement);
- break;
- }
- }
-
- return Task.CompletedTask;
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs b/absolute/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs
deleted file mode 100644
index dcb8b387..00000000
--- a/absolute/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Collections.Concurrent;
-using APITemplate.Application.Common.Security;
-using Microsoft.AspNetCore.Authentication.JwtBearer;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.Extensions.Options;
-
-namespace APITemplate.Api.Authorization;
-
-public sealed class PermissionPolicyProvider : IAuthorizationPolicyProvider
-{
- private readonly DefaultAuthorizationPolicyProvider _fallback;
- private readonly ConcurrentDictionary _cache = new();
-
- public PermissionPolicyProvider(IOptions options) =>
- _fallback = new DefaultAuthorizationPolicyProvider(options);
-
- public Task GetPolicyAsync(string policyName)
- {
- if (!Permission.All.Contains(policyName))
- return _fallback.GetPolicyAsync(policyName);
-
- var policy = _cache.GetOrAdd(
- policyName,
- name =>
- new AuthorizationPolicyBuilder(
- JwtBearerDefaults.AuthenticationScheme,
- AuthConstants.BffSchemes.Cookie
- )
- .RequireAuthenticatedUser()
- .AddRequirements(new PermissionRequirement(name))
- .Build()
- );
-
- return Task.FromResult(policy);
- }
-
- public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
-
- public Task GetFallbackPolicyAsync() =>
- _fallback.GetFallbackPolicyAsync();
-}
diff --git a/absolute/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs b/absolute/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs
deleted file mode 100644
index 91af7682..00000000
--- a/absolute/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace APITemplate.Api.Authorization;
-
-///
-/// Authorization requirement that represents a named permission that a user must hold.
-/// Evaluated by .
-///
-public sealed record PermissionRequirement(string Permission) : IAuthorizationRequirement;
diff --git a/absolute/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs b/absolute/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs
deleted file mode 100644
index 147dcecc..00000000
--- a/absolute/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-
-namespace APITemplate.Api.Authorization;
-
-///
-/// Marks a controller or action as requiring a specific named permission.
-/// The permission name is used as an ASP.NET Core authorization policy name,
-/// which is evaluated by the policy-based authorization infrastructure.
-///
-[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
-public sealed class RequirePermissionAttribute : AuthorizeAttribute
-{
- ///
- /// Initializes the attribute with the given permission name, applied as the authorization policy.
- ///
- public RequirePermissionAttribute(string permission)
- : base(policy: permission) { }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs b/absolute/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs
deleted file mode 100644
index beb7ff20..00000000
--- a/absolute/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using APITemplate.Application.Common.Events;
-
-namespace APITemplate.Api.Cache;
-
-///
-/// Handles by evicting tagged output cache entries.
-///
-public sealed class CacheInvalidationHandler
-{
- public static Task HandleAsync(
- CacheInvalidationNotification @event,
- IOutputCacheInvalidationService outputCacheInvalidationService,
- CancellationToken ct
- ) => outputCacheInvalidationService.EvictAsync(@event.CacheTag, ct);
-}
diff --git a/absolute/src/APITemplate.Api/Api/Cache/CachingOptions.cs b/absolute/src/APITemplate.Api/Api/Cache/CachingOptions.cs
deleted file mode 100644
index 53fc9888..00000000
--- a/absolute/src/APITemplate.Api/Api/Cache/CachingOptions.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace APITemplate.Api.Cache;
-
-///
-/// Strongly-typed options model for configuring per-resource output cache expiration durations.
-/// Bound from the Caching configuration section and validated on startup.
-///
-public sealed class CachingOptions
-{
- [Range(1, int.MaxValue)]
- public int ProductsExpirationSeconds { get; set; } = 30;
-
- [Range(1, int.MaxValue)]
- public int CategoriesExpirationSeconds { get; set; } = 60;
-
- [Range(1, int.MaxValue)]
- public int ReviewsExpirationSeconds { get; set; } = 30;
-
- [Range(1, int.MaxValue)]
- public int ProductDataExpirationSeconds { get; set; } = 30;
-
- [Range(1, int.MaxValue)]
- public int TenantsExpirationSeconds { get; set; } = 60;
-
- [Range(1, int.MaxValue)]
- public int TenantInvitationsExpirationSeconds { get; set; } = 30;
-
- [Range(1, int.MaxValue)]
- public int UsersExpirationSeconds { get; set; } = 30;
-}
diff --git a/absolute/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs b/absolute/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs
deleted file mode 100644
index d5bb42e6..00000000
--- a/absolute/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace APITemplate.Api.Cache;
-
-///
-/// Abstraction for evicting output cache entries by tag, allowing the infrastructure
-/// implementation to be swapped or mocked independently of the domain/application layers.
-///
-public interface IOutputCacheInvalidationService
-{
- /// Evicts all output cache entries associated with .
- Task EvictAsync(string tag, CancellationToken cancellationToken = default);
-
- /// Evicts all output cache entries associated with each of the provided .
- Task EvictAsync(IEnumerable tags, CancellationToken cancellationToken = default);
-}
diff --git a/absolute/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs b/absolute/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs
deleted file mode 100644
index d1d233aa..00000000
--- a/absolute/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System.Diagnostics;
-using APITemplate.Infrastructure.Observability;
-using Microsoft.AspNetCore.OutputCaching;
-using Microsoft.Extensions.Logging;
-
-namespace APITemplate.Api.Cache;
-
-///
-/// Production implementation of that delegates
-/// to ASP.NET Core's , with telemetry and per-tag error isolation.
-///
-public sealed class OutputCacheInvalidationService : IOutputCacheInvalidationService
-{
- private readonly IOutputCacheStore _outputCacheStore;
- private readonly ILogger _logger;
-
- public OutputCacheInvalidationService(
- IOutputCacheStore outputCacheStore,
- ILogger logger
- )
- {
- _outputCacheStore = outputCacheStore;
- _logger = logger;
- }
-
- ///
- public Task EvictAsync(string tag, CancellationToken cancellationToken = default) =>
- EvictAsync([tag], cancellationToken);
-
- ///
- /// Evicts each distinct tag individually. Errors are logged as warnings and do not abort
- /// eviction of remaining tags; is re-thrown.
- ///
- public async Task EvictAsync(
- IEnumerable tags,
- CancellationToken cancellationToken = default
- )
- {
- foreach (var tag in tags.Distinct(StringComparer.Ordinal))
- {
- var startedAt = Stopwatch.GetTimestamp();
- using var activity = CacheTelemetry.StartOutputCacheInvalidationActivity(tag);
-
- try
- {
- await _outputCacheStore.EvictByTagAsync(tag, cancellationToken);
- }
- catch (Exception ex) when (ex is not OperationCanceledException)
- {
- _logger.LogWarning(
- ex,
- "Failed to evict output cache for tag {Tag}. Stale data may be served until expiration.",
- tag
- );
- continue;
- }
-
- CacheTelemetry.RecordOutputCacheInvalidation(tag, Stopwatch.GetElapsedTime(startedAt));
- }
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs b/absolute/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs
deleted file mode 100644
index 275cd291..00000000
--- a/absolute/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace APITemplate.Api.Cache;
-
-internal static class RedisInstanceNames
-{
- public const string OutputCache = "ApiTemplate:OutputCache:";
- public const string Session = "ApiTemplate:Session:";
-}
diff --git a/absolute/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs b/absolute/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs
deleted file mode 100644
index 83928f2b..00000000
--- a/absolute/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System.Security.Claims;
-using APITemplate.Application.Common.Security;
-using APITemplate.Infrastructure.Observability;
-using Microsoft.AspNetCore.OutputCaching;
-
-namespace APITemplate.Api.Cache;
-
-///
-/// Output cache policy that enables caching for authenticated requests and varies the cache key
-/// by tenant, preventing cross-tenant data exposure.
-///
-///
-/// By default ASP.NET Core Output Cache skips caching when an Authorization header is present.
-/// This policy overrides that behaviour and segments the cache per tenant so one tenant's responses
-/// are never served to another.
-///
-public sealed class TenantAwareOutputCachePolicy : IOutputCachePolicy
-{
- ///
- /// Enables output caching for GET/HEAD requests and segments the cache key by tenant ID.
- /// Non-GET/HEAD requests are skipped without side-effects.
- ///
- public ValueTask CacheRequestAsync(
- OutputCacheContext context,
- CancellationToken cancellationToken
- )
- {
- if (
- !HttpMethods.IsGet(context.HttpContext.Request.Method)
- && !HttpMethods.IsHead(context.HttpContext.Request.Method)
- )
- return ValueTask.CompletedTask;
-
- // Explicitly enable caching even when an Authorization header is present.
- context.EnableOutputCaching = true;
- context.AllowCacheLookup = true;
- context.AllowCacheStorage = true;
-
- // Vary cache key by tenant so each tenant has isolated cache entries.
- var tenantId =
- context.HttpContext.User.FindFirstValue(AuthConstants.Claims.TenantId) ?? string.Empty;
- context.CacheVaryByRules.VaryByValues[AuthConstants.Claims.TenantId] = tenantId;
- CacheTelemetry.ConfigureRequest(context);
-
- return ValueTask.CompletedTask;
- }
-
- /// Records a cache-hit telemetry event when a cached response is served.
- public ValueTask ServeFromCacheAsync(
- OutputCacheContext context,
- CancellationToken cancellationToken
- )
- {
- CacheTelemetry.RecordCacheHit(context);
- return ValueTask.CompletedTask;
- }
-
- /// Records the response outcome telemetry when a fresh response is written to the cache.
- public ValueTask ServeResponseAsync(
- OutputCacheContext context,
- CancellationToken cancellationToken
- )
- {
- CacheTelemetry.RecordResponseOutcome(context);
- return ValueTask.CompletedTask;
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs b/absolute/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs
deleted file mode 100644
index 349fc1bf..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-
-namespace APITemplate.Api.Controllers;
-
-[ApiController]
-[Route("api/v{version:apiVersion}/[controller]")]
-public abstract class ApiControllerBase : ControllerBase
-{
- internal ActionResult OkOrUnprocessable(BatchResponse response) =>
- response.FailureCount > 0 ? UnprocessableEntity(response) : Ok(response);
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/BffController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/BffController.cs
deleted file mode 100644
index 8427f19d..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/BffController.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System.Security.Claims;
-using APITemplate.Api.Controllers;
-using APITemplate.Application.Common.Options;
-using APITemplate.Application.Common.Security;
-using APITemplate.Application.Features.Bff.DTOs;
-using Asp.Versioning;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Options;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-[Authorize(AuthenticationSchemes = AuthConstants.BffSchemes.Cookie)]
-///
-/// Presentation-layer controller that exposes Backend-for-Frontend (BFF) endpoints for
-/// cookie-based browser clients, including login, logout, CSRF token retrieval, and current-user info.
-///
-public sealed class BffController : ApiControllerBase
-{
- private readonly BffOptions _bffOptions;
-
- public BffController(IOptions bffOptions)
- {
- _bffOptions = bffOptions.Value;
- }
-
- ///
- /// Initiates an OIDC authorization-code challenge, redirecting the browser to Keycloak.
- /// Falls back to the root path when is not a local URL.
- ///
- [HttpGet("login")]
- [AllowAnonymous]
- public IActionResult Login([FromQuery] string? returnUrl = null)
- {
- var redirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/";
- return Challenge(
- new AuthenticationProperties { RedirectUri = redirectUri },
- AuthConstants.BffSchemes.Oidc
- );
- }
-
- ///
- /// Signs the user out of both the cookie session and the OIDC provider, then redirects
- /// to the configured post-logout URI.
- ///
- [HttpGet("logout")]
- public IActionResult Logout()
- {
- return SignOut(
- new AuthenticationProperties { RedirectUri = _bffOptions.PostLogoutRedirectUri },
- AuthConstants.BffSchemes.Cookie,
- AuthConstants.BffSchemes.Oidc
- );
- }
-
- ///
- /// Returns the CSRF header name and a static token value that browser clients must include
- /// on every state-changing request made with the cookie authentication scheme.
- ///
- [HttpGet("csrf")]
- [AllowAnonymous]
- public IActionResult GetCsrf() =>
- Ok(
- new
- {
- headerName = AuthConstants.Csrf.HeaderName,
- headerValue = AuthConstants.Csrf.HeaderValue,
- }
- );
-
- ///
- /// Returns the authenticated user's identity claims (id, username, email, tenant, roles)
- /// extracted from the current cookie session.
- ///
- [HttpGet("user")]
- public IActionResult GetUser()
- {
- var user = HttpContext.User;
-
- var result = new BffUserResponse(
- UserId: user.FindFirstValue(ClaimTypes.NameIdentifier),
- Username: user.FindFirstValue(ClaimTypes.Name),
- Email: user.FindFirstValue(ClaimTypes.Email),
- TenantId: user.FindFirstValue(AuthConstants.Claims.TenantId),
- Roles: user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray()
- );
-
- return Ok(result);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs
deleted file mode 100644
index 24da6cf7..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.Events;
-using APITemplate.Application.Common.Security;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.OutputCaching;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that exposes CRUD endpoints for product categories,
-/// including a stored-procedure-backed statistics query.
-///
-public sealed class CategoriesController(IMessageBus bus) : ApiControllerBase
-{
- ///
- /// Returns a paginated, filterable list of categories from the output cache.
- ///
- [HttpGet]
- [RequirePermission(Permission.Categories.Read)]
- [OutputCache(PolicyName = CacheTags.Categories)]
- public async Task>> GetAll(
- [FromQuery] CategoryFilter filter,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>>(
- new GetCategoriesQuery(filter),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Returns a single category by its identifier, or 404 if not found.
- [HttpGet("{id:guid}")]
- [RequirePermission(Permission.Categories.Read)]
- [OutputCache(PolicyName = CacheTags.Categories)]
- public async Task> GetById(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new GetCategoryByIdQuery(id),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Creates multiple categories in a single batch operation.
- [HttpPost]
- [RequirePermission(Permission.Categories.Create)]
- public async Task> Create(
- CreateCategoriesRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateCategoriesCommand(request),
- ct
- );
- return result.ToBatchResult(this);
- }
-
- /// Updates multiple categories in a single batch operation.
- [HttpPut]
- [RequirePermission(Permission.Categories.Update)]
- public async Task> Update(
- UpdateCategoriesRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new UpdateCategoriesCommand(request),
- ct
- );
- return result.ToBatchResult(this);
- }
-
- /// Soft-deletes multiple categories in a single batch operation.
- [HttpDelete]
- [RequirePermission(Permission.Categories.Delete)]
- public async Task> Delete(
- BatchDeleteRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new DeleteCategoriesCommand(request),
- ct
- );
- return result.ToBatchResult(this);
- }
-
- ///
- /// Returns aggregated statistics for a category by calling the
- /// get_product_category_stats(p_category_id) stored procedure via EF Core FromSql.
- ///
- [HttpGet("{id:guid}/stats")]
- [RequirePermission(Permission.Categories.Read)]
- [OutputCache(PolicyName = CacheTags.Categories)]
- public async Task> GetStats(
- Guid id,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new GetCategoryStatsQuery(id),
- ct
- );
- return result.ToActionResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs
deleted file mode 100644
index 9fb11b84..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Api.Filters.Idempotency;
-using APITemplate.Application.Common.Security;
-using APITemplate.Application.Features.Examples;
-using APITemplate.Application.Features.Examples.DTOs;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that demonstrates idempotent POST semantics using the
-/// action filter to detect and short-circuit duplicate requests.
-///
-public sealed class IdempotentController(IMessageBus bus) : ApiControllerBase
-{
- ///
- /// Creates a resource idempotently; repeated requests with the same idempotency key
- /// return the original response without re-executing the command.
- ///
- [HttpPost]
- [Idempotent]
- [RequirePermission(Permission.Examples.Create)]
- public async Task> Create(
- IdempotentCreateRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new IdempotentCreateCommand(request),
- ct
- );
- if (result.IsError)
- return result.ToActionResult(this);
-
- return Created(string.Empty, result.Value);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs
deleted file mode 100644
index b06f46d1..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.Security;
-using APITemplate.Application.Features.Examples;
-using APITemplate.Application.Features.Examples.DTOs;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that demonstrates long-running job submission and
-/// asynchronous status polling using a channel-based job queue.
-///
-public sealed class JobsController(IMessageBus bus) : ApiControllerBase
-{
- ///
- /// Enqueues a new job and returns 202 Accepted with a Location header pointing to the
- /// status endpoint so the caller can poll for completion.
- ///
- [HttpPost]
- [RequirePermission(Permission.Examples.Execute)]
- public async Task Submit(SubmitJobRequest request, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new SubmitJobCommand(request),
- ct
- );
- if (result.IsError)
- return result.ToErrorResult(this);
-
- return AcceptedAtAction(
- nameof(GetStatus),
- new { id = result.Value.Id, version = this.GetApiVersion() },
- result.Value
- );
- }
-
- /// Returns the current execution status of a previously submitted job, or 404 if not found.
- [HttpGet("{id:guid}")]
- [RequirePermission(Permission.Examples.Read)]
- public async Task> GetStatus(
- [FromRoute] GetJobStatusRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new GetJobStatusQuery(request),
- ct
- );
- return result.ToActionResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs
deleted file mode 100644
index 3682613b..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Features.Examples;
-using APITemplate.Application.Features.Examples.DTOs;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using SystemTextJsonPatch;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that demonstrates JSON Patch (RFC 6902) support
-/// for partial product updates using SystemTextJsonPatch.
-///
-public sealed class PatchController(IMessageBus bus) : ApiControllerBase
-{
- ///
- /// Applies a JSON Patch document to the specified product by passing an apply-delegate
- /// to the application layer, which mutates the DTO before persisting.
- ///
- [HttpPatch("products/{id:guid}")]
- [RequirePermission(Permission.Examples.Update)]
- public async Task> PatchProduct(
- Guid id,
- [FromBody] JsonPatchDocument patchDocument,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new PatchProductCommand(id, dto => patchDocument.ApplyTo(dto)),
- ct
- );
- return result.ToActionResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs
deleted file mode 100644
index 619b11e8..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.Events;
-using APITemplate.Application.Common.Security;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.OutputCaching;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-[Route("api/v{version:apiVersion}/product-data")]
-///
-/// Presentation-layer controller that manages product supplementary data (images and videos)
-/// stored in MongoDB, with output-cache integration for read endpoints.
-///
-public sealed class ProductDataController(IMessageBus bus) : ApiControllerBase
-{
- /// Returns all product data documents, optionally filtered by type.
- [HttpGet]
- [RequirePermission(Permission.ProductData.Read)]
- [OutputCache(PolicyName = CacheTags.ProductData)]
- public async Task>> GetAll(
- [FromQuery] string? type,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>>(
- new GetProductDataQuery(type),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Returns a single product data document by its identifier, or 404 if not found.
- [HttpGet("{id:guid}")]
- [RequirePermission(Permission.ProductData.Read)]
- [OutputCache(PolicyName = CacheTags.ProductData)]
- public async Task> GetById(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new GetProductDataByIdQuery(id),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Creates a new image product-data document and returns it with a 201 Location header.
- [HttpPost("image")]
- [RequirePermission(Permission.ProductData.Create)]
- public async Task> CreateImage(
- CreateImageProductDataRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateImageProductDataCommand(request),
- ct
- );
- return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() });
- }
-
- /// Creates a new video product-data document and returns it with a 201 Location header.
- [HttpPost("video")]
- [RequirePermission(Permission.ProductData.Create)]
- public async Task> CreateVideo(
- CreateVideoProductDataRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateVideoProductDataCommand(request),
- ct
- );
- return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() });
- }
-
- /// Deletes a product data document by its identifier.
- [HttpDelete("{id:guid}")]
- [RequirePermission(Permission.ProductData.Delete)]
- public async Task Delete(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(new DeleteProductDataCommand(id), ct);
- return result.ToNoContentResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs
deleted file mode 100644
index 791a0a63..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.Events;
-using APITemplate.Application.Common.Security;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.OutputCaching;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that exposes CRUD endpoints for product reviews,
-/// with output-cache support and a dedicated by-product lookup endpoint.
-///
-public sealed class ProductReviewsController(IMessageBus bus) : ApiControllerBase
-{
- /// Returns a paginated, filterable list of product reviews.
- [HttpGet]
- [RequirePermission(Permission.ProductReviews.Read)]
- [OutputCache(PolicyName = CacheTags.Reviews)]
- public async Task>> GetAll(
- [FromQuery] ProductReviewFilter filter,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>>(
- new GetProductReviewsQuery(filter),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Returns a single product review by its identifier, or 404 if not found.
- [HttpGet("{id:guid}")]
- [RequirePermission(Permission.ProductReviews.Read)]
- [OutputCache(PolicyName = CacheTags.Reviews)]
- public async Task> GetById(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new GetProductReviewByIdQuery(id),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Returns all reviews for the specified product.
- [HttpGet("by-product/{productId:guid}")]
- [RequirePermission(Permission.ProductReviews.Read)]
- [OutputCache(PolicyName = CacheTags.Reviews)]
- public async Task>> GetByProductId(
- Guid productId,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>>(
- new GetProductReviewsByProductIdQuery(productId),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Creates a new product review and returns it with a 201 Location header.
- [HttpPost]
- [RequirePermission(Permission.ProductReviews.Create)]
- public async Task> Create(
- CreateProductReviewRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateProductReviewCommand(request),
- ct
- );
- return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() });
- }
-
- /// Deletes a product review by its identifier.
- [HttpDelete("{id:guid}")]
- [RequirePermission(Permission.ProductReviews.Delete)]
- public async Task Delete(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new DeleteProductReviewCommand(id),
- ct
- );
- return result.ToNoContentResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs
deleted file mode 100644
index 05a24176..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.Events;
-using APITemplate.Application.Common.Security;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.OutputCaching;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that exposes full CRUD endpoints for the product catalog,
-/// with permission-based authorization and tenant-aware output caching.
-///
-public sealed class ProductsController(IMessageBus bus) : ApiControllerBase
-{
- /// Returns a filtered, paginated product list including search facets.
- [HttpGet]
- [RequirePermission(Permission.Products.Read)]
- [OutputCache(PolicyName = CacheTags.Products)]
- public async Task> GetAll(
- [FromQuery] ProductFilter filter,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new GetProductsQuery(filter),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Returns a single product by its identifier, or 404 if not found.
- [HttpGet("{id:guid}")]
- [RequirePermission(Permission.Products.Read)]
- [OutputCache(PolicyName = CacheTags.Products)]
- public async Task> GetById(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new GetProductByIdQuery(id),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Creates multiple products in a single batch operation.
- [HttpPost]
- [RequirePermission(Permission.Products.Create)]
- public async Task> Create(
- CreateProductsRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateProductsCommand(request),
- ct
- );
- return result.ToBatchResult(this);
- }
-
- /// Updates multiple products in a single batch operation.
- [HttpPut]
- [RequirePermission(Permission.Products.Update)]
- public async Task> Update(
- UpdateProductsRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new UpdateProductsCommand(request),
- ct
- );
- return result.ToBatchResult(this);
- }
-
- /// Soft-deletes multiple products in a single batch operation.
- [HttpDelete]
- [RequirePermission(Permission.Products.Delete)]
- public async Task> Delete(
- BatchDeleteRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new DeleteProductsCommand(request),
- ct
- );
- return result.ToBatchResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/SseController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/SseController.cs
deleted file mode 100644
index 8e580440..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/SseController.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using System.Text.Json;
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Application.Features.Examples;
-using APITemplate.Application.Features.Examples.DTOs;
-using Asp.Versioning;
-using Microsoft.AspNetCore.Mvc;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that demonstrates Server-Sent Events (SSE) by streaming
-/// notifications as newline-delimited JSON over a persistent HTTP connection.
-///
-public sealed class SseController(IMessageBus bus) : ApiControllerBase
-{
- private const string EventStreamContentType = "text/event-stream";
- private const string NoCacheDirective = "no-cache";
- private const string KeepAliveConnection = "keep-alive";
- private const string SseDataPrefix = "data: ";
-
- ///
- /// Sets SSE response headers and then iterates an async notification stream, writing each
- /// item as a data: <json>\n\n frame and flushing immediately for low latency.
- ///
- [HttpGet("stream")]
- [RequirePermission(Permission.Examples.Read)]
- public async Task Stream([FromQuery] SseStreamRequest request, CancellationToken ct = default)
- {
- Response.ContentType = EventStreamContentType;
- Response.Headers.CacheControl = NoCacheDirective;
- Response.Headers.Connection = KeepAliveConnection;
-
- var stream = await bus.InvokeAsync>(
- new GetNotificationStreamQuery(request),
- ct
- );
- await using var writer = new StreamWriter(Response.Body, leaveOpen: true);
-
- await foreach (var item in stream.WithCancellation(ct))
- {
- var json = JsonSerializer.Serialize(item, JsonSerializerOptions.Web);
- await writer.WriteAsync($"{SseDataPrefix}{json}\n\n");
- await writer.FlushAsync(ct);
- }
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs
deleted file mode 100644
index 1945258d..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.DTOs;
-using APITemplate.Application.Common.Events;
-using APITemplate.Application.Common.Security;
-using APITemplate.Application.Features.TenantInvitation;
-using APITemplate.Application.Features.TenantInvitation.DTOs;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.OutputCaching;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-[Route("api/v{version:apiVersion}/tenant-invitations")]
-///
-/// Presentation-layer controller that manages the lifecycle of tenant invitations,
-/// including creation, acceptance via a token link, revocation, and resending.
-///
-public sealed class TenantInvitationsController(IMessageBus bus) : ApiControllerBase
-{
- /// Returns a paginated list of tenant invitations, optionally filtered.
- [HttpGet]
- [RequirePermission(Permission.Invitations.Read)]
- [OutputCache(PolicyName = CacheTags.TenantInvitations)]
- public async Task>> GetAll(
- [FromQuery] TenantInvitationFilter filter,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>>(
- new GetTenantInvitationsQuery(filter),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Creates a new tenant invitation and sends the invite email.
- [HttpPost]
- [RequirePermission(Permission.Invitations.Create)]
- public async Task> Create(
- CreateTenantInvitationRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateTenantInvitationCommand(request),
- ct
- );
- if (result.IsError)
- return result.ToActionResult(this);
-
- return CreatedAtAction(
- nameof(GetAll),
- new { version = this.GetApiVersion() },
- result.Value
- );
- }
-
- /// Accepts a pending invitation using the one-time token from the invite email; allows anonymous callers.
- [HttpPost("accept")]
- [AllowAnonymous]
- public async Task Accept(
- [FromBody] AcceptInvitationRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new AcceptTenantInvitationCommand(request.Token),
- ct
- );
- return result.ToOkResult(this);
- }
-
- /// Marks an outstanding invitation as revoked so the token can no longer be accepted.
- [HttpPatch("{id:guid}/revoke")]
- [RequirePermission(Permission.Invitations.Revoke)]
- public async Task Revoke(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new RevokeTenantInvitationCommand(id),
- ct
- );
- return result.ToNoContentResult(this);
- }
-
- /// Re-sends the invitation email for a pending invitation that has not yet been accepted or revoked.
- [HttpPost("{id:guid}/resend")]
- [RequirePermission(Permission.Invitations.Create)]
- public async Task Resend(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new ResendTenantInvitationCommand(id),
- ct
- );
- return result.ToOkResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs
deleted file mode 100644
index c5dbbcb9..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.Events;
-using APITemplate.Application.Common.Security;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.OutputCaching;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that exposes CRUD endpoints for tenant management,
-/// restricted to platform-level permissions with tenant-isolated output caching.
-///
-public sealed class TenantsController(IMessageBus bus) : ApiControllerBase
-{
- /// Returns a paginated, filterable list of tenants.
- [HttpGet]
- [RequirePermission(Permission.Tenants.Read)]
- [OutputCache(PolicyName = CacheTags.Tenants)]
- public async Task>> GetAll(
- [FromQuery] TenantFilter filter,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>>(
- new GetTenantsQuery(filter),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Returns a single tenant by its identifier, or 404 if not found.
- [HttpGet("{id:guid}")]
- [RequirePermission(Permission.Tenants.Read)]
- [OutputCache(PolicyName = CacheTags.Tenants)]
- public async Task> GetById(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(new GetTenantByIdQuery(id), ct);
- return result.ToActionResult(this);
- }
-
- /// Creates a new tenant and returns it with a 201 Location header.
- [HttpPost]
- [RequirePermission(Permission.Tenants.Create)]
- public async Task> Create(
- CreateTenantRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateTenantCommand(request),
- ct
- );
- return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() });
- }
-
- /// Soft-deletes a tenant and cascades the deletion to its child entities.
- [HttpDelete("{id:guid}")]
- [RequirePermission(Permission.Tenants.Delete)]
- public async Task Delete(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(new DeleteTenantCommand(id), ct);
- return result.ToNoContentResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs
deleted file mode 100644
index 2ecb110f..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs
+++ /dev/null
@@ -1,168 +0,0 @@
-using System.IdentityModel.Tokens.Jwt;
-using System.Security.Claims;
-using APITemplate.Api.Authorization;
-using APITemplate.Api.Controllers;
-using APITemplate.Api.ErrorOrMapping;
-using APITemplate.Application.Common.DTOs;
-using APITemplate.Application.Common.Events;
-using APITemplate.Application.Common.Security;
-using APITemplate.Application.Features.User.DTOs;
-using Asp.Versioning;
-using ErrorOr;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.OutputCaching;
-using Wolverine;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that exposes user management endpoints including
-/// CRUD operations, activation/deactivation, role changes, and self-service password reset.
-///
-public sealed class UsersController(IMessageBus bus) : ApiControllerBase
-{
- /// Returns a paginated, filterable list of users.
- [HttpGet]
- [RequirePermission(Permission.Users.Read)]
- [OutputCache(PolicyName = CacheTags.Users)]
- public async Task>> GetAll(
- [FromQuery] UserFilter filter,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>>(
- new GetUsersQuery(filter),
- ct
- );
- return result.ToActionResult(this);
- }
-
- /// Returns a single user by their identifier, or 404 if not found.
- [HttpGet("{id:guid}")]
- [RequirePermission(Permission.Users.Read)]
- [OutputCache(PolicyName = CacheTags.Users)]
- public async Task> GetById(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(new GetUserByIdQuery(id), ct);
- return result.ToActionResult(this);
- }
-
- ///
- /// Returns the currently authenticated user's profile by resolving their id from the
- /// JWT/cookie claims (NameIdentifier, sub, or a custom subject claim).
- ///
- [HttpGet("me")]
- public async Task> GetMe(CancellationToken ct)
- {
- var userId =
- User.FindFirstValue(ClaimTypes.NameIdentifier)
- ?? User.FindFirstValue(JwtRegisteredClaimNames.Sub)
- ?? User.FindFirstValue(AuthConstants.Claims.Subject);
-
- if (userId is null || !Guid.TryParse(userId, out var id))
- return Unauthorized();
-
- var result = await bus.InvokeAsync>(new GetUserByIdQuery(id), ct);
- return result.ToActionResult(this);
- }
-
- /// Creates a new user account and returns it with a 201 Location header.
- [HttpPost]
- [RequirePermission(Permission.Users.Create)]
- public async Task> Create(
- CreateUserRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new CreateUserCommand(request),
- ct
- );
- return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() });
- }
-
- /// Replaces all mutable fields of an existing user.
- [HttpPut("{id:guid}")]
- [RequirePermission(Permission.Users.Update)]
- public async Task Update(
- Guid id,
- UpdateUserRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new UpdateUserCommand(id, request),
- ct
- );
- return result.ToNoContentResult(this);
- }
-
- /// Activates a previously deactivated user account.
- [HttpPatch("{id:guid}/activate")]
- [RequirePermission(Permission.Users.Update)]
- public async Task Activate(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new SetUserActiveCommand(id, IsActive: true),
- ct
- );
- return result.ToNoContentResult(this);
- }
-
- /// Deactivates an active user account, preventing further logins.
- [HttpPatch("{id:guid}/deactivate")]
- [RequirePermission(Permission.Users.Update)]
- public async Task Deactivate(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(
- new SetUserActiveCommand(id, IsActive: false),
- ct
- );
- return result.ToNoContentResult(this);
- }
-
- /// Changes the role of an existing user within the current tenant.
- [HttpPatch("{id:guid}/role")]
- [RequirePermission(Permission.Users.Update)]
- public async Task ChangeRole(
- Guid id,
- ChangeUserRoleRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new ChangeUserRoleCommand(id, request),
- ct
- );
- return result.ToNoContentResult(this);
- }
-
- /// Soft-deletes a user account by its identifier.
- [HttpDelete("{id:guid}")]
- [RequirePermission(Permission.Users.Delete)]
- public async Task Delete(Guid id, CancellationToken ct)
- {
- var result = await bus.InvokeAsync>(new DeleteUserCommand(id), ct);
- return result.ToNoContentResult(this);
- }
-
- ///
- /// Triggers a Keycloak-initiated password-reset email for the given address; allows
- /// anonymous callers so unauthenticated users can recover access.
- ///
- [HttpPost("password-reset")]
- [AllowAnonymous]
- public async Task RequestPasswordReset(
- RequestPasswordResetRequest request,
- CancellationToken ct
- )
- {
- var result = await bus.InvokeAsync>(
- new KeycloakPasswordResetCommand(request),
- ct
- );
- return result.ToOkResult(this);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs
deleted file mode 100644
index 3cc23954..00000000
--- a/absolute/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using APITemplate.Api.Controllers;
-using APITemplate.Api.Filters.Webhooks;
-using APITemplate.Application.Common.BackgroundJobs;
-using APITemplate.Application.Features.Examples.DTOs;
-using Asp.Versioning;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-
-namespace APITemplate.Api.Controllers.V1;
-
-[ApiVersion(1.0)]
-///
-/// Presentation-layer controller that receives inbound webhook payloads, validates the
-/// HMAC signature via , and enqueues them for
-/// asynchronous processing (max 1 MB).
-///
-public sealed class WebhooksController : ApiControllerBase
-{
- private readonly IWebhookProcessingQueue _queue;
-
- public WebhooksController(IWebhookProcessingQueue queue) => _queue = queue;
-
- ///
- /// Validates the HMAC signature on the incoming payload and enqueues it for background
- /// processing, returning 200 immediately to the sender.
- ///
- [HttpPost]
- [AllowAnonymous]
- [ValidateWebhookSignature]
- [RequestSizeLimit(1024 * 1024)] // 1 MB max for webhook payloads
- public async Task Receive(
- [FromBody] WebhookPayload payload,
- CancellationToken ct
- )
- {
- await _queue.EnqueueAsync(payload, ct);
- return Ok();
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs b/absolute/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs
deleted file mode 100644
index 36a5767a..00000000
--- a/absolute/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs
+++ /dev/null
@@ -1,157 +0,0 @@
-using APITemplate.Api.Controllers;
-using ErrorOr;
-using Microsoft.AspNetCore.Mvc;
-
-namespace APITemplate.Api.ErrorOrMapping;
-
-///
-/// Extension methods that convert results to
-/// responses, producing the same RFC 7807 ProblemDetails format as .
-///
-public static class ErrorOrExtensions
-{
- /// Maps a successful result to 200 OK, or errors to ProblemDetails.
- public static ActionResult ToActionResult(
- this ErrorOr result,
- ControllerBase controller
- )
- {
- if (!result.IsError)
- return controller.Ok(result.Value);
-
- return ToProblemResult(result.Errors, controller);
- }
-
- /// Maps a successful result to 201 Created, or errors to ProblemDetails.
- public static ActionResult ToCreatedResult(
- this ErrorOr result,
- ApiControllerBase controller,
- Func routeValuesFactory
- )
- {
- if (!result.IsError)
- return controller.CreatedAtAction(
- "GetById",
- routeValuesFactory(result.Value),
- result.Value
- );
-
- return ToProblemResult(result.Errors, controller);
- }
-
- /// Maps a successful void result to 204 NoContent, or errors to ProblemDetails.
- public static IActionResult ToNoContentResult(
- this ErrorOr result,
- ControllerBase controller
- )
- {
- if (!result.IsError)
- return controller.NoContent();
-
- return ToProblemDetails(result.Errors, controller);
- }
-
- ///
- /// Maps a successful batch result through ,
- /// or request-level errors to ProblemDetails.
- ///
- public static ActionResult ToBatchResult(
- this ErrorOr result,
- ApiControllerBase controller
- )
- {
- if (!result.IsError)
- return controller.OkOrUnprocessable(result.Value);
-
- return ToProblemResult(result.Errors, controller);
- }
-
- /// Maps a successful void result to 200 OK, or errors to ProblemDetails.
- public static IActionResult ToOkResult(this ErrorOr result, ControllerBase controller)
- {
- if (!result.IsError)
- return controller.Ok();
-
- return ToProblemDetails(result.Errors, controller);
- }
-
- ///
- /// Returns ProblemDetails for the error case of any result.
- /// Use when the success case is handled separately by the caller.
- ///
- public static IActionResult ToErrorResult(this ErrorOr result, ControllerBase controller)
- {
- return ToProblemDetails(result.Errors, controller);
- }
-
- private static ActionResult ToProblemResult(
- List errors,
- ControllerBase controller
- ) => ToProblemDetails(errors, controller);
-
- private static ObjectResult ToProblemDetails(
- List errors,
- ControllerBase controller
- )
- {
- var problemDetails = BuildProblemDetails(errors, controller);
- return new ObjectResult(problemDetails) { StatusCode = problemDetails.Status };
- }
-
- private static ProblemDetails BuildProblemDetails(
- List errors,
- ControllerBase controller
- )
- {
- var firstError = errors[0];
- var statusCode = MapToStatusCode(firstError.Type);
- var title = MapToTitle(firstError.Type);
- var errorCode = firstError.Code;
- var detail = firstError.Description;
-
- if (errors.Count > 1 && firstError.Type == ErrorType.Validation)
- detail = string.Join(" ", errors.Select(e => e.Description));
-
- var problemDetails = new ProblemDetails
- {
- Status = statusCode,
- Title = title,
- Detail = detail,
- Instance = controller.HttpContext.Request.Path,
- Type = BuildTypeUri(errorCode),
- };
-
- problemDetails.Extensions["errorCode"] = errorCode;
- problemDetails.Extensions["traceId"] = controller.HttpContext.TraceIdentifier;
-
- if (firstError.Metadata is { Count: > 0 })
- problemDetails.Extensions["metadata"] = firstError.Metadata;
-
- return problemDetails;
- }
-
- private static int MapToStatusCode(ErrorType errorType) =>
- errorType switch
- {
- ErrorType.Validation => StatusCodes.Status400BadRequest,
- ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
- ErrorType.Forbidden => StatusCodes.Status403Forbidden,
- ErrorType.NotFound => StatusCodes.Status404NotFound,
- ErrorType.Conflict => StatusCodes.Status409Conflict,
- _ => StatusCodes.Status500InternalServerError,
- };
-
- private static string MapToTitle(ErrorType errorType) =>
- errorType switch
- {
- ErrorType.Validation => "Bad Request",
- ErrorType.Unauthorized => "Unauthorized",
- ErrorType.Forbidden => "Forbidden",
- ErrorType.NotFound => "Not Found",
- ErrorType.Conflict => "Conflict",
- _ => "Internal Server Error",
- };
-
- private static string BuildTypeUri(string errorCode) =>
- $"https://api-template.local/errors/{errorCode}";
-}
diff --git a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs b/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs
deleted file mode 100644
index a88d4748..00000000
--- a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs
+++ /dev/null
@@ -1,203 +0,0 @@
-using APITemplate.Domain.Exceptions;
-using APITemplate.Infrastructure.Observability;
-using Microsoft.AspNetCore.Diagnostics;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-
-namespace APITemplate.Api.ExceptionHandling;
-
-///
-/// Central REST exception translator for the HTTP pipeline.
-///
-///
-/// Converts domain/application exceptions to RFC7807
-/// responses with stable status codes and error codes. GraphQL requests are intentionally
-/// bypassed because GraphQL uses a separate error handling pipeline.
-///
-public sealed class ApiExceptionHandler : IExceptionHandler
-{
- private const int ClientClosedRequestStatusCode = 499;
- private readonly ILogger _logger;
- private readonly IProblemDetailsService _problemDetailsService;
-
- public ApiExceptionHandler(
- ILogger logger,
- IProblemDetailsService problemDetailsService
- )
- {
- _logger = logger;
- _problemDetailsService = problemDetailsService;
- }
-
- ///
- /// Maps an exception to HTTP status + payload metadata, logs it with severity by status code,
- /// and writes an RFC7807 response through .
- ///
- public async ValueTask TryHandleAsync(
- HttpContext context,
- Exception exception,
- CancellationToken cancellationToken
- )
- {
- // GraphQL has its own error format and middleware, so let that pipeline handle GraphQL exceptions.
- if (context.Request.Path.StartsWithSegments(TelemetryPathPrefixes.GraphQl))
- return false;
-
- if (IsClientAbortedRequest(context, exception, cancellationToken))
- {
- if (!context.Response.HasStarted)
- context.Response.StatusCode = ClientClosedRequestStatusCode;
-
- return true;
- }
-
- var (statusCode, title, detail, errorCode, metadata) = Resolve(exception);
- var problemDetails = new ProblemDetails
- {
- Status = statusCode,
- Title = title,
- Detail = detail,
- Instance = context.Request.Path,
- Type = BuildTypeUri(errorCode),
- };
-
- problemDetails.Extensions["errorCode"] = errorCode;
- if (metadata is not null && metadata.Count > 0)
- problemDetails.Extensions["metadata"] = metadata;
-
- if (statusCode >= StatusCodes.Status500InternalServerError)
- {
- _logger.UnhandledException(exception, statusCode, errorCode, context.TraceIdentifier);
- }
- else
- {
- _logger.HandledApplicationException(
- exception,
- statusCode,
- errorCode,
- context.TraceIdentifier
- );
- }
-
- ApiMetrics.RecordHandledException(statusCode, errorCode, exception.GetType().Name);
-
- ConflictTelemetry.Record(exception, errorCode);
-
- context.Response.StatusCode = statusCode;
- var wasWritten = await _problemDetailsService.TryWriteAsync(
- new ProblemDetailsContext
- {
- HttpContext = context,
- Exception = exception,
- ProblemDetails = problemDetails,
- }
- );
-
- return wasWritten;
- }
-
- private static bool IsClientAbortedRequest(
- HttpContext context,
- Exception exception,
- CancellationToken cancellationToken
- ) =>
- exception is OperationCanceledException
- && (
- context.RequestAborted.IsCancellationRequested
- || cancellationToken.IsCancellationRequested
- );
-
- private static (
- int StatusCode,
- string Title,
- string Detail,
- string ErrorCode,
- IReadOnlyDictionary? Metadata
- ) Resolve(Exception exception)
- {
- if (exception is AppException appException)
- {
- var (statusCode, title, defaultErrorCode) = MapToHttp(appException);
- var errorCode = ResolveErrorCode(appException, defaultErrorCode);
-
- return (statusCode, title, appException.Message, errorCode, appException.Metadata);
- }
-
- if (exception is DbUpdateConcurrencyException)
- {
- return (
- StatusCodes.Status409Conflict,
- "Conflict",
- "The resource was modified by another request. Please retrieve the latest version and retry.",
- ErrorCatalog.General.ConcurrencyConflict,
- null
- );
- }
-
- return (
- StatusCodes.Status500InternalServerError,
- "Internal Server Error",
- "An unexpected error occurred.",
- ErrorCatalog.General.Unknown,
- null
- );
- }
-
- private static (int StatusCode, string Title, string ErrorCode) MapToHttp(
- AppException appException
- ) =>
- appException switch
- {
- ValidationException => (
- StatusCodes.Status400BadRequest,
- "Bad Request",
- ErrorCatalog.General.ValidationFailed
- ),
- UnauthorizedException => (
- StatusCodes.Status401Unauthorized,
- "Unauthorized",
- ErrorCatalog.General.Unknown
- ),
- ForbiddenException => (
- StatusCodes.Status403Forbidden,
- "Forbidden",
- ErrorCatalog.Auth.Forbidden
- ),
- NotFoundException => (
- StatusCodes.Status404NotFound,
- "Not Found",
- ErrorCatalog.General.NotFound
- ),
- ConflictException => (
- StatusCodes.Status409Conflict,
- "Conflict",
- ErrorCatalog.General.Conflict
- ),
- _ => (
- StatusCodes.Status500InternalServerError,
- "Internal Server Error",
- ErrorCatalog.General.Unknown
- ),
- };
-
- private static string ResolveErrorCode(AppException appException, string defaultErrorCode)
- {
- if (!string.IsNullOrWhiteSpace(appException.ErrorCode))
- return appException.ErrorCode!;
-
- if (
- appException.Metadata is not null
- && appException.Metadata.TryGetValue("errorCode", out var metadataErrorCode)
- && metadataErrorCode is string value
- && !string.IsNullOrWhiteSpace(value)
- )
- {
- return value;
- }
-
- return defaultErrorCode;
- }
-
- private static string BuildTypeUri(string errorCode) =>
- $"https://api-template.local/errors/{errorCode}";
-}
diff --git a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs
deleted file mode 100644
index 6b77de85..00000000
--- a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using APITemplate.Infrastructure.Logging;
-
-namespace APITemplate.Api.ExceptionHandling;
-
-///
-/// Source-generated logging contract for .
-/// Keeps log templates and event identifiers centralized, strongly typed, and allocation-friendly.
-///
-internal static partial class ApiExceptionHandlerLogs
-{
- ///
- /// Logs an unhandled server-side exception (typically HTTP 5xx).
- ///
- /// Target logger instance.
- /// Captured exception to attach to the log event.
- /// HTTP status code returned to the client.
- /// Application error code. Classified as sensitive for redaction.
- /// Request trace identifier. Classified as personal for redaction.
- [LoggerMessage(
- EventId = 1001,
- Level = LogLevel.Error,
- Message = "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}")]
- public static partial void UnhandledException(
- this ILogger logger,
- Exception exception,
- int statusCode,
- [SensitiveData] string errorCode,
- [PersonalData] string traceId);
-
- ///
- /// Logs a handled application exception (typically HTTP 4xx).
- ///
- /// Target logger instance.
- /// Captured exception to attach to the log event.
- /// HTTP status code returned to the client.
- /// Application error code. Classified as sensitive for redaction.
- /// Request trace identifier. Classified as personal for redaction.
- [LoggerMessage(
- EventId = 1002,
- Level = LogLevel.Warning,
- Message = "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}")]
- public static partial void HandledApplicationException(
- this ILogger logger,
- Exception exception,
- int statusCode,
- [SensitiveData] string errorCode,
- [PersonalData] string traceId);
-}
diff --git a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs b/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs
deleted file mode 100644
index 8526fb55..00000000
--- a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using Microsoft.AspNetCore.Http;
-
-namespace APITemplate.Api.ExceptionHandling;
-
-///
-/// Provides a static configuration helper that customizes the global
-/// for the API presentation layer.
-///
-public static class ApiProblemDetailsOptions
-{
- ///
- /// Configures global enrichment for API responses.
- ///
- ///
- /// Adds a request trace identifier, guarantees an errorCode fallback, and
- /// ensures a stable type URI shape for error documentation.
- ///
- public static void Configure(ProblemDetailsOptions options)
- {
- options.CustomizeProblemDetails = context =>
- {
- var extensions = context.ProblemDetails.Extensions;
- extensions["traceId"] = context.HttpContext.TraceIdentifier;
-
- // Preserve errorCode set by upstream handlers; only fall back when not provided.
- var errorCode =
- extensions.TryGetValue("errorCode", out var existingErrorCode)
- && existingErrorCode is string existing
- ? existing
- : ErrorCatalog.General.Unknown;
-
- extensions["errorCode"] = errorCode;
- context.ProblemDetails.Type ??= $"https://api-template.local/errors/{errorCode}";
- };
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs b/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs
deleted file mode 100644
index aa5ebab6..00000000
--- a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-using System.Net.Mime;
-using System.Text.Json;
-using APITemplate.Application.Common.Contracts;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Filters;
-
-namespace APITemplate.Api.Filters.Idempotency;
-
-///
-/// Action filter that enforces idempotency for endpoints decorated with .
-/// On the first call the response is stored in ; subsequent calls with
-/// the same Idempotency-Key header replay the cached response without re-executing the action.
-///
-public sealed class IdempotencyActionFilter : IAsyncActionFilter
-{
- private readonly IIdempotencyStore _store;
-
- public IdempotencyActionFilter(IIdempotencyStore store)
- {
- _store = store;
- }
-
- ///
- /// Intercepts the action execution to check for a cached idempotent result or to store
- /// a new one, ensuring at-most-once semantics for the decorated endpoint.
- ///
- public async Task OnActionExecutionAsync(
- ActionExecutingContext context,
- ActionExecutionDelegate next
- )
- {
- var attribute = context
- .ActionDescriptor.EndpointMetadata.OfType()
- .FirstOrDefault();
-
- if (attribute is null)
- {
- await next();
- return;
- }
-
- if (
- !context.HttpContext.Request.Headers.TryGetValue(
- IdempotencyConstants.HeaderName,
- out var keyValues
- ) || string.IsNullOrWhiteSpace(keyValues)
- )
- {
- context.Result = new BadRequestObjectResult(
- "Idempotency-Key header is required for this endpoint."
- );
- return;
- }
-
- var key = keyValues.ToString();
- if (key.Length > IdempotencyConstants.MaxKeyLength)
- {
- context.Result = new BadRequestObjectResult(
- $"Idempotency key must not exceed {IdempotencyConstants.MaxKeyLength} characters."
- );
- return;
- }
-
- var resultTtl = TimeSpan.FromHours(attribute.TtlHours);
- var lockTimeout = TimeSpan.FromSeconds(attribute.LockTimeoutSeconds);
- var ct = context.HttpContext.RequestAborted;
-
- var existing = await _store.TryGetAsync(key, ct);
- if (existing is not null)
- {
- if (existing.LocationHeader is not null)
- context.HttpContext.Response.Headers.Location = existing.LocationHeader;
-
- context.Result = new ContentResult
- {
- StatusCode = existing.StatusCode,
- Content = existing.ResponseBody,
- ContentType = existing.ResponseContentType,
- };
- return;
- }
-
- if (!await _store.TryAcquireAsync(key, lockTimeout, ct))
- {
- context.Result = new ConflictObjectResult(
- "A request with this idempotency key is already being processed."
- );
- return;
- }
-
- ActionExecutedContext executedContext;
- try
- {
- executedContext = await next();
- }
- catch
- {
- await _store.ReleaseAsync(key, ct);
- throw;
- }
-
- if (
- executedContext.Result is ObjectResult objectResult
- && objectResult.StatusCode is >= 200 and < 300
- )
- {
- var responseBody = objectResult.Value is not null
- ? JsonSerializer.Serialize(objectResult.Value, JsonSerializerOptions.Web)
- : null;
-
- string? locationHeader = executedContext.Result switch
- {
- CreatedResult cr => cr.Location,
- _ => null,
- };
-
- var entry = new IdempotencyCacheEntry(
- objectResult.StatusCode ?? 200,
- responseBody,
- MediaTypeNames.Application.Json,
- locationHeader
- );
-
- await _store.SetAsync(key, entry, resultTtl, ct);
- }
-
- await _store.ReleaseAsync(key, ct);
- }
-}
diff --git a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs b/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs
deleted file mode 100644
index e846a058..00000000
--- a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace APITemplate.Api.Filters.Idempotency;
-
-///
-/// Shared constants for the idempotency feature: header name, key constraints, and default timeouts.
-///
-public static class IdempotencyConstants
-{
- public const string HeaderName = "Idempotency-Key";
- public const int DefaultTtlHours = 24;
- public const int LockTimeoutSeconds = 30;
- public const int MaxKeyLength = 100;
-}
diff --git a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs b/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs
deleted file mode 100644
index 5b5ae760..00000000
--- a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace APITemplate.Api.Filters.Idempotency;
-
-///
-/// Marks an action method as idempotent, enabling the
-/// to store and replay responses using the Idempotency-Key request header.
-///
-[AttributeUsage(AttributeTargets.Method)]
-public sealed class IdempotentAttribute : Attribute
-{
- public int TtlHours { get; set; } = IdempotencyConstants.DefaultTtlHours;
- public int LockTimeoutSeconds { get; set; } = IdempotencyConstants.LockTimeoutSeconds;
-}
diff --git a/absolute/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs b/absolute/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs
deleted file mode 100644
index 84c44e5c..00000000
--- a/absolute/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using APITemplate.Infrastructure.Observability;
-using FluentValidation;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Filters;
-
-namespace APITemplate.Api.Filters.Validation;
-
-///
-/// Global action filter that automatically validates all action arguments using FluentValidation.
-/// Runs before every controller action. If a registered exists for an
-/// argument type, it is resolved from DI and executed. On failure, returns HTTP 400 with a
-/// body — the controller method is never invoked.
-/// Arguments without a registered validator are silently skipped.
-///
-public sealed class FluentValidationActionFilter : IAsyncActionFilter
-{
- private readonly IServiceProvider _serviceProvider;
-
- public FluentValidationActionFilter(IServiceProvider serviceProvider)
- {
- _serviceProvider = serviceProvider;
- }
-
- ///
- /// Iterates over all action arguments, resolves a matching from DI
- /// for each, and short-circuits with HTTP 400 if any validation fails.
- ///
- public async Task OnActionExecutionAsync(
- ActionExecutingContext context,
- ActionExecutionDelegate next
- )
- {
- foreach (var argument in context.ActionArguments.Values)
- {
- if (argument is null)
- continue;
-
- var argumentType = argument.GetType();
- var validatorType = typeof(IValidator<>).MakeGenericType(argumentType);
- var validator = _serviceProvider.GetService(validatorType) as IValidator;
-
- if (validator is null)
- continue;
-
- var validationContext = new ValidationContext