Write production-grade C# and ASP.NET Core services on modern .NET — minimal APIs vs controllers, DI lifetimes, options and secrets, correct async/await with cancellation, nullable reference types, validation, ProblemDetails errors, EF Core usage, HttpClientFactory with resilience, structured logging, JWT and policy auth, background services and OpenAPI.
When agents use itUse this whenever the user asks to write, refactor, debug or explain C# back-end code; edits Program.cs, a *.csproj, appsettings.json, a DbContext, entity configurations, migrations, controllers, endpoint mappings, middleware, hosted services or HttpClient registrations; hits a deadlock, ObjectDisposedException, "cannot consume scoped service from singleton", null warnings, or asks "how do I do X in ASP.NET Core". Also apply it when reviewing C#/.NET pull requests, even if the user only asks about one method.
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill dotnet-development -a github-copilot
# or with the org installer (adds .github/skills/dotnet-development):
npx -y github:AGCO-Global/org-skills add skill dotnet-development
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the backend-dotnet-skills plugin, which bundles all Backend / .NET skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install backend-dotnet-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill dotnet-development -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill dotnet-development -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-development -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-development -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
.NET Development
Write every service as if it will run under load, behind a load balancer, with requests cancelled mid-flight and configuration injected by the platform. Target the current LTS release (.NET 8 and .NET 10 are LTS; odd-numbered releases such as .NET 9 are STS with a shorter support window) unless the project's global.json or <TargetFramework> pins otherwise — check before using newer APIs.
1. Decide the endpoint style
Situation
Default
Change when
New HTTP API, small-to-medium surface
Minimal APIs with MapGroup per feature, TypedResults
The team already has a large controller codebase — stay consistent
Heavy use of filters, model binding conventions, OData
Controllers ([ApiController])
—
Native AOT / trimming required
Minimal APIs (MVC controllers are not AOT-compatible)
—
Server-rendered UI
Razor Pages / Blazor
Not covered here
Keep endpoints thin: bind, validate, call one handler/service, map the result. No EF queries or business rules inline in Program.cs.
Captive dependency: a singleton holding a scoped DbContext (shared across requests, not thread-safe) or a transient typed HttpClient (never rotates handlers)
Scoped
DbContext, unit of work, per-request context
Resolving outside a scope (background services) throws or leaks
Transient
Lightweight, stateless helpers
Disposable transients resolved from the root container are held until shutdown
Enable ValidateScopes and ValidateOnBuild (on by default in Development) so captive dependencies fail at startup.
In singletons/background services, create a scope: using var scope = scopeFactory.CreateScope();.
Prefer constructor injection (primary constructors are fine); never IServiceProvider.GetService inside business code (service locator). Keyed services (.NET 8+) instead of hand-rolled factories when you need named variants.
3. Configuration, options and secrets
Bind sections to typed options and validate at startup: services.AddOptions<PaymentOptions>().BindConfiguration("Payment").ValidateDataAnnotations().ValidateOnStart();
IOptions<T> for static config, IOptionsSnapshot<T> (scoped) per request, IOptionsMonitor<T> in singletons that must see reloads.
Secrets never in appsettings.json or source: User Secrets locally, environment variables or a vault/secret-store configuration provider in deployed environments. Prefer managed/workload identity over connection-string passwords.
appsettings.{Environment}.json for non-secret differences only.
4. Async correctness
Async all the way down. Never.Result, .Wait() or .GetAwaiter().GetResult() on request paths — it blocks thread-pool threads and causes starvation under load.
Accept and pass CancellationToken through every I/O call (EF, HttpClient, streams); endpoints get HttpContext.RequestAborted bound automatically.
ASP.NET Core has no SynchronizationContext, so ConfigureAwait(false) is unnecessary in app code; do use it in reusable libraries that may run in UI/legacy hosts.
No async void except event handlers. Don't fire-and-forget Tasks from a request — hand work to a queue/background service.
ValueTask only for hot paths that usually complete synchronously; never await a ValueTask twice.
5. Language features that prevent bugs
<Nullable>enable</Nullable> and <TreatWarningsAsErrors>true</TreatWarningsAsErrors> (at least for nullable warnings) in Directory.Build.props. Don't silence with ! — fix the flow or model the absence.
record / record struct for DTOs, commands and value objects (value equality, immutability); required and init for mandatory properties.
Pattern matching and switch expressions for state/type dispatch; exhaustive switches with a _ => throw new UnreachableException() arm.
Use sealed by default on classes not designed for inheritance.
6. Validation and errors
Validate at the edge: FluentValidation (explicit validator per request type), DataAnnotations with [ApiController], or the built-in minimal API validation in newer releases — pick one per solution.
Invariants that must always hold belong in the domain type (constructor/factory), not only in validators.
Return RFC 9457 ProblemDetails for every error: builder.Services.AddProblemDetails(); + app.UseExceptionHandler(); + app.UseStatusCodePages();. Map known exceptions with an IExceptionHandler (.NET 8+).
Never leak stack traces or exception messages to clients outside Development; log them with a correlation/trace id and return that id in the problem.
7. EF Core
Concern
Rule
Lifetime
AddDbContext (scoped) or AddDbContextPool; IDbContextFactory<T> for background/Blazor/parallel work. Never share a context across threads
Reads
AsNoTracking() + projection with Select to a DTO; tracking only when you will modify
N+1
No lazy loading in APIs; Include or projection; watch the logged SQL count
Bulk changes
ExecuteUpdateAsync / ExecuteDeleteAsync (EF Core 7+) instead of load-modify-save loops
Raw SQL
FromSql($"... WHERE Id = {id}") / SqlQuery<T> — interpolation is parameterised. FromSqlRaw only with explicit parameters, never concatenation
Migrations
One migration per change, reviewed SQL (dotnet ef migrations script --idempotent), applied by a pipeline step or migration bundle, not Database.Migrate() at app start in multi-instance deployments
Concurrency
Row version / concurrency token on aggregates edited concurrently; handle DbUpdateConcurrencyException
8. Outbound HTTP and resilience
Always IHttpClientFactory — typed clients (services.AddHttpClient<PaymentsClient>(...)). Never new HttpClient() per call (socket exhaustion) nor one static client without PooledConnectionLifetime (stale DNS).
Add resilience with Microsoft.Extensions.Http.Resilience (built on Polly v8): .AddStandardResilienceHandler() gives timeout, retry with jitter, circuit breaker. Retry only idempotent operations or those carrying an idempotency key.
Set explicit timeouts; propagate the CancellationToken.
9. Logging
ILogger<T> with message templates, not interpolation: logger.LogInformation("Order {OrderId} placed for {CustomerId}", order.Id, customerId);
For hot paths use the [LoggerMessage] source generator (no allocations when disabled).
Never log secrets, tokens, full request bodies or PII; use scopes for correlation. Export via OpenTelemetry rather than vendor SDKs in business code.
10. Authentication and authorisation
JWT bearer: AddAuthentication().AddJwtBearer(...) validating issuer, audience, lifetime and signing keys (use the authority's metadata; never disable validation to "make it work").
Authorise with policies, not role strings scattered in code: AddAuthorizationBuilder().AddPolicy("orders:write", p => p.RequireClaim("scope", "orders.write"));
Resource-based checks (does this user own order X?) via IAuthorizationService or an explicit check in the handler — endpoint attributes alone don't cover it.
Fallback policy requiring authenticated users; opt out explicitly with AllowAnonymous.
11. Background work
BackgroundService for long-running loops; honour stoppingToken; wrap each iteration in try/catch + logging (an unhandled exception stops the host by default since .NET 6).
In-process queue via System.Threading.Channels for short, loss-tolerant work; a durable broker or job library for anything that must survive restarts.
Create a DI scope per unit of work; make handlers idempotent.
12. OpenAPI
.NET 9+: built-in builder.Services.AddOpenApi() / app.MapOpenApi(); earlier: Swashbuckle or NSwag. Describe responses via TypedResults/Produces<T>; commit the generated document or diff it in CI to catch breaking changes.
Deliverable format
When writing or changing code, return:
Files changed with full code for new files and focused diffs for edits (Program.cs registrations included).
Why — one line per non-obvious decision (lifetime, tracking mode, resilience policy).
Migrations/config — new migration name, new options keys, secrets the operator must provide.
Tests added or needed (unit + integration) and how to run them (dotnet test).
Anti-patterns to reject
.Result / .Wait() / async void; missing CancellationToken on I/O.
Scoped services (especially DbContext) captured by singletons or static fields.
new HttpClient() per request; retries on non-idempotent POSTs without an idempotency key.
Secrets in appsettings.json, source or logs; disabled token validation.
Business logic and EF queries inline in endpoints or controllers.
Lazy loading in APIs, tracking queries for read-only data, string-concatenated SQL.
Interpolated log messages; ! to silence nullable warnings.
---
name: dotnet-development
description: Write production-grade C# and ASP.NET Core services on modern .NET — minimal APIs vs controllers, DI lifetimes, options and secrets, correct async/await with cancellation, nullable reference types, validation, ProblemDetails errors, EF Core usage, HttpClientFactory with resilience, structured logging, JWT and policy auth, background services and OpenAPI. Use this whenever the user asks to write, refactor, debug or explain C# back-end code; edits Program.cs, a *.csproj, appsettings.json, a DbContext, entity configurations, migrations, controllers, endpoint mappings, middleware, hosted services or HttpClient registrations; hits a deadlock, ObjectDisposedException, "cannot consume scoped service from singleton", null warnings, or asks "how do I do X in ASP.NET Core". Also apply it when reviewing C#/.NET pull requests, even if the user only asks about one method.
metadata:
technology: .NET
type: development
---
# .NET Development
Write every service as if it will run under load, behind a load balancer, with requests cancelled mid-flight and configuration injected by the platform. Target the **current LTS** release (.NET 8 and .NET 10 are LTS; odd-numbered releases such as .NET 9 are STS with a shorter support window) unless the project's `global.json` or `<TargetFramework>` pins otherwise — check before using newer APIs.
## 1. Decide the endpoint style
| Situation | Default | Change when |
|---|---|---|
| New HTTP API, small-to-medium surface | **Minimal APIs** with `MapGroup` per feature, `TypedResults` | The team already has a large controller codebase — stay consistent |
| Heavy use of filters, model binding conventions, OData | **Controllers** (`[ApiController]`) | — |
| Native AOT / trimming required | Minimal APIs (MVC controllers are not AOT-compatible) | — |
| Server-rendered UI | Razor Pages / Blazor | Not covered here |
Keep endpoints thin: bind, validate, call one handler/service, map the result. No EF queries or business rules inline in `Program.cs`.
```csharp
var orders = app.MapGroup("/orders").WithTags("Orders").RequireAuthorization();
orders.MapGet("/{id:guid}", async Task<Results<Ok<OrderDto>, NotFound>> (
Guid id, IOrderQueries queries, CancellationToken ct) =>
await queries.GetAsync(id, ct) is { } dto ? TypedResults.Ok(dto) : TypedResults.NotFound());
```
## 2. Dependency injection lifetimes
| Lifetime | Use for | Pitfall |
|---|---|---|
| Singleton | Stateless, thread-safe services, caches, compiled lookups | **Captive dependency**: a singleton holding a scoped `DbContext` (shared across requests, not thread-safe) or a transient typed `HttpClient` (never rotates handlers) |
| Scoped | `DbContext`, unit of work, per-request context | Resolving outside a scope (background services) throws or leaks |
| Transient | Lightweight, stateless helpers | Disposable transients resolved from the root container are held until shutdown |
- Enable `ValidateScopes` and `ValidateOnBuild` (on by default in Development) so captive dependencies fail at startup.
- In singletons/background services, create a scope: `using var scope = scopeFactory.CreateScope();`.
- Prefer constructor injection (primary constructors are fine); never `IServiceProvider.GetService` inside business code (service locator). Keyed services (.NET 8+) instead of hand-rolled factories when you need named variants.
## 3. Configuration, options and secrets
- Bind sections to typed options and validate at startup: `services.AddOptions<PaymentOptions>().BindConfiguration("Payment").ValidateDataAnnotations().ValidateOnStart();`
- `IOptions<T>` for static config, `IOptionsSnapshot<T>` (scoped) per request, `IOptionsMonitor<T>` in singletons that must see reloads.
- Secrets never in `appsettings.json` or source: User Secrets locally, environment variables or a vault/secret-store configuration provider in deployed environments. Prefer managed/workload identity over connection-string passwords.
- `appsettings.{Environment}.json` for non-secret differences only.
## 4. Async correctness
- Async all the way down. **Never** `.Result`, `.Wait()` or `.GetAwaiter().GetResult()` on request paths — it blocks thread-pool threads and causes starvation under load.
- Accept and pass `CancellationToken` through every I/O call (EF, HttpClient, streams); endpoints get `HttpContext.RequestAborted` bound automatically.
- ASP.NET Core has no `SynchronizationContext`, so `ConfigureAwait(false)` is unnecessary in app code; **do** use it in reusable libraries that may run in UI/legacy hosts.
- No `async void` except event handlers. Don't fire-and-forget `Task`s from a request — hand work to a queue/background service.
- `ValueTask` only for hot paths that usually complete synchronously; never await a `ValueTask` twice.
## 5. Language features that prevent bugs
- `<Nullable>enable</Nullable>` and `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>` (at least for nullable warnings) in `Directory.Build.props`. Don't silence with `!` — fix the flow or model the absence.
- `record` / `record struct` for DTOs, commands and value objects (value equality, immutability); `required` and `init` for mandatory properties.
- Pattern matching and switch expressions for state/type dispatch; exhaustive switches with a `_ => throw new UnreachableException()` arm.
- Use `sealed` by default on classes not designed for inheritance.
## 6. Validation and errors
- Validate at the edge: FluentValidation (explicit validator per request type), DataAnnotations with `[ApiController]`, or the built-in minimal API validation in newer releases — pick one per solution.
- Invariants that must always hold belong in the domain type (constructor/factory), not only in validators.
- Return **RFC 9457 ProblemDetails** for every error: `builder.Services.AddProblemDetails();` + `app.UseExceptionHandler();` + `app.UseStatusCodePages();`. Map known exceptions with an `IExceptionHandler` (.NET 8+).
- Never leak stack traces or exception messages to clients outside Development; log them with a correlation/trace id and return that id in the problem.
## 7. EF Core
| Concern | Rule |
|---|---|
| Lifetime | `AddDbContext` (scoped) or `AddDbContextPool`; `IDbContextFactory<T>` for background/Blazor/parallel work. Never share a context across threads |
| Reads | `AsNoTracking()` + projection with `Select` to a DTO; tracking only when you will modify |
| N+1 | No lazy loading in APIs; `Include` or projection; watch the logged SQL count |
| Bulk changes | `ExecuteUpdateAsync` / `ExecuteDeleteAsync` (EF Core 7+) instead of load-modify-save loops |
| Raw SQL | `FromSql($"... WHERE Id = {id}")` / `SqlQuery<T>` — interpolation is parameterised. `FromSqlRaw` only with explicit parameters, never concatenation |
| Migrations | One migration per change, reviewed SQL (`dotnet ef migrations script --idempotent`), applied by a pipeline step or migration bundle, not `Database.Migrate()` at app start in multi-instance deployments |
| Concurrency | Row version / concurrency token on aggregates edited concurrently; handle `DbUpdateConcurrencyException` |
## 8. Outbound HTTP and resilience
- Always `IHttpClientFactory` — typed clients (`services.AddHttpClient<PaymentsClient>(...)`). Never `new HttpClient()` per call (socket exhaustion) nor one static client without `PooledConnectionLifetime` (stale DNS).
- Add resilience with `Microsoft.Extensions.Http.Resilience` (built on Polly v8): `.AddStandardResilienceHandler()` gives timeout, retry with jitter, circuit breaker. Retry only idempotent operations or those carrying an idempotency key.
- Set explicit timeouts; propagate the `CancellationToken`.
## 9. Logging
- `ILogger<T>` with **message templates**, not interpolation: `logger.LogInformation("Order {OrderId} placed for {CustomerId}", order.Id, customerId);`
- For hot paths use the `[LoggerMessage]` source generator (no allocations when disabled).
- Never log secrets, tokens, full request bodies or PII; use scopes for correlation. Export via OpenTelemetry rather than vendor SDKs in business code.
## 10. Authentication and authorisation
- JWT bearer: `AddAuthentication().AddJwtBearer(...)` validating issuer, audience, lifetime and signing keys (use the authority's metadata; never disable validation to "make it work").
- Authorise with **policies**, not role strings scattered in code: `AddAuthorizationBuilder().AddPolicy("orders:write", p => p.RequireClaim("scope", "orders.write"));`
- Resource-based checks (does this user own order X?) via `IAuthorizationService` or an explicit check in the handler — endpoint attributes alone don't cover it.
- Fallback policy requiring authenticated users; opt out explicitly with `AllowAnonymous`.
## 11. Background work
- `BackgroundService` for long-running loops; honour `stoppingToken`; wrap each iteration in try/catch + logging (an unhandled exception stops the host by default since .NET 6).
- In-process queue via `System.Threading.Channels` for short, loss-tolerant work; a durable broker or job library for anything that must survive restarts.
- Create a DI scope per unit of work; make handlers idempotent.
## 12. OpenAPI
- .NET 9+: built-in `builder.Services.AddOpenApi()` / `app.MapOpenApi()`; earlier: Swashbuckle or NSwag. Describe responses via `TypedResults`/`Produces<T>`; commit the generated document or diff it in CI to catch breaking changes.
## Deliverable format
When writing or changing code, return:
1. **Files changed** with full code for new files and focused diffs for edits (`Program.cs` registrations included).
2. **Why** — one line per non-obvious decision (lifetime, tracking mode, resilience policy).
3. **Migrations/config** — new migration name, new options keys, secrets the operator must provide.
4. **Tests** added or needed (unit + integration) and how to run them (`dotnet test`).
## Anti-patterns to reject
- `.Result` / `.Wait()` / `async void`; missing `CancellationToken` on I/O.
- Scoped services (especially `DbContext`) captured by singletons or static fields.
- `new HttpClient()` per request; retries on non-idempotent POSTs without an idempotency key.
- Secrets in `appsettings.json`, source or logs; disabled token validation.
- Business logic and EF queries inline in endpoints or controllers.
- Lazy loading in APIs, tracking queries for read-only data, string-concatenated SQL.
- Swallowed exceptions, `catch (Exception) { return null; }`, custom error shapes instead of ProblemDetails.
- Interpolated log messages; `!` to silence nullable warnings.