Languages No. 104

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.

  • namecsharp-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

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.

~/.claude/skills/csharp-dotnet/SKILL.md

--- name: csharp-dotnet description: Writes modern C# on .NET 8, 9, and 10 — records and primary constructors, nullable reference types as errors, async/await with… license: MIT ---

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.json so CI and developers agree.
  • Nullable warnings are bugs. Never blanket-suppress with #nullable disable or the ! operator to make them go away.
  • Async all the way down. No sync-over-async; no async void except event handlers.
  • Configuration through strongly typed options (IOptions<T> with validation), never configuration["Key"] scattered in code.
  • Settings shared by all projects go in Directory.Build.props; package versions in Directory.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}"),
};
  • required members for object initializers that must set a property.
  • TimeProvider instead of DateTime.UtcNow for testable time; DateTimeOffset over DateTime for instants.
  • Structured logging templates ({OrderId}), never string interpolation in log calls.
  • C# 14 (with .NET 10) adds the field keyword in property accessors and extension members; use them only if the whole team is on the .NET 10 SDK.

Async rules

DoDo not
await every task; return Task/ValueTask.Result, .Wait(), .GetAwaiter().GetResult() in request code
Accept CancellationToken ct as the last parameter and pass it downCreate tokens you never pass to I/O calls
await Task.WhenAll(a, b) for independent workSequential awaits of independent calls
ConfigureAwait(false) in librariesConfigureAwait(false) sprinkled in ASP.NET Core app code (no sync context; noise)
IAsyncEnumerable<T> with [EnumeratorCancellation] for streamsBuffering huge result sets into lists
async Taskasync 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()) once Program.cs grows past one screen.

EF Core essentials

GoalDo
Read-only queryAsNoTracking() + Select into a DTO
Avoid N+1Include for small graphs, projection for APIs; AsSplitQuery() when Includes multiply rows
Bulk update/deleteExecuteUpdateAsync / ExecuteDeleteAsync (no entity loading)
ConcurrencyRow version / concurrency token; handle DbUpdateConcurrencyException
See generated SQL.ToQueryString() or LogTo(Console.WriteLine, LogLevel.Information) in dev
LifetimeDbContext 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

  • HttpClient created per call exhausts sockets; use IHttpClientFactory (AddHttpClient<T>()) or a long-lived instance with PooledConnectionLifetime.
  • Captured primary-constructor parameters are mutable and can be captured twice if also assigned to a field; pick one.
  • record with collection properties: equality compares list references, not contents.
  • DateTime.Now in servers: timezone bugs. Use TimeProvider / UTC DateTimeOffset.
  • IEnumerable returned from EF without materializing executes the query again on each enumeration and after the context is disposed.
  • async lambdas passed to List.ForEach or Parallel.ForEach become async void; use Parallel.ForEachAsync or a loop with await.

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.

esc