Skip to content

Commit 5df5ca6

Browse files
authored
Merge pull request #8 from HodeTech/feat/phase-02a-packet-3-cross-cutting-foundation
feat(phase-02a): packet 3 — cross-cutting foundation (ADR-0032)
2 parents 77d8e45 + 2c2099b commit 5df5ca6

102 files changed

Lines changed: 5433 additions & 65 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/Directory.Build.props‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
<Nullable>enable</Nullable>
77
<ImplicitUsings>enable</ImplicitUsings>
88
<TreatWarningsAsErrors Condition="'$(CI)' == 'true'">true</TreatWarningsAsErrors>
9+
<!--
10+
LS0001 ("LearnStackException-DomainExceptionThrow", ADR-0032
11+
§ Sub-decision 4 + Amendment 1) is a Warning until the Phase 03 exit
12+
gate, then escalates to Error. Keep it OUT of TreatWarningsAsErrors
13+
until then so a legitimate aggregate-invariant `throw new
14+
DomainException(...)` does not break CI before the documented
15+
escalation point. Remove LS0001 from this list when the analyzer's
16+
default severity flips to Error after Phase 03 exit.
17+
-->
18+
<WarningsNotAsErrors>$(WarningsNotAsErrors);LS0001</WarningsNotAsErrors>
919
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
1020
<AnalysisLevel>latest</AnalysisLevel>
1121
<!--
@@ -61,10 +71,18 @@
6171
CA2234 — `HttpClient.GetAsync(string)` overload is fine in test
6272
assertions; the Uri-overload preference is library-grade
6373
hygiene that costs readability in tests.
74+
CA1711 — xunit collection-definition classes by convention end in
75+
"Collection"; the rule was written for API surfaces, not
76+
test scaffolding.
77+
CA1822 — controller actions / endpoint methods that intentionally
78+
throw for L1-handler integration tests don't access
79+
instance data; making them static is fine but
80+
ControllerBase conventions and ASP.NET routing prefer
81+
instance methods.
6482
Real code-quality rules (CA1305 culture-invariant formatting, CA1861
6583
static-readonly array reuse) STAY ON — fix violations in the code.
6684
-->
67-
<NoWarn>$(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234</NoWarn>
85+
<NoWarn>$(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234;CA1711;CA1822</NoWarn>
6886
</PropertyGroup>
6987

7088
</Project>

‎backend/Directory.Packages.props‎

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,66 @@
5454
</ItemGroup>
5555

5656
<!--
57-
Reserved for Phase 02a wiring (Dapr building blocks, audit pipeline,
58-
observability, EF Core migrations + design-time). Declared centrally now
59-
so the version matrix is consistent the moment they are pulled in;
60-
pruning happens if a package is dropped from the Phase 02a plan entirely.
57+
Phase 02a Packet 3 — Cross-cutting foundation (ADR-0032).
58+
Serilog primary logger + OTLP sink, OpenTelemetry SDK with the canonical
59+
instrumentations + TenantContextSpanProcessor + OTLP exporter, Polly v8
60+
Resilience for IProviderResilience<TPort>, and the Sentry SDK referenced
61+
only by LearnStack.Infrastructure.ErrorTracking (enforced by the
62+
Modules_Do_Not_Reference_Sentry_SDK_Directly architecture test).
6163
-->
62-
<ItemGroup Label="Reserved for Phase 02a">
64+
<ItemGroup Label="Phase 02a (in use)">
6365
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
6466
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
6567
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
68+
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
69+
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
70+
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
71+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
72+
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
73+
74+
<!-- Serilog (primary logger; OTel via OTLP sink, no AddOpenTelemetry().WithLogging()). -->
75+
<PackageVersion Include="Serilog" Version="4.1.0" />
76+
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
77+
<PackageVersion Include="Serilog.Sinks.OpenTelemetry" Version="4.1.1" />
78+
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
79+
80+
<!-- OpenTelemetry SDK + canonical instrumentations + OTLP exporter. -->
81+
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
82+
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
83+
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
84+
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
85+
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
86+
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
87+
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
88+
89+
<!-- Polly v8 + the Microsoft.Extensions.Resilience surface used by
90+
IProviderResilience<TPort>. -->
91+
<PackageVersion Include="Polly" Version="8.5.0" />
92+
<PackageVersion Include="Polly.Extensions" Version="8.5.0" />
93+
<PackageVersion Include="Polly.RateLimiting" Version="8.5.0" />
94+
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="9.0.0" />
95+
96+
<!-- Sentry SDK — referenced ONLY by LearnStack.Infrastructure.ErrorTracking
97+
per ADR-0032 § Sub-decision 9. The architecture test
98+
Modules_Do_Not_Reference_Sentry_SDK_Directly enforces the scope. -->
99+
<PackageVersion Include="Sentry" Version="5.0.0" />
100+
</ItemGroup>
101+
102+
<!--
103+
Reserved for later Phase 02a packets (EF migrations + design-time).
104+
-->
105+
<ItemGroup Label="Reserved for later Phase 02a packets">
66106
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
67107
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
68-
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.10.0" />
69-
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
70-
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.10.0" />
71-
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.10.0" />
72-
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.10.0" />
108+
</ItemGroup>
109+
110+
<!--
111+
Roslyn analyzer host packages — referenced by backend/analyzers/.
112+
Pin to the Microsoft.CodeAnalysis.CSharp version that ships with the .NET 10 SDK.
113+
-->
114+
<ItemGroup Label="Analyzers">
115+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
116+
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
73117
</ItemGroup>
74118

75119
</Project>

‎backend/LearnStack.slnx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@
66
<Project Path="src/LearnStack.Application/LearnStack.Application.csproj" />
77
<Project Path="src/LearnStack.Infrastructure/LearnStack.Infrastructure.csproj" />
88
<Project Path="src/LearnStack.Infrastructure.Audit/LearnStack.Infrastructure.Audit.csproj" />
9+
<Project Path="src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj" />
10+
<Project Path="src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj" />
11+
<Project Path="src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj" />
912
<Project Path="src/LearnStack.Api/LearnStack.Api.csproj" />
1013
</Folder>
14+
<Folder Name="/analyzers/">
15+
<Project Path="analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj" />
16+
</Folder>
1117
<Folder Name="/src/Modules/Tenancy/">
1218
<Project Path="src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj" />
1319
<Project Path="src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/LearnStack.Modules.Tenancy.Application.Contracts.csproj" />
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
; Shipped analyzer releases.
2+
; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
; Unshipped analyzer release.
2+
; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
3+
4+
### New Rules
5+
6+
Rule ID | Category | Severity | Notes
7+
--------|----------|----------|------------
8+
LS0001 | Design | Warning | Rule "LearnStackException-DomainExceptionThrow" (ADR-0032 § Sub-decision 4 + Amendment 1). DomainException is reserved for programmer errors; expected business-rule violations return Result.Fail(business_rule_violation, ...). Roslyn diagnostic ids must be valid identifiers, so the wire id is LS0001; the hyphenated string is the human-readable rule name. Severity escalates to Error after Phase 03 exit.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace LearnStack.Analyzers;
8+
9+
/// <summary>
10+
/// Rule "LearnStackException-DomainExceptionThrow" (Roslyn diagnostic id
11+
/// <see cref="DiagnosticId"/> = <c>LS0001</c>) — flags every
12+
/// <c>throw new DomainException(...)</c> in Domain / Application code per
13+
/// ADR-0032 § Sub-decision 4. Expected business-rule violations return
14+
/// <c>Result.Fail(business_rule_violation, ...)</c>; the exception is
15+
/// reserved for programmer errors.
16+
/// </summary>
17+
/// <remarks>
18+
/// <para>
19+
/// The Roslyn diagnostic id MUST be a valid identifier (letters/digits, no
20+
/// hyphens) — Roslyn throws <c>AD0001</c> at report time otherwise. The
21+
/// human-readable rule name <c>LearnStackException-DomainExceptionThrow</c>
22+
/// (ADR-0032 / Standards 21 naming convention) is carried in the title and
23+
/// help text; the wire-level id is <c>LS0001</c>. See ADR-0032 Amendment 1.
24+
/// </para>
25+
/// <para>
26+
/// Severity: <see cref="DiagnosticSeverity.Warning"/> in Phase 02a. Per
27+
/// ADR-0032 the severity escalates to <see cref="DiagnosticSeverity.Error"/>
28+
/// after Phase 03 exit when every existing call site has been migrated.
29+
/// Until then <c>LS0001</c> is listed in <c>WarningsNotAsErrors</c>
30+
/// (Directory.Build.props) so a legitimate aggregate-invariant throw does
31+
/// not break CI under <c>TreatWarningsAsErrors</c> before the documented
32+
/// escalation point.
33+
/// </para>
34+
/// </remarks>
35+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
36+
public sealed class DomainExceptionThrowAnalyzer : DiagnosticAnalyzer
37+
{
38+
public const string DiagnosticId = "LS0001";
39+
40+
/// <summary>The human-readable rule name (ADR-0032 / Standards 21).</summary>
41+
public const string RuleName = "LearnStackException-DomainExceptionThrow";
42+
43+
private static readonly LocalizableString Title =
44+
"Avoid throwing DomainException for expected business-rule violations (LearnStackException-DomainExceptionThrow)";
45+
46+
private static readonly LocalizableString MessageFormat =
47+
"DomainException is reserved for programmer errors. " +
48+
"Return Result.Fail(business_rule_violation, ...) instead.";
49+
50+
private static readonly LocalizableString Description =
51+
"ADR-0032 § Sub-decision 4 reserves DomainException for aggregate-invariant " +
52+
"violations that signal a programming mistake. Expected business-rule " +
53+
"violations are an outcome — return Result.Fail(business_rule_violation, ...) " +
54+
"from the domain method.";
55+
56+
private static readonly DiagnosticDescriptor Rule = new(
57+
id: DiagnosticId,
58+
title: Title,
59+
messageFormat: MessageFormat,
60+
category: "Design",
61+
defaultSeverity: DiagnosticSeverity.Warning,
62+
isEnabledByDefault: true,
63+
description: Description,
64+
helpLinkUri: "https://github.com/cemililik/LearnStack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md");
65+
66+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
67+
=> ImmutableArray.Create(Rule);
68+
69+
public override void Initialize(AnalysisContext context)
70+
{
71+
if (context is null) throw new System.ArgumentNullException(nameof(context));
72+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
73+
context.EnableConcurrentExecution();
74+
context.RegisterSyntaxNodeAction(AnalyzeThrowExpression, SyntaxKind.ThrowExpression);
75+
context.RegisterSyntaxNodeAction(AnalyzeThrowStatement, SyntaxKind.ThrowStatement);
76+
}
77+
78+
private static void AnalyzeThrowStatement(SyntaxNodeAnalysisContext context)
79+
{
80+
var node = (ThrowStatementSyntax)context.Node;
81+
if (node.Expression is null) return;
82+
InspectThrown(context, node.Expression);
83+
}
84+
85+
private static void AnalyzeThrowExpression(SyntaxNodeAnalysisContext context)
86+
{
87+
var node = (ThrowExpressionSyntax)context.Node;
88+
InspectThrown(context, node.Expression);
89+
}
90+
91+
private static void InspectThrown(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
92+
{
93+
if (expression is not ObjectCreationExpressionSyntax creation)
94+
{
95+
return;
96+
}
97+
98+
var typeInfo = context.SemanticModel.GetTypeInfo(creation, context.CancellationToken);
99+
var symbol = typeInfo.Type;
100+
if (symbol is null)
101+
{
102+
return;
103+
}
104+
105+
if (!IsDomainException(symbol))
106+
{
107+
return;
108+
}
109+
110+
context.ReportDiagnostic(Diagnostic.Create(Rule, creation.GetLocation()));
111+
}
112+
113+
private static bool IsDomainException(ITypeSymbol type)
114+
{
115+
for (var current = type; current is not null; current = current.BaseType)
116+
{
117+
if (current.Name == "DomainException" &&
118+
current.ContainingNamespace?.ToDisplayString() == "LearnStack.SharedKernel.Errors")
119+
{
120+
return true;
121+
}
122+
}
123+
124+
return false;
125+
}
126+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<!-- Roslyn analyzers must target netstandard2.0 — the analyzer host
5+
(Visual Studio, dotnet build, Roslyn) only loads netstandard2.0
6+
analyzer assemblies regardless of the project's TargetFramework.
7+
See: https://learn.microsoft.com/dotnet/csharp/roslyn-sdk/tutorials/how-to-write-csharp-analyzer-code-fix#analyzer-target-framework -->
8+
<TargetFramework>netstandard2.0</TargetFramework>
9+
<Nullable>enable</Nullable>
10+
<LangVersion>latest</LangVersion>
11+
<IsPackable>false</IsPackable>
12+
<RootNamespace>LearnStack.Analyzers</RootNamespace>
13+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
14+
</PropertyGroup>
15+
16+
<ItemGroup>
17+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp">
18+
<PrivateAssets>all</PrivateAssets>
19+
</PackageReference>
20+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers">
21+
<PrivateAssets>all</PrivateAssets>
22+
</PackageReference>
23+
</ItemGroup>
24+
25+
<ItemGroup>
26+
<!-- Roslyn release-tracking rule (RS2008) requires these markdown files
27+
to live alongside the analyzer assembly. -->
28+
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
29+
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
30+
</ItemGroup>
31+
32+
</Project>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System.Net;
2+
using LearnStack.SharedKernel.Errors;
3+
using LearnStack.SharedKernel.Results;
4+
5+
namespace LearnStack.Api.Common;
6+
7+
/// <summary>
8+
/// Maps an <see cref="Error.Code"/> (or a <see cref="LearnStackException"/>
9+
/// subclass) to the HTTP status the API surface returns. The table mirrors
10+
/// <see href="../../../../docs/standards/09-error-handling.md">Standards 09
11+
/// § Result Type</see>; adding a new error code requires updating both
12+
/// places so the contract stays in sync.
13+
/// </summary>
14+
public static class HttpStatusMap
15+
{
16+
public static int For(Error error)
17+
{
18+
ArgumentNullException.ThrowIfNull(error);
19+
return For(error.Code);
20+
}
21+
22+
public static int For(string code) => code switch
23+
{
24+
"validation_failed" => (int)HttpStatusCode.BadRequest,
25+
"unsupported_locale" => (int)HttpStatusCode.BadRequest,
26+
"unauthorized" => (int)HttpStatusCode.Unauthorized,
27+
"forbidden" => (int)HttpStatusCode.Forbidden,
28+
"resource_scope_violation" => (int)HttpStatusCode.Forbidden,
29+
"feature_disabled" => (int)HttpStatusCode.Forbidden,
30+
"not_found" => (int)HttpStatusCode.NotFound,
31+
"tenant_mismatch" => (int)HttpStatusCode.NotFound,
32+
"concurrency_conflict" => (int)HttpStatusCode.Conflict,
33+
"business_rule_violation" => (int)HttpStatusCode.Conflict,
34+
"recording_consent_required" => (int)HttpStatusCode.Conflict,
35+
"rate_limited" => (int)HttpStatusCode.TooManyRequests,
36+
"dependency_unavailable" => (int)HttpStatusCode.ServiceUnavailable,
37+
_ => (int)HttpStatusCode.InternalServerError,
38+
};
39+
40+
public static int For(Exception exception)
41+
{
42+
ArgumentNullException.ThrowIfNull(exception);
43+
44+
return exception switch
45+
{
46+
// 499 "client closed request" is an Nginx convention, not an
47+
// IETF code — Standards 09 § Result Type does not pin a status
48+
// for client disconnects. We pick 499 (rather than 408 / 503 /
49+
// 500) because:
50+
// * IIS, Nginx, Envoy, and APISIX all emit 499 for pre-
51+
// response client aborts; log dashboards and SLO calculators
52+
// already treat it as "not our fault".
53+
// * 408 implies a server-side timeout (we did not time out —
54+
// the client left).
55+
// * 5xx codes would inflate error-budget metrics and trip
56+
// PagerDuty rotations for nothing.
57+
// L1 handler skips both Sentry capture and the response body
58+
// write for OperationCanceled per ADR-0032 § Sub-decision 7;
59+
// the status is set here for parity with the upstream proxy's
60+
// behaviour. If a future ADR pins a different code, change
61+
// this line.
62+
OperationCanceledException => 499,
63+
64+
// Every LearnStackException carries a structured Error; the HTTP
65+
// status is derived from that Error.Code so the response status
66+
// and the Problem Details `code` field can NEVER disagree. In
67+
// particular `ProviderException.IsClientError` is an
68+
// observability concern (it gates Sentry capture in
69+
// ShouldCapture), NOT an HTTP-status concern: a bare provider
70+
// failure carries `dependency_unavailable` → 503, and an adapter
71+
// that wants to surface a provider 4xx as a client-actionable
72+
// status passes an explicit Error (e.g. validation_failed → 400).
73+
// Deriving from the code keeps body+status consistent for both.
74+
LearnStackException known => For(known.Error),
75+
_ => (int)HttpStatusCode.InternalServerError,
76+
};
77+
}
78+
}

0 commit comments

Comments
 (0)