Platform Foundationsv1.3.2

Backend

The .NET conventions, the reasoning, and the patterns for everyday work.

Backend apps are .NET and C#. The source of truth is the Plain engineering skills. This page explains how the pieces fit and when to reach for each one; the skills hold the exhaustive rules.

Two skills anchor everything. plain-engineering-conventions is language-agnostic (errors, boundaries, observability, testing, security). plain-dotnet-guardrails is the .NET side (VSA, EF Core, Aspire, DI, build hygiene, architecture tests). Read those two first.

Local orchestration with Aspire

Every Platform .NET app runs its whole local system through .NET Aspire: the AppHost describes the composition in C# instead of docker-compose YAML, and the same dotnet run --project src/{Project}.AppHost (wired as pnpm dev) starts the database, the API, and the web frontend in dependency order with health gating. Three pieces:

  • AppHost — an executable project that declares resources and services. Containers get persistent lifetimes and data volumes; services get injected connection strings and WaitFor health gating; the frontend joins through the CommunityToolkit NodeJS extension with a fixed dev port. It is the only composition: repos drop their docker-compose files rather than carrying two definitions in parallel.
  • ServiceDefaults — a shared project every service references. AddServiceDefaults() brings OpenTelemetry (logs, traces, metrics), service discovery, default resilience on every HttpClient, and /health + /alive endpoints. The Aspire dashboard shows the whole system's logs, traces, and resource health locally, with zero configuration.
  • Client integrations — packages such as Aspire.Microsoft.EntityFrameworkCore.SqlServer wire pooling, health checks, and SQL spans into EF Core from the connection name the AppHost injects. Connection strings are resolved by name at runtime and never hardcoded in appsettings.json.

The shape in practice:

var builder = DistributedApplication.CreateBuilder(args);

var sql = builder.AddSqlServer("sql")
    .WithLifetime(ContainerLifetime.Persistent)
    .WithDataVolume();
var db = sql.AddDatabase("appdb");

var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WithReference(db).WaitFor(db)
    .WithExternalHttpEndpoints();

builder.AddPnpmApp("web", "../../apps/web", "dev")
    .WithPnpmPackageInstallation()
    .WithHttpEndpoint(port: 3000, env: "PORT")
    .WithEnvironment("NEXT_PUBLIC_API_URL", api.GetEndpoint("http"))
    .WithExternalHttpEndpoints()
    .WaitFor(api);

Guardrails that every AppHost carries: .WithDataVolume() on stateful containers (data survives volume recreation, not just restarts), .WithRedisCommander() when Redis is in the graph, and .WaitFor(...) on every resource reference so services start only when dependencies are healthy. Resource names, connection string patterns, and the project-class naming pitfall are in the aspire-conventions reference of plain-dotnet-guardrails.

Architecture

Apps use Clean Architecture layers with the dependency arrow pointing inward:

Api             minimal-API endpoints, grouped by feature; the composition root
Application     use-cases as commands and queries (CQS); DTOs
Domain          entities, value objects, domain events; no outward dependencies
Infrastructure  EF Core and external integrations; implements Application/Domain interfaces
SharedKernel    DDD building blocks reused across the domain

A request flows one direction. It lands on an endpoint in Api, which dispatches a command or query to Application. The handler works with the Domain model and persists through Infrastructure. Domain knows nothing about the outer layers, which is what keeps the core testable and stable. The full reference is in Architecture; deeper tactical patterns are in the clean-ddd-hexagonal skill.

Vertical slices and CQS

Inside the layers, organize by feature, not by technical type. Everything for "create invoice" lives together rather than being scattered across generic Services and Repositories folders. Separate the write side from the read side: commands change state and return a result, queries read and never mutate. This keeps handlers small and intention-revealing.

Errors as values

Domain and application errors are return values, not thrown exceptions, using ErrorOr<T> mapped to RFC 7807 ProblemDetails at the edge:

public async Task<ErrorOr<User>> Handle(GetUser query)
{
    var user = await _users.FindAsync(query.Id);
    return user is null
        ? Error.NotFound("User.NotFound", "User not found")
        : user;
}

// endpoint maps the result to HTTP
result.Match(Results.Ok, errors => errors.ToProblemDetails());

Exceptions are for the genuinely exceptional, not for control flow.

When to use what

  • A service or small app: a few layered projects with feature folders inside. This is the default. Do not add modules or a mediator before you feel the pain.
  • A larger system: a modular monolith where each module is a bounded context, internally VSA and CQS. Split into separate deployables only when a boundary genuinely demands it.

Data access

EF Core is the default. Keep queries in Infrastructure behind interfaces the Application layer owns. Project to DTOs for reads instead of loading full aggregates, use AsNoTracking for read-only queries, and watch for N+1 with explicit Include or split queries. Repository and query patterns are in dotnet-backend-patterns; tuning is in optimizing-ef-core-queries.

Testing

Follow the pyramid. Many unit tests with xUnit over the domain and application handlers, where the value-as-errors style makes assertions clean. Fewer integration tests that run a real host through WebApplicationFactory against a real database through Testcontainers, so you catch wiring and query bugs. A few end-to-end tests over the critical paths. Skill: csharp-xunit.

Azure SQL backups in production

Every production environment manages its database backup policy from Bicep, explicitly, with the platform standard values. Restating the defaults is the point: an unmanaged policy is one portal click or one implicit reset away from drifting, and a backupShortTermRetentionPolicies deployment that only sets retentionDays silently resets the differential cadence to Azure's 12-hour default on every apply.

The standard is a 7-day point-in-time restore window, one differential backup every 24 hours, and no long-term retention unless the domain demands it:

resource backupRetention 'Microsoft.Sql/servers/databases/backupShortTermRetentionPolicies@2023-08-01-preview' = {
  parent: database
  name: 'default'
  properties: {
    retentionDays: 7              // PITR window, the platform production standard
    diffBackupIntervalInHours: 24 // stated so an apply cannot reset it to 12
  }
}

Wire both values from the environment config (infra/pro.env) through the infra workflow, so pro carries them and PRE stays on the platform defaults. Verify after the first apply in the portal under SQL server, Backups, Retention policies.

Observability and security

Structured logging with ILogger<T> and OpenTelemetry for traces and metrics. Never log secrets or personal data. Run dependency and secret scanning in CI. The plain-engineering-conventions references cover observability and security in depth.

Skills for agents

plain-engineering-conventions, plain-dotnet-guardrails, clean-ddd-hexagonal, dotnet-backend-patterns, modern-csharp-coding-standards, optimizing-ef-core-queries, csharp-xunit. See AI and agents for how to install them.