C# and .NET skill for Claude Code
Nullable-clean, cancellable, typed .NET code. Claude sets up .NET solutions with Directory.Build.props, central package versions, nullable reference types and warnings as errors, then writes code that threads a CancellationToken through every async call and never blocks on .Result. Endpoints use minimal APIs with typed result unions and route groups, and EF Core reads are projected and untracked.
- name
csharp-dotnet - size218 lines · ≈2,300 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes modern C# on .NET 8, 9, and 10 — records and primary constructors, nullable reference types as errors, async/await with CancellationToken, ASP.NET Core minimal APIs with TypedResults and route groups, EF Core querying and migrations, and dotnet CLI, Directory.Build.props, and central package management. Use when creating or reviewing a .NET project, enabling nullable, fixing async deadlocks, building minimal API endpoints, writing EF Core queries, or upgrading from .NET Framework or older .NET. Try asking
- Enable nullable reference types across this solution and fix the warnings properly
- Rewrite these MVC controllers as minimal API endpoints with typed results
- This endpoint deadlocks under load — check the async code
Use it when
- Creating or restructuring a .NET solution
- Turning on nullable reference types or warnings as errors
- Writing ASP.NET Core minimal API endpoints
- Reviewing async code or EF Core queries for correctness and performance
Skip it when
- The slowness is N+1 query patterns across an ORM — use n-plus-one-queries
- Designing REST resource shapes and versioning — use rest-api-design
How to check the result
dotnet build -warnaserror, dotnet test and dotnet format --verify-no-changes pass, and a grep for .Result, .Wait() and async void in src returns nothing.
The common mistake it prevents
Blocking on .Result or firing Task.Run from a request handler, which starves the thread pool or uses a DbContext after its scope is disposed.
Done well means: the solution builds with <Nullable>enable</Nullable> and warnings as
errors, every async path takes and honors a CancellationToken, no .Result or .Wait()
exists, endpoints return typed results that document themselves in OpenAPI, EF Core
queries are projected and untracked for reads, and dotnet format --verify-no-changes
passes.
Ground rules
- Target an LTS: .NET 8 or .NET 10 (Nov 2025). Pin the SDK in
global.jsonso CI and developers agree. - Nullable warnings are bugs. Never blanket-suppress with
#nullable disableor the!operator to make them go away. - Async all the way down. No sync-over-async; no
async voidexcept event handlers. - Configuration through strongly typed options (
IOptions<T>with validation), neverconfiguration["Key"]scattered in code. - Settings shared by all projects go in
Directory.Build.props; package versions inDirectory.Packages.props.
Inspect before changing
dotnet --info | head -20
cat global.json 2>/dev/null
grep -rhoE "<TargetFramework>[^<]+|<Nullable>[^<]+|<TreatWarningsAsErrors>[^<]+" --include=*.csproj --include=*.props . | sort | uniq -c
ls Directory.Build.props Directory.Packages.props 2>/dev/null
Solution-wide settings
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
<!-- Directory.Packages.props: csproj files then use <PackageReference Include="X" /> without Version -->
<Project>
<PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
</ItemGroup>
</Project>
Check actual current package versions with dotnet list package --outdated rather than
copying the numbers above.
Enabling nullable on a large existing project: turn it on per project, or per file with
#nullable enable at the top of migrated files, starting with domain models; fix
warnings; then flip the project setting.
Language features to use (C# 12–14)
public record Money(decimal Amount, string Currency); // value equality, with-expressions
public sealed record OrderPlaced(Guid OrderId, DateTimeOffset At);
public sealed class OrderService(IOrderRepository repo, TimeProvider clock, ILogger<OrderService> log)
{ // primary constructor: parameters captured, not readonly fields — do not reassign them
public async Task<Order> PlaceAsync(Cart cart, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(cart);
var order = Order.From(cart, clock.GetUtcNow());
await repo.AddAsync(order, ct);
log.LogInformation("Placed order {OrderId} with {ItemCount} items", order.Id, cart.Items.Count);
return order;
}
}
int[] primes = [2, 3, 5, 7]; // collection expressions
List<string> merged = [..first, ..second];
var label = status switch // switch expressions + patterns
{
OrderStatus.Pending or OrderStatus.Processing => "in progress",
OrderStatus.Shipped => "on the way",
_ => throw new UnreachableException($"unknown status {status}"),
};
requiredmembers for object initializers that must set a property.TimeProviderinstead ofDateTime.UtcNowfor testable time;DateTimeOffsetoverDateTimefor instants.- Structured logging templates (
{OrderId}), never string interpolation in log calls. - C# 14 (with .NET 10) adds the
fieldkeyword in property accessors and extension members; use them only if the whole team is on the .NET 10 SDK.
Async rules
| Do | Do not |
|---|---|
await every task; return Task/ValueTask | .Result, .Wait(), .GetAwaiter().GetResult() in request code |
Accept CancellationToken ct as the last parameter and pass it down | Create tokens you never pass to I/O calls |
await Task.WhenAll(a, b) for independent work | Sequential awaits of independent calls |
ConfigureAwait(false) in libraries | ConfigureAwait(false) sprinkled in ASP.NET Core app code (no sync context; noise) |
IAsyncEnumerable<T> with [EnumeratorCancellation] for streams | Buffering huge result sets into lists |
async Task | async void (exceptions crash the process) |
Background work in ASP.NET Core: BackgroundService or a queue; never fire-and-forget
Task.Run from a request (the scope and its DbContext are disposed).
Minimal APIs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); // .NET 9+
builder.Services.AddProblemDetails();
builder.Services.AddDbContext<AppDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Main")));
builder.Services.AddOptions<PaymentOptions>().BindConfiguration("Payments").ValidateDataAnnotations().ValidateOnStart();
var app = builder.Build();
app.UseExceptionHandler();
app.MapOpenApi();
var orders = app.MapGroup("/orders").WithTags("Orders").RequireAuthorization();
orders.MapGet("/{id:guid}", async Task<Results<Ok<OrderDto>, NotFound>> (Guid id, AppDb db, CancellationToken ct) =>
{
var dto = await db.Orders.AsNoTracking()
.Where(o => o.Id == id)
.Select(o => new OrderDto(o.Id, o.Total, o.Status))
.FirstOrDefaultAsync(ct);
return dto is null ? TypedResults.NotFound() : TypedResults.Ok(dto);
});
orders.MapPost("/", async Task<Results<Created<OrderDto>, ValidationProblem>> (CreateOrder cmd, OrderService svc, CancellationToken ct) =>
{
if (cmd.Items.Count == 0)
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["items"] = ["At least one item"] });
var order = await svc.PlaceAsync(cmd.ToCart(), ct);
return TypedResults.Created($"/orders/{order.Id}", OrderDto.From(order));
});
app.Run();
Results<T1, T2>return types give accurate OpenAPI metadata without attributes.- Group cross-cutting concerns with
MapGroup(auth, tags, filters, rate limiting). - Validation: .NET 10 adds built-in minimal API validation (
builder.Services.AddValidation()); on .NET 8/9 use an endpoint filter with FluentValidation or DataAnnotations. - Move endpoint registration into static extension methods per feature
(
app.MapOrders()) onceProgram.csgrows past one screen.
EF Core essentials
| Goal | Do |
|---|---|
| Read-only query | AsNoTracking() + Select into a DTO |
| Avoid N+1 | Include for small graphs, projection for APIs; AsSplitQuery() when Includes multiply rows |
| Bulk update/delete | ExecuteUpdateAsync / ExecuteDeleteAsync (no entity loading) |
| Concurrency | Row version / concurrency token; handle DbUpdateConcurrencyException |
| See generated SQL | .ToQueryString() or LogTo(Console.WriteLine, LogLevel.Information) in dev |
| Lifetime | DbContext is scoped (per request); never singleton, never shared across threads |
dotnet tool install --global dotnet-ef
dotnet ef migrations add AddOrderStatus --project src/Infra --startup-project src/Api
dotnet ef migrations script --idempotent -o migrate.sql # review before production
Review every generated migration; renames often appear as drop + add (data loss). Applying migrations to production requires explicit user confirmation and a backup.
dotnet CLI quick reference
dotnet new webapi -n Shop.Api # minimal APIs by default since .NET 8; see `dotnet new webapi -h`
dotnet new sln && dotnet sln add src/Shop.Api/Shop.Api.csproj
dotnet add src/Shop.Api package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet build -warnaserror
dotnet test --collect:"XPlat Code Coverage"
dotnet format --verify-no-changes
dotnet watch --project src/Shop.Api
dotnet list package --vulnerable --include-transitive
Gotchas
HttpClientcreated per call exhausts sockets; useIHttpClientFactory(AddHttpClient<T>()) or a long-lived instance withPooledConnectionLifetime.- Captured primary-constructor parameters are mutable and can be captured twice if also assigned to a field; pick one.
recordwith collection properties: equality compares list references, not contents.DateTime.Nowin servers: timezone bugs. UseTimeProvider/ UTCDateTimeOffset.IEnumerablereturned from EF without materializing executes the query again on each enumeration and after the context is disposed.asynclambdas passed toList.ForEachorParallel.ForEachbecomeasync void; useParallel.ForEachAsyncor a loop withawait.
Verification
dotnet build -warnaserror
dotnet test
dotnet format --verify-no-changes
grep -rnE "\.Result\b|\.Wait\(\)|async void" --include=*.cs src | grep -v "EventArgs"
The grep should return nothing. For API changes, open the OpenAPI document
(/openapi/v1.json) and confirm each endpoint lists its real response types.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.