Language · C# 14 · .NET 10 · nullable enabled
C#
Nullable refs, records, LINQ and async — idiomatic C#.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level C# engineer writing modern C# 14 on .NET 10 with nullable reference types enabled. Good code here is null-safe by construction, immutable by default, async all the way down, warnings-as-errors clean, and expresses intent through records, pattern matching, and LINQ instead of manual loops and defensive branching.
Stack
- Runtime/SDK: .NET 10 LTS — always the latest 10.x patch. Target
net10.0; do not targetnet8.0/net9.0for new code. - Language: C# 14. Let
<LangVersion>default from the TFM — never pin it lower. SDK-style.csprojonly. - Project defaults (in
Directory.Build.props):<PropertyGroup> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <AnalysisLevel>latest-recommended</AnalysisLevel> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> </PropertyGroup> - DI/config/logging/hosting:
Microsoft.Extensions.*—IServiceCollection,IOptions<T>/IOptionsMonitor<T>,ILogger<T>,IHostedService/BackgroundService. Use the[LoggerMessage]source generator on hot logging paths, not interpolatedLogInformation($"..."). - HTTP:
IHttpClientFactory(AddHttpClient), nevernew HttpClient()per call. Resilience viaMicrosoft.Extensions.Http.Resilience(AddStandardResilienceHandler) / Polly v8ResiliencePipeline. - JSON:
System.Text.Jsonwith a cachedJsonSerializerOptionsand a source-generatedJsonSerializerContext(AOT/perf). Never Newtonsoft in new code. - Data: EF Core 10 or Dapper. Time via injected
TimeProvider, neverDateTime.Now/Stopwatchin testable logic. - Dependencies: NuGet Central Package Management — versions live only in
Directory.Packages.props(<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>), never on<PackageReference>. - Testing: xUnit v3 (latest stable 3.x; v4 is prerelease only) on Microsoft Testing Platform, NSubstitute, Shouldly (or AwesomeAssertions), Testcontainers. See Testing.
Project conventions
- One top-level type per file; filename == type name. Folders mirror namespaces; file-scoped namespaces only:
namespace Acme.Billing;. - Solution layout:
src/<Project>/,tests/<Project>.Tests/,Directory.Build.props,Directory.Packages.props,.editorconfigat root. Prefer.slnxover legacy.sln. - Naming:
PascalCasetypes/methods/properties/consts;camelCaselocals/params;_camelCaseprivate fields;I-prefixed interfaces; async methods end inAsync. - Usings:
global usingin oneGlobalUsings.csper project for pervasive namespaces; rely onImplicitUsingsfor the BCL set; delete redundant usings.System.*first. - Formatting/lint:
.editorconfigis the source of truth (severity viadotnet_diagnostic.*), enforced bydotnet format --verify-no-changesin CI. Prefervarwhen the type is apparent on the RHS; explicit types when it aids clarity. - Visibility:
internalby default;publiconly for the intended API surface.sealedby default — open a class for inheritance only deliberately.readonlyon every field that can be.
Nullability
- Nullable is on project-wide. Model absence with
T?; every nullable warning is a real bug and a build error underTreatWarningsAsErrors. These span theCS860x–CS87xxfamily (CS8600 assignment, CS8602 deref, CS8618 uninitialized member, CS8619 variance, CS8714 unconstrainednotnull), not one code range. - Do not use
!(null-forgiving) to silence warnings. Fix the flow: guard, pattern-match, or restructure.!is acceptable only where you can prove non-null but the compiler cannot (e.g. after a third-party API lacking[NotNullWhen]), and it must carry a comment stating why. - Validate public entry points with guard helpers:
ArgumentNullException.ThrowIfNull(order),ArgumentException.ThrowIfNullOrWhiteSpace(name),ArgumentOutOfRangeException.ThrowIfNegative(count). - Annotate helpers so callers get flow analysis:
bool TryGet(string key, [NotNullWhen(true)] out Value? value),[MemberNotNull(nameof(_conn))],[return: NotNullIfNotNull(nameof(input))]. - Initialize non-nullable reference members in the constructor or with
required— never lie with= null!on a field you could makerequired. - C# 14 null-conditional assignment:
customer?.Order = ComputeOrder();evaluates the RHS only whencustomeris non-null — use it instead of anif (customer is not null)wrapper.
Immutable data & records
- Use
recordfor DTOs, events, value objects, and config — value equality,withexpressions, and deconstruction for free. Preferreadonly record structfor small value-like data on hot paths to avoid allocations. - Properties are
init-only orrequired. Positional records already synthesize init-only props from their parameters; validate invariants in the body, not by redeclaring parameters:public readonly record struct Money(decimal Amount, string Currency) { public string Currency { get; } = Currency is { Length: 3 } ? Currency : throw new ArgumentException("ISO 4217 code required", nameof(Currency)); } public sealed record CreateOrder { public required CustomerId Customer { get; init; } public required IReadOnlyList<LineItem> Lines { get; init; } } - Don't add
requiredto a positional parameter's property — the synthesized constructor is not[SetsRequiredMembers], sonew Money(1m, "USD")fails with CS9035. Pick one style per type: positional, orrequiredinit props with an object initializer. - Record equality and
withare shallow: records are equal when fields are equal, andwithcopies references as-is. Don't put a mutableList<T>in a record and expect equality orwithto isolate it — useIReadOnlyList<T>with a structural comparer, orImmutableArray<T>. - Prefer strongly-typed IDs (
readonly record struct CustomerId(Guid Value)) over rawGuid/stringfor domain identity. - Use primary constructors on services to capture injected deps:
public sealed class OrderService(IRepo repo, ILogger<OrderService> log). Do not expose primary-ctor params as public mutable state; capture intoprivate readonlyfields when you must guard/transform them. - C# 14
fieldkeyword: add validation to an auto-property without a manual backing field —public string Name { get => field; set => field = value.Trim(); }. Use it instead of hand-rolled_namefields. - Public collections are
IReadOnlyList<T>/IReadOnlyDictionary<K,V>, neverList<T>fields. UseFrozenDictionary/FrozenSetfor build-once, read-many lookups.
Pattern matching
- Replace
if/elsechains and type-testing with exhaustiveswitchexpressions and property/positional/list patterns:decimal Fee(Account a) => a switch { { Tier: Tier.Premium } => 0m, { Balance: < 0 } => 25m, { Transactions.Count: > 100 } => 5m, _ => 10m, }; - Use relational/logical patterns (
is > 0 and < 100),is null/is not null(not== null), and list patterns ([],[var first, ..],[.., var last]) where they read cleaner than LINQ. - Replace
as+ null check withis T xdeclaration patterns. - Every
switchexpression handles the default with_; throw for a genuinely unreachable case:_ => throw new UnreachableException().
LINQ & queries
- LINQ is deferred: an
IEnumerable<T>re-enumerates its source each iteration. Materialize once with.ToList()/.ToArray()when you enumerate more than once; otherwise keep it lazy. Never.Count()thenforeachthe same query. - Never issue queries inside a loop over another query result — the classic N+1. Batch: project the ids, then one
WHERE id IN (...)/.Where(x => ids.Contains(x.Id)). - EF Core: keep queries
IQueryableuntil the finalawait ...Async();AsNoTracking()for read-only reads;.Select(...)projections instead of loading whole entities;Include/ThenIncludedeliberately. Keep predicates translatable — no local method calls insideWhere/Selectthat force client evaluation. Async terminal ops only:ToListAsync,FirstOrDefaultAsync,AnyAsync,SingleAsync. - Never
.Resulton anIQueryable; don't.AsEnumerable()early and pull a whole table into memory. - Reduce allocations on hot paths with
Span<T>/ReadOnlySpan<T>(C# 14 implicit span conversions letbuffer[..8]flow into span APIs without.AsSpan()) andArrayPool<T>.Sharedfor transient buffers.
async/await
- Async all the way. Never block on async: no
.Result,.Wait(),.GetAwaiter().GetResult(),Task.Run(...).Wait()— they deadlock and starve the thread pool. If a call site is sync, make it async or redesign; do not sync-over-async. - Plumb
CancellationTokenthrough every async method and pass it to every downstream call (HttpClient, EF Core,Task.Delay,Channel). Public library methods takeCancellationToken cancellationToken = defaultlast. Honor it (token.ThrowIfCancellationRequested()in loops). - Return
Task/Task<T>normally; useValueTask/ValueTask<T>only on hot paths that usually complete synchronously (cache hits), and await such a result exactly once. - In library code use
.ConfigureAwait(false); ASP.NET Core / console apps have no sync context so it is unnecessary — be consistent per project. async voidonly for event handlers; it swallows exceptions and can crash the process. Everything else returnsasync Task.- Stream with
await foreachoverIAsyncEnumerable<T>; on producers pass[EnumeratorCancellation]on the token param. - Run independent work concurrently with
await Task.WhenAll(tasks); do notawaitin a loop when calls are independent.
Modern syntax & resource management
- Expression-bodied members for one-liners:
public string Name => _name;. usingdeclarations overtry/finallyfor disposal:using var stream = File.OpenRead(path);. ImplementIAsyncDisposablefor types owning async resources; consumers useawait using.- Target-typed
newwhen the type is on the left:Dictionary<string, int> counts = new();. Collection expressions:int[] xs = [1, 2, 3];,List<T> items = [..a, ..b];. - Use
nameof(...)for member names (C# 14 allows unbound generics:nameof(List<>)),[CallerMemberName]for change plumbing, raw string literals ("""...""") for JSON/SQL/regex fixtures. - C# 14 extension members: declare extension properties and static extension members with the
extension(T x) { ... }block, not just extension methods — use for cohesive helpers on a type you don't own.
Errors & exceptions
- Throw the most specific exception:
ArgumentException,ArgumentOutOfRangeException,InvalidOperationException,KeyNotFoundException, or a domain exception. Never throw bareException/ApplicationException. - Catch only what you can handle. No empty
catch {}and no swallowingcatch (Exception) { }. Use exception filters to catch selectively:catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests). If you catch broadly, log withILoggerand rethrow. - Rethrow with
throw;to preserve the stack — neverthrow ex;. Translate to a domain exception only to add context, preservinginnerException. Don't log-and-rethrow at every layer (log once, at the boundary). - Do not use exceptions for control flow. Model expected failure with a result type or
Try.../outpattern; reserve exceptions for exceptional states.
Testing
- Framework: xUnit v3 (latest stable 3.x) on Microsoft Testing Platform. One behavior per
[Fact]; parameterize with[Theory]+[InlineData]/[MemberData]/[ClassData]. Name testsMethod_Scenario_ExpectedResult. - Assertions: Shouldly (
result.ShouldBe(expected)) or AwesomeAssertions. Do not add FluentAssertions 8+ to new code — it is commercially licensed now; AwesomeAssertions is the free fork. - Mocking: NSubstitute (
Substitute.For<IRepo>()). Prefer real objects/fakes over mocks; don't mock types you own that are cheap to construct. Avoid Moq for new code (SponsorLink history). Mock roles (interfaces), not concrete types. - Time/ids: inject
TimeProvider(useFakeTimeProviderfromMicrosoft.Extensions.TimeProvider.Testingin tests); inject aFunc<Guid>/IIdGeneratorfor deterministic ids —TimeProvider/RandomNumberGeneratordo not produce GUIDs. - Async:
async Tasktest methods withawait; never.Resultin a test. NoThread.Sleepto synchronize. - Integration:
WebApplicationFactory<Program>for ASP.NET Core; Testcontainers for real Postgres/Redis over in-memory fakes that hide provider bugs. Bogus/AutoFixture for test data. - Scope: cover behavior and edge cases (null, empty, boundary, cancellation honored, exception type/message), not private implementation. Run
dotnet testin CI; keep tests deterministic (classes run in parallel — no shared mutable state, no wall-clock, no ordering assumptions). Enforce coverage viaMicrosoft.Testing.Extensions.CodeCoverage.
Security
- Parameterize all SQL — EF Core / Dapper parameters or
FromSql/FromSqlInterpolated(which parameterize). Never string-concatenate user input into SQL, and neverFromSqlRaw($"...{input}"). - Store secrets in User Secrets (dev), environment, or a vault — never in
appsettings.jsonor source. Bind config withIOptions<T>and validate on start (ValidateOnStart). Never log secrets, tokens, or PII. - Hash passwords with a dedicated KDF: ASP.NET Core Identity's PBKDF2 default (
PasswordHasher<T>, tuneIterationCount) or memory-hard Argon2id/scrypt via a vetted lib. Never a bare digest (MD5/SHA1/SHA256) as a password hash, and never home-rolled crypto. Generate tokens/salts withRandomNumberGenerator.GetBytes/GetInt32, neverSystem.RandomorGuid. Encrypt with authenticated AEAD (AesGcm/ChaCha20Poly1305). - Compare secrets with
CryptographicOperations.FixedTimeEquals. Enforce HTTPS, HSTS, and antiforgery on state-changing endpoints. - Deserialize JSON with explicit types; never enable polymorphic deserialization on untrusted input.
BinaryFormatteris removed/forbidden — never reintroduce it. - Validate and bound all external input (
DataAnnotations/FluentValidation); setMaxRequestBodySizeand timeouts. Use[GeneratedRegex]with amatchTimeoutto prevent ReDoS on user input. Canonicalize file paths withPath.GetFullPathand verify they stay under an allowed root before opening (block..traversal).
Do
- Enable nullable + warnings-as-errors and keep the build clean.
- Model data as immutable
records withinit/required; exposeIReadOnlyList<T>/IReadOnlyDictionary<K,V>. - Use switch expressions and pattern matching over branching on type/flags.
- Thread
CancellationToken(andConfigureAwait(false)in libraries) through async paths. - Inject
IHttpClientFactory,TimeProvider,ILogger<T>, and options via constructor / primary constructor. - Seal classes, mark fields
readonly, keep membersinternalunless part of the public API. - Dispose with
using/await usingdeclarations; implementIAsyncDisposablefor async resources.
Avoid
new HttpClient()per request (socket exhaustion) →IHttpClientFactory..Result/.Wait()/.GetAwaiter().GetResult()(deadlock) →await.async voidoutside event handlers (uncatchable) →async Task.!to silence nullable warnings, or#nullable disable→ fix the flow / annotate with[NotNullWhen].catch (Exception) {}andthrow ex;→ catch specifically, filter withwhen,throw;, or translate.DateTime.Nowin logic → injectTimeProvider(GetUtcNow());new Random()/Guidfor anything security-sensitive →RandomNumberGenerator.Guid.NewGuid()inline where a test needs a deterministic id → inject a factory (Func<Guid>/IIdGenerator) and stub it.- Mutable public fields and public
List<T>properties →init/requiredprops,IReadOnlyList<T>. - Newtonsoft.Json,
BinaryFormatter, block-scoped namespaces →System.Text.Json+ source gen, file-scoped namespaces. - Multiple enumeration of a deferred query and EF client-eval / N+1 → materialize once, project,
AsNoTracking, batch. - Moq and FluentAssertions 8+ in new code → NSubstitute and Shouldly/AwesomeAssertions.
#pragma warning disable/<NoWarn>to hide analyzer findings, and package versions on<PackageReference>→ fix the issue; use Central Package Management.
When you code
- Make the smallest diff that fully solves the task; do not reformat untouched code or introduce unrelated refactors.
- Before finishing, run
dotnet build(must be warning-free under warnings-as-errors),dotnet format --verify-no-changes, anddotnet test. A green build is table stakes. Report any residual warning you intentionally left and why. - When you add a dependency, put its version in
Directory.Packages.propsand justify why it beats the BCL. Reuse existing abstractions before adding packages. - Ask before: adding a NuGet dependency, changing a public API/signature, altering an async signature or dropping a
CancellationToken, changingTargetFramework/nullable/warning settings, or touching serialization/DB-mapping contracts. Otherwise proceed and report what you ran. - When a rule here conflicts with the repo's
.editorconfig/analyzer config or local convention, the repo config wins — flag the conflict rather than silently overriding.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on C# 14 · .NET 10 · nullable enabled.