.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.
1. The repo baseline — three files
Section titled “1. The repo baseline — three files”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 = errordotnet_diagnostic.CA2007.severity = none # we don't use ConfigureAwait in app code2. A vertical slice, end to end
Section titled “2. A vertical slice, end to end”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();4. The migration-ordering gate
Section titled “4. The migration-ordering gate”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:
mainhas20260701_AddBranchLocation. Your branch does not.- Your PR adds
20260702_AddRepTerritory→ build fails. - Merge
maininto your branch, regenerate your migration so it sorts last, push → green.
Back to .NET Backend Standards.