Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · C# 14 · .NET 10 · nullable enabled

C#

Nullable refs, records, LINQ and async — idiomatic C#.

csharpdotnetlinq

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 target net8.0/net9.0 for new code.
  • Language: C# 14. Let <LangVersion> default from the TFM — never pin it lower. SDK-style .csproj only.
  • 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 interpolated LogInformation($"...").
  • HTTP: IHttpClientFactory (AddHttpClient), never new HttpClient() per call. Resilience via Microsoft.Extensions.Http.Resilience (AddStandardResilienceHandler) / Polly v8 ResiliencePipeline.
  • JSON: System.Text.Json with a cached JsonSerializerOptions and a source-generated JsonSerializerContext (AOT/perf). Never Newtonsoft in new code.
  • Data: EF Core 10 or Dapper. Time via injected TimeProvider, never DateTime.Now/Stopwatch in 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, .editorconfig at root. Prefer .slnx over legacy .sln.
  • Naming: PascalCase types/methods/properties/consts; camelCase locals/params; _camelCase private fields; I-prefixed interfaces; async methods end in Async.
  • Usings: global using in one GlobalUsings.cs per project for pervasive namespaces; rely on ImplicitUsings for the BCL set; delete redundant usings. System.* first.
  • Formatting/lint: .editorconfig is the source of truth (severity via dotnet_diagnostic.*), enforced by dotnet format --verify-no-changes in CI. Prefer var when the type is apparent on the RHS; explicit types when it aids clarity.
  • Visibility: internal by default; public only for the intended API surface. sealed by default — open a class for inheritance only deliberately. readonly on 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 under TreatWarningsAsErrors. These span the CS860xCS87xx family (CS8600 assignment, CS8602 deref, CS8618 uninitialized member, CS8619 variance, CS8714 unconstrained notnull), 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 make required.
  • C# 14 null-conditional assignment: customer?.Order = ComputeOrder(); evaluates the RHS only when customer is non-null — use it instead of an if (customer is not null) wrapper.

Immutable data & records

  • Use record for DTOs, events, value objects, and config — value equality, with expressions, and deconstruction for free. Prefer readonly record struct for small value-like data on hot paths to avoid allocations.
  • Properties are init-only or required. 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 required to a positional parameter's property — the synthesized constructor is not [SetsRequiredMembers], so new Money(1m, "USD") fails with CS9035. Pick one style per type: positional, or required init props with an object initializer.
  • Record equality and with are shallow: records are equal when fields are equal, and with copies references as-is. Don't put a mutable List<T> in a record and expect equality or with to isolate it — use IReadOnlyList<T> with a structural comparer, or ImmutableArray<T>.
  • Prefer strongly-typed IDs (readonly record struct CustomerId(Guid Value)) over raw Guid/string for 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 into private readonly fields when you must guard/transform them.
  • C# 14 field keyword: 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 _name fields.
  • Public collections are IReadOnlyList<T>/IReadOnlyDictionary<K,V>, never List<T> fields. Use FrozenDictionary/FrozenSet for build-once, read-many lookups.

Pattern matching

  • Replace if/else chains and type-testing with exhaustive switch expressions 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 with is T x declaration patterns.
  • Every switch expression 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() then foreach the 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 IQueryable until the final await ...Async(); AsNoTracking() for read-only reads; .Select(...) projections instead of loading whole entities; Include/ThenInclude deliberately. Keep predicates translatable — no local method calls inside Where/Select that force client evaluation. Async terminal ops only: ToListAsync, FirstOrDefaultAsync, AnyAsync, SingleAsync.
  • Never .Result on an IQueryable; 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 let buffer[..8] flow into span APIs without .AsSpan()) and ArrayPool<T>.Shared for 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 CancellationToken through every async method and pass it to every downstream call (HttpClient, EF Core, Task.Delay, Channel). Public library methods take CancellationToken cancellationToken = default last. Honor it (token.ThrowIfCancellationRequested() in loops).
  • Return Task/Task<T> normally; use ValueTask/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 void only for event handlers; it swallows exceptions and can crash the process. Everything else returns async Task.
  • Stream with await foreach over IAsyncEnumerable<T>; on producers pass [EnumeratorCancellation] on the token param.
  • Run independent work concurrently with await Task.WhenAll(tasks); do not await in a loop when calls are independent.

Modern syntax & resource management

  • Expression-bodied members for one-liners: public string Name => _name;.
  • using declarations over try/finally for disposal: using var stream = File.OpenRead(path);. Implement IAsyncDisposable for types owning async resources; consumers use await using.
  • Target-typed new when 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 bare Exception/ApplicationException.
  • Catch only what you can handle. No empty catch {} and no swallowing catch (Exception) { }. Use exception filters to catch selectively: catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests). If you catch broadly, log with ILogger and rethrow.
  • Rethrow with throw; to preserve the stack — never throw ex;. Translate to a domain exception only to add context, preserving innerException. 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.../out pattern; 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 tests Method_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 (use FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing in tests); inject a Func<Guid>/IIdGenerator for deterministic ids — TimeProvider/RandomNumberGenerator do not produce GUIDs.
  • Async: async Task test methods with await; never .Result in a test. No Thread.Sleep to 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 test in CI; keep tests deterministic (classes run in parallel — no shared mutable state, no wall-clock, no ordering assumptions). Enforce coverage via Microsoft.Testing.Extensions.CodeCoverage.

Security

  • Parameterize all SQL — EF Core / Dapper parameters or FromSql/FromSqlInterpolated (which parameterize). Never string-concatenate user input into SQL, and never FromSqlRaw($"...{input}").
  • Store secrets in User Secrets (dev), environment, or a vault — never in appsettings.json or source. Bind config with IOptions<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>, tune IterationCount) 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 with RandomNumberGenerator.GetBytes/GetInt32, never System.Random or Guid. 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. BinaryFormatter is removed/forbidden — never reintroduce it.
  • Validate and bound all external input (DataAnnotations/FluentValidation); set MaxRequestBodySize and timeouts. Use [GeneratedRegex] with a matchTimeout to prevent ReDoS on user input. Canonicalize file paths with Path.GetFullPath and 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 with init/required; expose IReadOnlyList<T>/IReadOnlyDictionary<K,V>.
  • Use switch expressions and pattern matching over branching on type/flags.
  • Thread CancellationToken (and ConfigureAwait(false) in libraries) through async paths.
  • Inject IHttpClientFactory, TimeProvider, ILogger<T>, and options via constructor / primary constructor.
  • Seal classes, mark fields readonly, keep members internal unless part of the public API.
  • Dispose with using/await using declarations; implement IAsyncDisposable for async resources.

Avoid

  • new HttpClient() per request (socket exhaustion) → IHttpClientFactory.
  • .Result / .Wait() / .GetAwaiter().GetResult() (deadlock) → await.
  • async void outside event handlers (uncatchable) → async Task.
  • ! to silence nullable warnings, or #nullable disable → fix the flow / annotate with [NotNullWhen].
  • catch (Exception) {} and throw ex; → catch specifically, filter with when, throw;, or translate.
  • DateTime.Now in logic → inject TimeProvider (GetUtcNow()); new Random()/Guid for 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/required props, 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, and dotnet 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.props and 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, changing TargetFramework/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.

Back to top ↑