- name
- loom-csharp
- description
- C# language expertise for idiomatic, production-quality code.
- triggers
- ["csharp","c#","dotnet",".net","nuget","msbuild","csproj","sln","xunit","nunit","mstest","asp.net core","minimal api","entity framework","ef core","linq","ConfigureAwait","nullable reference types","record struct","IAsyncEnumerable","CancellationToken","HttpClientFactory","Span"]
# C# Language Expertise
## Overview
Idiomatic, production-quality C# on current .NET (.NET 8 and .NET 10 are the LTS releases; C# 12 ships with .NET 8, C# 14 with .NET 10). Assumes competence with the language. The value is in what compiles cleanly and still fails: nullable annotations that do not hold at runtime, sync-over-async deadlocks, deferred LINQ queries that run twice or on the client, DI lifetime mismatches, and test filters that select nothing.
## Tooling
The `dotnet` CLI is the build tool, package manager, test driver and formatter front end. Keep the repository's SDK pin (`global.json`), solution layout and package management style.
| Task | Command |
| --- | --- |
| New solution and projects | `dotnet new sln -n Spool`, `dotnet new classlib -o src/Spool`, `dotnet new xunit -o tests/Spool.Tests` |
| Wire them together | `dotnet sln add src/Spool/Spool.csproj`, `dotnet add tests/Spool.Tests/Spool.Tests.csproj reference src/Spool/Spool.csproj` |
| Add a package | `dotnet add src/Spool/Spool.csproj package Polly` (never hand-edit `PackageReference`) |
| Build / test | `dotnet build`, `dotnet test` (restores and builds first) |
| Format | `dotnet format` (whitespace, code style and analyzer fixes driven by `.editorconfig`) |
| Audit dependencies | `dotnet list package --vulnerable --include-transitive` |
| Local tools | `dotnet tool restore` (versions pinned in `.config/dotnet-tools.json`) |
| Ignore build output | `dotnet new gitignore` (covers `bin/` and `obj/`) |
Shared settings go in `Directory.Build.props` at the repository root; every project below it inherits them:
```xml
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
```
- Central Package Management: `Directory.Packages.props` with `<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>` holds every `PackageVersion`; project files carry versionless `PackageReference`s, so every project in the solution resolves the same version of a package.
- NuGet audit runs during restore and reports known-vulnerable packages as `NU1901`–`NU1904` warnings; with `TreatWarningsAsErrors` they fail the build. CI restores with `dotnet restore --locked-mode` so a drifted `packages.lock.json` fails.
- `dotnet format` applies `.editorconfig` rules; some repositories use CSharpier for layout instead. Follow what the repository has.
- `EnforceCodeStyleInBuild` makes IDE style rules (`IDExxxx`) build diagnostics, so the build and the IDE agree.
The gate (warnings are errors through `Directory.Build.props`):
```bash
dotnet format --verify-no-changes && dotnet build && dotnet test --no-build
```
## Nullable Reference Types
Nullable annotations are a compile-time analysis. Nothing checks them at runtime: a `string` parameter can still receive `null` from reflection, deserialization, an older assembly, or a caller that wrote `null!`.
- Enable per project (`<Nullable>enable</Nullable>`) and make the warnings errors (`TreatWarningsAsErrors`, or `<WarningsAsErrors>nullable</WarningsAsErrors>` alone). Nullable warnings left as warnings pile up into the hundreds and stop being read.
- Check at public boundaries: `ArgumentNullException.ThrowIfNull(arg)`, `ArgumentException.ThrowIfNullOrWhiteSpace(name)` (.NET 8), `ArgumentOutOfRangeException.ThrowIfNegative(count)` (.NET 8). They capture the argument name through `CallerArgumentExpression`.
- `!` (null-forgiving) silences the compiler and changes nothing at runtime. Each one is a claim the reviewer must be able to check from the surrounding lines; `= default!` on a property hands the lie to the next reader.
- Teach the analysis with `System.Diagnostics.CodeAnalysis` attributes so callers need no `!`:
```csharp
public bool TryGetSpool(string id, [NotNullWhen(true)] out Spool? spool)
{
spool = _spools.GetValueOrDefault(id);
return spool is not null;
}
if (TryGetSpool(id, out var spool))
{
spool.Append(record); // no warning: NotNullWhen(true) proved it non-null
}
```
- Other attributes: `[MemberNotNull(nameof(_conn))]` on an initialization helper, `[return: NotNullIfNotNull(nameof(input))]`, `[DoesNotReturn]` on throw helpers.
- Holes the analysis does not see: `new string[4]` holds four nulls typed `string`; `default(SomeStruct)` leaves its reference fields null; a property initialized by neither constructor nor `required`.
- System.Text.Json writes `null` into non-nullable properties unless told otherwise. Mark mandatory members `required` (honoured since .NET 7: a missing property throws) and set `JsonSerializerOptions.RespectNullableAnnotations = true` (.NET 9) to reject explicit `null`s.
- Unconstrained generic `T?` means "may be `default`": for `T = int` it is `int`, with no `Nullable<int>` involved. Constrain with `where T : class` or `where T : struct` when the distinction matters.
## Records, Structs and Classes
| Need | Use |
| --- | --- |
| Immutable data with value equality | `record` (reference) or `readonly record struct` (small value) |
| Identity, behaviour, mutable state | `sealed class` (unsealed only when designed for inheritance) |
| Small (about 16 bytes or less), short-lived value, no boxing | `readonly struct` |
| Members that must be set at construction | `required` (C# 11) + `init` (C# 9) |
```csharp
public sealed record SpoolEntry(string Id, DateTimeOffset At, IReadOnlyList<string> Tags);
var moved = entry with { At = clock.GetUtcNow() }; // shallow copy: Tags is the same list object
public sealed class SpoolOptions
{
public required string Root { get; init; }
public int MaxBytes { get; init; } = 1 << 20;
}
```
- ⚠ **Record equality compares each member with `EqualityComparer<T>.Default`.** A `List<T>`, array or `ImmutableArray<T>` member compares by reference, so two records with equal contents are unequal: dictionary lookups miss and test assertions fail. Override `Equals(SpoolEntry? other)` and `GetHashCode` with `SequenceEqual`, or keep collections out of records used as keys or compared in tests.
- `record struct` positional properties are **mutable** (`get; set;`); `readonly record struct` makes them `init`.
- `with` copies shallowly; nested mutable objects are shared by both copies.
- Primary constructors on classes (C# 12) capture parameters as hidden mutable fields; they are not properties. Initializing a property from a parameter and also reading the parameter in a method stores two copies that can diverge (warning CS9124). Assign to one `readonly` member and use only that.
- `sealed` lets the JIT devirtualize calls; CA1852 flags internal types that could be sealed.
- A non-exhaustive switch expression warns CS8509 and throws `SwitchExpressionException` at runtime. Enums are open sets (any `int` casts to an enum), so a switch naming every member still warns CS8524 until it has a `_` arm; make that arm throw.
```csharp
decimal Fee(Order o) => o switch
{
{ Items: [] } => throw new InvalidOperationException("empty order"),
{ Total: > 1000m } => 0m,
{ Customer.IsPremium: true } => 2.5m,
_ => 5m,
};
```
- Collection expressions (C# 12): `int[] ids = [1, 2, 3];`, `List<string> all = [..left, ..right];`.
- C# 14 (.NET 10): the `field` keyword reaches the compiler-generated backing field from an accessor (`set => field = value.Trim();`); `extension` blocks declare extension properties and static members; `a?.B = x` assigns only when `a` is non-null.
## Exceptions and Disposal
- Throw for failures the caller cannot anticipate; use the `TryX(out T)` pattern or a result type for expected outcomes (parse failures, cache misses). Exceptions cost microseconds each and must not drive a hot loop.
- Rethrow with `throw;`. `throw ex;` resets the stack trace to the rethrow line. To rethrow a captured exception elsewhere, `ExceptionDispatchInfo.Capture(ex).Throw()`.
- Exception filters run before the stack unwinds, so crash dumps keep the throwing frame:
```csharp
try
{
return await client.GetFromJsonAsync<Blob>(uri, ct);
}
catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
```
- `catch (Exception)` belongs at process and request boundaries, where it logs and translates. Anywhere else it also catches `OperationCanceledException` and turns a cancellation into a phantom error or a silent success.
- Own a resource with `using var` (disposed at scope end) or `await using` for `IAsyncDisposable`. A class that owns an `IDisposable` field implements `IDisposable` itself. Finalizers only for types wrapping raw OS handles, and `SafeHandle` usually removes the need.
## Async / Await
- Async all the way down. `.Result`, `.Wait()` and `.GetAwaiter().GetResult()` on a thread with a `SynchronizationContext` (WPF, WinForms, MAUI, classic ASP.NET) deadlock: the continuation needs the context the blocked thread holds. ASP.NET Core has no context, so the same code runs there and starves the thread pool under load instead.
- `ConfigureAwait(false)` tells an await not to resume on the captured context. Use it on every await in **library** code that UI or legacy hosts may call (enable CA2007 in library projects). Application code in ASP.NET Core, console apps and worker services gains nothing from it. It applies only to the await it is attached to and does nothing when that task already completed, so a library applies it to every await.
- .NET 8 adds `ConfigureAwaitOptions` for `Task`: `await loop.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing)` waits for a cancelled background loop to finish without rethrowing.
- `async void` only for event handlers: its exceptions go to the synchronization context or crash the process, and nothing can await it.
- `ValueTask<T>` is for hot paths that usually complete synchronously. Await it exactly once; never await it twice, read `.Result` before completion, or await it concurrently. Call `.AsTask()` when you need any of those.
- `CancellationToken` is the last parameter of every async API and is passed to every call inside. For a timeout, `using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(timeout);`. Undisposed linked sources leak registrations on the parent token.
- `await Task.WhenAll(tasks)` rethrows only the first exception; all of them are on the returned task's `Exception.InnerExceptions`.
- `await` inside `lock` does not compile (CS1996). Guard async critical sections with `SemaphoreSlim(1, 1)` and release in `finally`. For synchronous locks, .NET 9 / C# 13 adds `System.Threading.Lock`; `lock` on a `Lock` field uses its faster scope API.
- Bounded concurrency: `Parallel.ForEachAsync` with `MaxDegreeOfParallelism` (.NET 6); `Channel.CreateBounded<T>(capacity)` for producer/consumer with backpressure.
- `IAsyncEnumerable<T>` producers take `[EnumeratorCancellation] CancellationToken ct` so `await foreach (var x in source.WithCancellation(ct))` flows the token.
- Fire-and-forget (`_ = DoWorkAsync();`) loses exceptions and outlives the request scope. Queue the work to a `BackgroundService` through a `Channel<T>`.
```csharp
public async Task<Spool> OpenAsync(string path, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
return Spool.Parse(bytes);
}
finally
{
_gate.Release();
}
}
```
## LINQ Pitfalls
- **Deferred execution.** A query is a recipe; each enumeration runs it again (and queries the database again). Materialize once with `ToList()`/`ToArray()` when a sequence is enumerated more than once (CA1851 flags it). Side effects inside `Select` run lazily, repeatedly, or never.
- **`IQueryable<T>` and `IEnumerable<T>`.** EF Core translates the expression tree to SQL up to the first `AsEnumerable()`/`ToList()`; every operator after that runs in memory over the rows already fetched. Filter, project and page before materializing:
```csharp
// BAD: pulls every order into memory, then filters and pages
var recent = db.Orders.ToList().Where(o => o.PlacedAt > since).Take(50);
// GOOD: one SQL query with WHERE, ORDER BY and a row limit
var recent = await db.Orders
.AsNoTracking()
.Where(o => o.PlacedAt > since)
.OrderByDescending(o => o.PlacedAt)
.Select(o => new OrderRow(o.Id, o.Total))
.Take(50)
.ToListAsync(ct);
```
- N+1: lazy-loading proxies, or a navigation property read inside a loop, issue one query per row. Load with `Include` or project with `Select`; add `AsSplitQuery()` when several collection `Include`s multiply the row count.
- `Any()` stops at the first element; `Count() > 0` counts them all. `Single` throws on more than one match, `First` on none. `FirstOrDefault` on a value type returns `0`/`default`, indistinguishable from a real zero: pass the fallback explicitly (`FirstOrDefault(pred, -1)`, .NET 6) or use a `TryGet`-style API.
- `OrderBy(a).OrderBy(b)` discards the first sort; chain `ThenBy`.
- A `for` loop variable is shared by every lambda created in the loop; a `foreach` variable is per iteration (since C# 5).
- Built-in operators replace hand-written grouping: `Chunk`, `DistinctBy`, `MaxBy`/`MinBy` (.NET 6); `CountBy`, `AggregateBy`, `Index` (.NET 9).
- LINQ allocates enumerators and delegates. In a measured hot path, loop over an array or span.
## ASP.NET Core Minimal APIs
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddDbContext<SpoolDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("spool")));
builder.Services.AddHttpClient<BlobClient>(c => c.BaseAddress = new Uri("https://blobs.internal/"));
builder.Services.AddOptions<SpoolOptions>().BindConfiguration("Spool").ValidateDataAnnotations().ValidateOnStart();
var app = builder.Build();
app.UseExceptionHandler();
var spools = app.MapGroup("/spools").RequireAuthorization();
spools.MapGet("/{id:guid}", GetSpool);
app.Run();
static async Task<Results<Ok<SpoolDto>, NotFound>> GetSpool(Guid id, SpoolDb db, CancellationToken ct)
{
var spool = await db.Spools.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
return spool is null ? TypedResults.NotFound() : TypedResults.Ok(SpoolDto.From(spool));
}
```
- `TypedResults` with a `Results<...>` return type gives the handler a checkable signature and generates OpenAPI metadata; a handler written as a static method is unit-testable without a server.
- Binding: route and query parameters by name, complex types from the JSON body, registered services from DI, `CancellationToken` from `HttpContext.RequestAborted`; `[AsParameters]` groups them into one record.
- Validation: .NET 10 adds `builder.Services.AddValidation()`, which runs DataAnnotations attributes on minimal API parameters; earlier versions need an endpoint filter or FluentValidation. An unvalidated body model is untrusted input.
- DI lifetimes: a singleton that takes a scoped service (a `DbContext`) keeps one instance forever, a captive dependency with stale data and cross-request state. Development builds validate scopes by default; turn on `ValidateOnBuild` and `ValidateScopes` in tests too. A `BackgroundService` creates scopes through `IServiceScopeFactory`.
- `HttpClient`: `new HttpClient()` per call exhausts sockets; one static instance never sees DNS changes unless `SocketsHttpHandler.PooledConnectionLifetime` is set. Use `IHttpClientFactory` (typed clients via `AddHttpClient<T>`).
- `ValidateOnStart()` on options makes bad configuration fail at startup instead of on the first request that reads it.
- `AddProblemDetails()` plus `UseExceptionHandler()` turn unhandled exceptions into problem-details JSON without leaking stack traces.
## Testing
xUnit and NUnit differ in the place that causes most flaky suites: instance lifetime.
| Concern | xUnit | NUnit |
| --- | --- | --- |
| Test | `[Fact]`; data rows `[Theory]` + `[InlineData]`/`[MemberData]` | `[Test]`; data rows `[TestCase(...)]`/`[TestCaseSource]` |
| Setup / teardown | constructor / `Dispose` (`IAsyncLifetime` for async) | `[SetUp]` / `[TearDown]`, `[OneTimeSetUp]` |
| Instance per test | yes: a new class instance for every test | no: one fixture instance shared by its tests (`[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]` changes it) |
View on GitHub