Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -874,3 +874,30 @@ dotnet_diagnostic.IDE0130.severity = none

[Tests/Presentation/MMCA.Common.API.Tests/Fakes/**.cs]
dotnet_diagnostic.IDE0130.severity = none

[*.cs]
# ─────────────────────────────────────────────────────────────────────────────
# Microsoft.CodeAnalysis.PublicApiAnalyzers (RS rules): overrides
# The public API surface gate is Source-only (see Directory.Build.props); the two rules that make it
# a gate (RS0016 for a public member missing from PublicAPI.Shipped.txt, RS0017 for a declared member
# that disappeared) stay at the global error severity. The rest are turned off deliberately.
# ─────────────────────────────────────────────────────────────────────────────

# RS0026/RS0027: "do not add multiple public overloads with optional parameters". Sound advice for a
# NEW API, but the v1.152.0 surface being baselined already ships those pairs (the repository
# read/query methods above all), and obeying the rule now would mean a breaking signature change on
# every consumer. Off rather than silently baselined as a lie.
dotnet_diagnostic.RS0026.severity = none
dotnet_diagnostic.RS0027.severity = none
# RS0041: "public members should not use oblivious reference types". Every hit is inside Razor
# generated code (BuildRenderTree and friends), which is not nullable-annotated and is not ours to
# annotate; the rule cannot be satisfied from source.
dotnet_diagnostic.RS0041.severity = none
# RS0051-RS0056: the INTERNAL-API analog of the same analyzer (InternalAPI.Shipped.txt). Only the
# public, packaged surface is under contract here, so internal API tracking is not adopted.
dotnet_diagnostic.RS0051.severity = none
dotnet_diagnostic.RS0052.severity = none
dotnet_diagnostic.RS0053.severity = none
dotnet_diagnostic.RS0054.severity = none
dotnet_diagnostic.RS0055.severity = none
dotnet_diagnostic.RS0056.severity = none
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,16 @@ Downstream apps register `AddApplicationDecorators()` **last**: Scrutor `TryDeco
`ICommandHandler<TCmd, TResult>` / `IQueryHandler<TQuery, TResult>` with decorators registered in `AddApplicationDecorators()` and applied by Scrutor `TryDecorate` in reverse registration order (last registered = outermost). Execution order, outermost to innermost (ADR-014):

```
Commands: FeatureGate -> Logging -> Caching -> Validating -> Transactional -> Handler
Queries: FeatureGate -> Logging -> Caching -> Handler
Commands: FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> Handler
Queries: FeatureGate -> Authorization -> Logging -> Caching -> Timeout -> Handler
```

- **FeatureGate**: short-circuits when the command/query's feature flag is off.
- **Authorization**: `IRequiresPermission` commands/queries are checked against `IPermissionRegistry.HasPermission(ICurrentUserService.Roles, ...)`; a denial short-circuits with a `Forbidden` error and increments `cqrs.authorization.denied.count`. Sits outside caching on purpose, so a denied query never reads or populates the cache.
- **Logging**: full pipeline duration via `ICorrelationContext`.
- **Caching**: `ICacheInvalidating` commands invalidate on success (outside the transaction); `IQueryCacheable` queries (with `CacheKey` + `CacheDuration`) cache results.
- **Validating**: FluentValidation before the transaction opens; queries have no Validating or Transactional decorator.
- **Timeout**: `IHasTimeout` commands/queries run under a linked token cancelled after their own budget; expiry returns a `Request.TimedOut` failure and increments `cqrs.timeout.count`, while caller cancellation still propagates as an exception. A budget of zero or less passes through.
- **Transactional**: `ITransactional` commands get a DB transaction; exceptions AND business failures (`Result.Failure`) roll back (atomicity over partial persistence). In-process domain event dispatch is deferred until after a successful commit (`DbContextFactory.ExecuteInTransactionAsync` flushes it post-commit and drops it on rollback), so handlers never act on state that could still roll back; cache invalidation still runs only on success, outside the transaction.

An optional `Profiling` decorator pair is registered by a separate opt-in `AddApplicationProfiling()` call and is not wired by any host today.
Expand Down
26 changes: 25 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
operator immediately fails the build with CS8625/CS8604. Suppressed HERE rather than in
.editorconfig because a `dotnet_diagnostic` severity does not reach Razor-generated code.
The compiler is the authority on nullability, not the analyzer. -->
<NoWarn>$(NoWarn);CS1591;RMG020;S8970</NoWarn>
<!-- RS0041 ("public members should not use oblivious reference types") fires only on Razor
generated code (BuildRenderTree and the component parameter surface), which is not
nullable-annotated and is not ours to annotate. Suppressed HERE rather than in .editorconfig
for the same reason as S8970 above: a `dotnet_diagnostic` severity does not reach generated
code. The two rules that make the public API gate a gate, RS0016 and RS0017, stay at error. -->
<NoWarn>$(NoWarn);CS1591;RMG020;S8970;RS0041</NoWarn>
</PropertyGroup>

<!-- Suppress xUnit1051 (CancellationToken propagation) in test projects -->
Expand Down Expand Up @@ -60,6 +65,25 @@
<None Include="$(MSBuildThisFileDirectory)assets\icon.png" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>

<!--
Public API surface gate. Every packable Source project declares its public surface in
PublicAPI.Shipped.txt; RS0016 fails the build on a public member that is not declared and RS0017 on a
declared member that disappeared, so widening or breaking the shipped API of a NuGet package becomes a
reviewable diff in a text file instead of something a consumer discovers after the release.
Source-only (tests have no shipped surface) and never on a .dcproj, mirroring the analyzer group below.
MMCA.Common.UI.Maui is deliberately excluded: it lives outside MMCA.Common.slnx and builds only on the
windows build-maui job across four MAUI TFMs (ADR-042), so its baseline could neither be bootstrapped
nor kept honest from the normal build.
-->
<ItemGroup Condition="$(MSBuildProjectDirectory.Contains('Source')) AND '$(MSBuildProjectExtension)' != '.dcproj' AND '$(MSBuildProjectName)' != 'MMCA.Common.UI.Maui'">
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<AdditionalFiles Include="PublicAPI.Shipped.txt" Condition="Exists('PublicAPI.Shipped.txt')" />
<AdditionalFiles Include="PublicAPI.Unshipped.txt" Condition="Exists('PublicAPI.Unshipped.txt')" />
</ItemGroup>

<ItemGroup Condition="'$(MSBuildProjectExtension)' != '.dcproj'">
<PackageReference Include="Meziantou.Analyzer">
<PrivateAssets>all</PrivateAssets>
Expand Down
11 changes: 11 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.11" />
<PackageVersion Include="MudBlazor" Version="9.8.0" />
<PackageVersion Include="Polly" Version="8.7.0" />
<!-- Circuit breaker on the outbox broker-publish path (MMCA.Common.Infrastructure). Polly.Core
rather than the Polly meta-package: Core carries the v8 resilience-pipeline API on its own,
while Polly adds the v7 Policy compatibility shim that Infrastructure has no use for. Kept
on the same version as the Polly pin above so the two never resolve to different Core
assemblies in one graph. -->
<PackageVersion Include="Polly.Core" Version="8.7.0" />
<!-- QR encoding for the shared QrCodeImage component (MIT). Pure managed encode-to-PNG: no
native binary, no network call, no image service, so it renders identically on every head
including Blazor WebAssembly. -->
Expand Down Expand Up @@ -151,6 +157,11 @@
<PackageVersion Include="ZXing.Net.Maui.Controls" Version="0.10.3" />
<!-- Analyzers -->
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.163" />
<!-- Public API surface gate (RS0016/RS0017): every packable Source project carries a
PublicAPI.Shipped.txt baseline, so adding or removing a public member is a deliberate,
reviewable diff instead of an accident discovered by a consumer. Source-only (see the
PublicApiAnalyzers ItemGroup in Directory.Build.props). -->
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="18.7.23" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.16.1" />
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.32.0.713" />
Expand Down
6 changes: 3 additions & 3 deletions FACTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# MMCA.Common — Canonical Facts

**Single source of truth for the framework-wide facts that otherwise drift across dozens of docs.**
_As of: 2026-08-14 (framework v1.152.0) — **generated from source by `build/facts`; do not hand-edit the numbers below.**_
_As of: 2026-08-18 (framework v1.152.0) — **generated from source by `build/facts`; do not hand-edit the numbers below.**_

> **Rule: link here, don't restate.** Other docs (scorecards, CLAUDE.md files, READMEs, the LinkedIn/Medium
> campaigns) must **reference** these facts rather than copy the numbers inline. A "thirteen packages"
Expand Down Expand Up @@ -41,10 +41,10 @@ The ADRs live in the Website repo (`docs-src/adr/`), published at
it owns the range/count and the one-line summaries. Do not restate the `(001-NNN)` range elsewhere.

## Architecture fitness functions
- **100 test methods across 32 abstract `*TestsBase` classes**, shipped once in the
- **102 test methods across 34 abstract `*TestsBase` classes**, shipped once in the
`MMCA.Common.Testing.Architecture` package (ADR-015) and re-run as thin subclasses across all consuming
repos (Common, ADC, Store).
- MMCA.Common's own build executes **78** of them (the methods of the bases its arch-tests
- MMCA.Common's own build executes **87** of them (the methods of the bases its arch-tests
subclass, plus its Common-only direct tests, e.g. `FrameworkSanityTests`/`SpecificationFitnessTests`).

## Governance rubric
Expand Down
52 changes: 39 additions & 13 deletions Source/Core/MMCA.Common.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,33 +52,46 @@ public IServiceCollection AddApplication()
/// <para>
/// <b>Command pipeline (nesting from outermost to innermost):</b>
/// <code>
/// FeatureGateCommandDecorator ← outermost: short-circuits if feature flag disabled
/// → LoggingCommandDecorator ← logs start/end, captures full pipeline duration
/// → CachingCommandDecorator ← invalidates cache AFTER transaction commits
/// → ValidatingCommandDecorator ← short-circuits with Result.Failure on validation errors
/// → TransactionalCommandDecorator ← wraps handler in DB transaction (if ITransactional)
/// → ConcreteHandler ← the actual business logic
/// FeatureGateCommandDecorator ← outermost: short-circuits if feature flag disabled
/// → AuthorizationCommandDecorator ← short-circuits with Forbidden (if IRequiresPermission)
/// → LoggingCommandDecorator ← logs start/end, captures full pipeline duration
/// → CachingCommandDecorator ← invalidates cache AFTER transaction commits
/// → ValidatingCommandDecorator ← short-circuits with Result.Failure on validation errors
/// → TimeoutCommandDecorator ← applies the command's own budget (if IHasTimeout)
/// → TransactionalCommandDecorator ← wraps handler in DB transaction (if ITransactional)
/// → ConcreteHandler ← the actual business logic
/// </code>
/// </para>
/// <para>
/// <b>Query pipeline (nesting from outermost to innermost):</b>
/// <code>
/// FeatureGateQueryDecorator ← outermost: short-circuits if feature flag disabled
/// → LoggingQueryDecorator ← logs start/end, captures full pipeline duration
/// → CachingQueryDecorator ← innermost: caches results (if IQueryCacheable)
/// → ConcreteHandler ← the actual query logic
/// FeatureGateQueryDecorator ← outermost: short-circuits if feature flag disabled
/// → AuthorizationQueryDecorator ← short-circuits with Forbidden (if IRequiresPermission)
/// → LoggingQueryDecorator ← logs start/end, captures full pipeline duration
/// → CachingQueryDecorator ← caches results (if IQueryCacheable)
/// → TimeoutQueryDecorator ← innermost: applies the query's own budget (if IHasTimeout)
/// → ConcreteHandler ← the actual query logic
/// </code>
/// </para>
/// <para>
/// <b>Design rationale:</b>
/// <list type="bullet">
/// <item>Feature gating is outermost so disabled features are rejected immediately with zero
/// overhead — no logging, caching, validation, or transaction work.</item>
/// overhead: no authorization, logging, caching, validation, or transaction work. It also
/// sits outside authorization deliberately: a feature that is off must answer the same way
/// for every caller rather than leaking which permission guards it.</item>
/// <item>Authorization sits directly inside feature gating and outside caching, so a denied
/// request neither reads nor populates the cache: a cache lookup ahead of the permission
/// check would serve another caller's rows to a principal not allowed to run the query.</item>
/// <item>Logging sits inside feature gating so it only measures enabled feature executions.</item>
/// <item>Validation sits outside the transaction boundary so invalid commands never start
/// a database transaction — saving resources on malformed requests.</item>
/// <item>Cache invalidation sits outside validation so cache is only cleared after a valid,
/// committed mutation — a rollback or validation failure leaves cache intact.</item>
/// <item>The timeout budget sits inside validation and outside the transaction, so it covers
/// the database work that actually hangs, does not charge the caller for validation, and
/// cancels the transaction instead of leaving it open. On the query side it is innermost, so
/// a cache hit is served without starting a budget at all.</item>
/// <item>On business failure (<see cref="Result"/>.<c>IsFailure</c>), the transaction is rolled
/// back (atomicity over partial persistence) and cache invalidation is skipped.</item>
/// <item>On exception, the transaction rolls back and the exception propagates through all decorators.</item>
Expand All @@ -92,14 +105,18 @@ public IServiceCollection AddApplicationDecorators()
// Registered first = innermost (wraps the concrete handler directly).
// Registered last = outermost (wraps all other decorators).
services.TryDecorate(typeof(ICommandHandler<,>), typeof(TransactionalCommandDecorator<,>)); // innermost
services.TryDecorate(typeof(ICommandHandler<,>), typeof(ValidatingCommandDecorator<,>)); // validates before transaction
services.TryDecorate(typeof(ICommandHandler<,>), typeof(TimeoutCommandDecorator<,>)); // per-command execution budget
services.TryDecorate(typeof(ICommandHandler<,>), typeof(ValidatingCommandDecorator<,>)); // validates before the budget and transaction
services.TryDecorate(typeof(ICommandHandler<,>), typeof(CachingCommandDecorator<,>)); // cache invalidation
services.TryDecorate(typeof(ICommandHandler<,>), typeof(LoggingCommandDecorator<,>)); // logging
services.TryDecorate(typeof(ICommandHandler<,>), typeof(AuthorizationCommandDecorator<,>)); // permission check
services.TryDecorate(typeof(ICommandHandler<,>), typeof(FeatureGateCommandDecorator<,>)); // outermost — feature flag check

// ── Query decorators ────────────────────────────────────────
services.TryDecorate(typeof(IQueryHandler<,>), typeof(CachingQueryDecorator<,>)); // innermost
services.TryDecorate(typeof(IQueryHandler<,>), typeof(TimeoutQueryDecorator<,>)); // innermost: per-query execution budget
services.TryDecorate(typeof(IQueryHandler<,>), typeof(CachingQueryDecorator<,>)); // caching
services.TryDecorate(typeof(IQueryHandler<,>), typeof(LoggingQueryDecorator<,>)); // logging
services.TryDecorate(typeof(IQueryHandler<,>), typeof(AuthorizationQueryDecorator<,>)); // permission check
services.TryDecorate(typeof(IQueryHandler<,>), typeof(FeatureGateQueryDecorator<,>)); // outermost — feature flag check

return services;
Expand Down Expand Up @@ -135,6 +152,15 @@ public IServiceCollection ScanModuleApplicationServices<TAssemblyMarker>()
.AsSelfWithInterfaces()
.WithScopedLifetime());

// DTO projectors are optional and opt-in: an entity that has one gets server-side
// projection on its list reads, an entity that has none keeps materialize-then-map. They
// are scanned beside the mappers so a module only has to write the projector class.
services.Scan(scan => scan
.FromAssemblyOf<TAssemblyMarker>()
.AddClasses(classes => classes.AssignableTo(typeof(IEntityDTOProjector<,,>)))
.AsSelfWithInterfaces()
.WithScopedLifetime());

services.Scan(scan => scan
.FromAssemblyOf<TAssemblyMarker>()
.AddClasses(classes => classes.AssignableTo(typeof(IEntityRequestMapper<,,>)))
Expand Down
Loading