تخطَّ إلى المحتوى

.NET Examples

هذا المحتوى غير متوفر بلغتك بعد.

Illustrative only. The .NET Backend Standards page states the rules; this page shows the shapes. Where an example and a repo’s committed config disagree, the committed config wins.

global.json — everyone compiles with the same SDK, so analyzer results are identical everywhere.

{
"sdk": { "version": "10.0.100", "rollForward": "latestFeature" }
}

Directory.Build.props — coarse, solution-wide switches. Note what is not here: per-rule severity.

<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Build breaks on any warning. This is the load-bearing gate. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<!-- Pinned to the TFM major, never `latest`: an SDK patch must not
introduce new rules that break an unrelated PR. -->
<AnalysisLevel>10.0</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>

Directory.Packages.props — every version, once. A .csproj then references packages with no Version= attribute.

<Project>
<ItemGroup>
<PackageVersion Include="FastEndpoints" Version="7.1.0" />
<PackageVersion Include="FluentValidation" Version="12.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.8" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<!-- Analyzers and test runners: never flow to consumers. -->
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.27.0.140913">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageVersion>
</ItemGroup>
</Project>

Per-rule severity lives in .editorconfig, not in the props file:

[*.cs]
dotnet_diagnostic.CA1062.severity = error
dotnet_diagnostic.CA2007.severity = none # we don't use ConfigureAwait in app code

One folder = one use case. Four small files instead of an endpoint, a controller, a handler, a service, and an interface.

Api/Endpoints/BranchLocations/SetBranchLocation/
SetBranchLocationEndpoint.cs
SetBranchLocationRequest.cs
SetBranchLocationValidator.cs
SetBranchLocationCommand.cs
// SetBranchLocationEndpoint.cs — the endpoint IS the handler.
public sealed class SetBranchLocationEndpoint(AppDbContext db)
: Endpoint<SetBranchLocationRequest>
{
public override void Configure()
{
Put("/branches/{BranchId}/location");
Permissions(Perm.ManageBranches);
}
public override async Task HandleAsync(SetBranchLocationRequest req, CancellationToken ct)
{
var branch = await db.Branches.FindAsync([req.BranchId], ct);
if (branch is null) { await SendNotFoundAsync(ct); return; }
// Invariants live on the entity and throw — they are not re-checked here.
branch.MoveTo(req.Latitude, req.Longitude);
await db.SaveChangesAsync(ct);
await SendNoContentAsync(ct);
}
}
// SetBranchLocationValidator.cs — transport validation only, its own file.
public sealed class SetBranchLocationValidator : Validator<SetBranchLocationRequest>
{
public SetBranchLocationValidator()
{
RuleFor(x => x.Latitude).InclusiveBetween(-90, 90);
RuleFor(x => x.Longitude).InclusiveBetween(-180, 180);
}
}

3. Errors and config — fail loudly, leak nothing

Section titled “3. Errors and config — fail loudly, leak nothing”
// One handler for the whole app. Detail goes to the log; the client gets a stable shape.
internal sealed class GlobalExceptionHandler(
IProblemDetailsService problemDetails,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx, Exception ex, CancellationToken ct)
{
logger.LogError(ex, "Unhandled exception on {Path}", ctx.Request.Path);
ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = ctx,
ProblemDetails =
{
Title = "An unexpected error occurred.", // fixed string — never ex.Message
Status = StatusCodes.Status500InternalServerError,
},
});
}
}

Options binding — the difference between “boots with a wrong config” and “refuses to boot”:

// Wrong: a missing section silently becomes a default, and auth may end up permissive.
services.AddSingleton(config.GetSection("Osrm").Get<OsrmOptions>() ?? new OsrmOptions());
// Right: a missing or invalid section fails startup.
services.AddOptions<OsrmOptions>()
.Bind(config.GetSection(OsrmOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();

Validation failures: two settings, not one

Section titled “Validation failures: two settings, not one”

API Design requires 422 and RFC 9457 application/problem+json on a validation failure. FastEndpoints does neither by default — a failed validator returns 400 in FastEndpoints’ own ErrorResponse shape. Turning on problem details is not enough either: ErrorOptions.StatusCode defaults to 400 and UseProblemDetails() does not touch it, so you get RFC-shaped JSON with the wrong status. Both values, or the standard is not met.

app.UseFastEndpoints(c =>
{
// 1. The shape: problem+json instead of FastEndpoints' ErrorResponse.
c.Errors.UseProblemDetails();
// 2. The status. Independent of the above — without it, validation
// failures stay 400 and api-design.md's 422 rule is unmet.
c.Errors.StatusCode = StatusCodes.Status422UnprocessableEntity;
});

One test asserts both, because either alone passes a review and fails the contract:

[Fact]
public async Task Validation_failure_is_422_problem_json()
{
var ct = TestContext.Current.CancellationToken;
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/v1/branches/b-1/location", new { latitude = 999, longitude = 0 }, ct);
response.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity);
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/problem+json");
}

The failure it prevents: two feature branches each add a migration, both merge, and the migration chain on main is now ordered differently from the one either developer tested.

# In the PR pipeline. Needs full history — a shallow checkout silently passes.
- checkout: self
fetchDepth: 0
- task: PowerShell@2
displayName: 'Block a branch that is behind on migrations'
inputs:
filePath: 'devops/scripts/Compare-Migrations.ps1'
arguments: >
-SourceBranch $(System.PullRequest.SourceBranch)
-TargetBranch $(System.PullRequest.TargetBranch)
-MigrationsPath 'src/Infrastructure/Migrations'

The script computes the merge base and fails the build when the target branch holds a migration file the source branch does not. Worked example:

  1. main has 20260701_AddBranchLocation. Your branch does not.
  2. Your PR adds 20260702_AddRepTerritorybuild fails.
  3. Merge main into your branch, regenerate your migration so it sorts last, push → green.

Back to .NET Backend Standards.

👤 المسؤول: Firas Darwish🗓 آخر مراجعة: 2026-07-26

Read this page in English