C# (.NET)

.NET services.

What this is

.NET services. Async correctness is the dominant theme in the BLOCKER tier — async void outside event handlers, sync-over-async blocking that deadlocks the thread pool, missing cancellation tokens, HttpClient instantiated per request instead of via IHttpClientFactory. The HIGH tier is mostly EF Core and LINQ pitfalls: N+1 navigation property access, multiple enumeration of the same query, entities leaking out of controllers instead of DTOs.

How to onboard it

A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:

terminal
$ npx redlinegate init  # detects the profile
$ npx redlinegate init --profile service-dotnet  # or name one

One profile pulls these rules in: service-dotnet.

Once onboarded, your files match this stack when they fit any of these globs:

  • **/*.cs

How to use it — 28 rules

Nothing to run. Once your profile includes C# (.NET), redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.

Rule idSeverityCatches
csharp/async-voidBLOCKER`async void` anywhere except event handlers — exceptions escape the caller and crash the process; return `Task`.
csharp/sync-over-asyncBLOCKERSync-over-async: `.Result`, `.Wait()`, `.GetAwaiter().GetResult()` on async calls — deadlock and thread-pool starvation; async all the way.
csharp/httpclient-per-requestBLOCKER`HttpClient` instantiated per request — socket exhaustion; use `IHttpClientFactory`.
csharp/captive-dependenciesBLOCKERCaptive dependencies: scoped services (DbContext) injected into singletons — cross-request state bleed; inject `IServiceScopeFactory` or restructure.
csharp/sql-string-concatenationBLOCKERString-concatenated SQL with external input — parameterized queries / EF LINQ only.
csharp/swallowed-exceptionsBLOCKERSwallowed exceptions: `catch { }` or catch-log-continue treating failure as success.
csharp/missing-cancellation-tokenBLOCKERMissing `CancellationToken` propagation on new async endpoints and outbound calls.
csharp/task-returned-from-usingBLOCKERA task returned unawaited from inside a `using` scope. `using var db = …; return db.Orders.ToListAsync();` disposes the context before the query finishes, so it fails with `ObjectDisposedException` whenever the query does not complete synchronously. Make the method `async` and `return await`.
csharp/insecure-randomBLOCKER`System.Random` / `Random.Shared` for a security-sensitive value — OTPs, reset tokens, API keys, session ids, nonces, salts, invitation codes. Its output is predictable from a few samples. Use `RandomNumberGenerator.GetInt32`/`GetBytes`/`GetHexString`. Not for jitter, sampling or shuffling non-secret data.
csharp/ef-n-plus-oneHIGHEF Core N+1: navigation properties accessed in loops — `.Include`/projection; and unbounded queries without paging.
csharp/multiple-enumerationHIGH`IEnumerable` multiple enumeration of LINQ-to-entities queries — materialize once with `ToListAsync`.
csharp/datetime-nowHIGHDateTime.Now in new code — `DateTimeOffset.UtcNow` / `TimeProvider`.
csharp/entity-in-controller-responseHIGHEntities returned from controllers — DTOs/records only; entities leak lazy-nav and internal fields.
csharp/missing-model-validationHIGHMissing model validation at the boundary (`[ApiController]` + data annotations or FluentValidation).
csharp/lock-over-asyncHIGH`lock` over async operations (can't await inside lock) — `SemaphoreSlim`.
csharp/fire-and-forget-task-runHIGHFire-and-forget `Task.Run` without exception observation — use hosted services / background queues.
csharp/exception-detail-in-responseHIGHException details in API responses — ProblemDetails mapping.
csharp/rethrow-loses-stackHIGH`throw ex;` inside a catch. Rethrowing the variable restarts the stack trace at this method, so the frames between the fault and the catch are gone from the incident log. Use bare `throw;`, or `ExceptionDispatchInfo.Capture(ex).Throw()` outside the handler.
csharp/exception-wrapped-without-innerHIGHAn exception translated without its cause. `catch (SqlException ex) { throw new OrderException("Could not save"); }` turns a deadlock, a timeout and a constraint violation into one message with no cause. Pass `ex` as `innerException`, or rethrow with `throw;`.
csharp/reserved-exception-typeHIGHThrowing `Exception`, `ApplicationException`, `SystemException` or a runtime-reserved type (`NullReferenceException`, `IndexOutOfRangeException`, …). A caller can only handle it with `catch (Exception)`, which also swallows real bugs — mapping "not found" to 404 that way turns a null dereference into a 404 too. Throw a specific framework or domain exception. Not in test doubles simulating an arbitrary failure.
csharp/sync-io-in-asyncHIGHA synchronous API inside an async method when an `…Async` overload exists — `File.ReadAllText`, `stream.Read`, `SaveChanges()`, `ToList()`/`First()` on an EF query, `Thread.Sleep`. It blocks a pool thread for the whole I/O, so under load the pool starves and unrelated requests time out. Await the async overload.
csharp/culture-implicit-parse-formatHIGHMachine-readable values parsed or formatted with the server's culture — `decimal/double/DateTime.Parse(s)`, `ToString()` written to storage or the wire, `ToLower()` building a key. On a `de_DE` host `decimal.Parse("1.50")` is `150`. Pass `CultureInfo.InvariantCulture` (or an invariant format, `ToLowerInvariant()`); current culture only for text shown to users.
csharp/case-compare-via-tolowerHIGHCase-insensitive comparison via `a.ToUpper() == b.ToUpper()` / `.ToLower()`. It depends on culture — `role.ToUpper() == "ADMIN"` is false for `admin` on a tr-TR host — and allocates per comparison. Use `string.Equals(a, b, StringComparison.OrdinalIgnoreCase)`. Not inside EF Core `IQueryable` predicates, which cannot translate the overload.
csharp/ef-inmemory-test-doubleHIGHQuery tests against `UseInMemoryDatabase` or a mocked/list-backed `DbSet`. The fake has no transactions, raw SQL, constraints or provider translation, so the suite is green on queries that throw or behave differently in production — Microsoft's own testing guidance discourages it. Test against the real provider (Testcontainers, LocalDB) or stub a repository boundary.
csharp/records-for-dtosSUGGESTIONRecords for immutable DTOs; `required`/init-only properties over constructors with many args.
csharp/nullable-reference-typesSUGGESTIONNullable reference types enabled and respected in new projects — no `!` dammit-operator without comment.
csharp/structured-loggingSUGGESTION`ILogger<T>` structured logging with message templates, not string interpolation.
csharp/api-style-consistencySUGGESTIONMinimal APIs vs controllers — follow whichever the service already uses.

Expected output

A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:

a finding from this file
Redline/BLOCKER [csharp/async-void]: <one-line problem>

Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 28 rules here, 9 BLOCKER, 15 HIGH and 4 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.

If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.

How to edit it

Rules are edited in standards/stacks/csharp.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.

  1. Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
  2. node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
  3. node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
  4. Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
  5. Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
  6. node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.

Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.

The full file

standards/stacks/csharp.md · 65 lines · 5.9 KB
# C# (.NET) Review Rules

## BLOCKER — request changes

- `csharp/async-void` — **`async void`** anywhere except event handlers — exceptions escape the caller and crash the process; return `Task`.
- `csharp/sync-over-async` — **Sync-over-async**: `.Result`, `.Wait()`, `.GetAwaiter().GetResult()` on async calls — deadlock and thread-pool starvation; async all the way.
- `csharp/httpclient-per-request` — **`HttpClient` instantiated per request** — socket exhaustion; use `IHttpClientFactory`.
- `csharp/captive-dependencies` — **Captive dependencies**: scoped services (DbContext) injected into singletons — cross-request state bleed; inject `IServiceScopeFactory` or restructure.
- `csharp/sql-string-concatenation` — **String-concatenated SQL with external input** — parameterized queries / EF LINQ only.
- `csharp/swallowed-exceptions` — **Swallowed exceptions**: `catch { }` or catch-log-continue treating failure as success.
- `csharp/missing-cancellation-token` — **Missing `CancellationToken` propagation** on new async endpoints and outbound calls.
- `csharp/task-returned-from-using` — **A task returned unawaited from inside a `using` scope.** `using var db =
  …; return db.Orders.ToListAsync();` disposes the context before the query finishes, so it fails with
  `ObjectDisposedException` whenever the query does not complete synchronously. Make the method `async` and
  `return await`.
- `csharp/insecure-random` — **`System.Random` / `Random.Shared` for a security-sensitive value** — OTPs, reset
  tokens, API keys, session ids, nonces, salts, invitation codes. Its output is predictable from a few samples.
  Use `RandomNumberGenerator.GetInt32`/`GetBytes`/`GetHexString`. Not for jitter, sampling or shuffling
  non-secret data.

## HIGH

- `csharp/ef-n-plus-one` — EF Core N+1: navigation properties accessed in loops — `.Include`/projection; and unbounded queries without paging.
- `csharp/multiple-enumeration` — `IEnumerable` multiple enumeration of LINQ-to-entities queries — materialize once with `ToListAsync`.
- `csharp/datetime-now` — DateTime.Now in new code — `DateTimeOffset.UtcNow` / `TimeProvider`.
- `csharp/entity-in-controller-response` — Entities returned from controllers — DTOs/records only; entities leak lazy-nav and internal fields.
- `csharp/missing-model-validation` — Missing model validation at the boundary (`[ApiController]` + data annotations or FluentValidation).
- `csharp/lock-over-async` — `lock` over async operations (can't await inside lock) — `SemaphoreSlim`.
- `csharp/fire-and-forget-task-run` — Fire-and-forget `Task.Run` without exception observation — use hosted services / background queues.
- `csharp/exception-detail-in-response` — Exception details in API responses — ProblemDetails mapping.
- `csharp/rethrow-loses-stack` — **`throw ex;` inside a catch.** Rethrowing the variable restarts the stack
  trace at this method, so the frames between the fault and the catch are gone from the incident log. Use bare
  `throw;`, or `ExceptionDispatchInfo.Capture(ex).Throw()` outside the handler.
- `csharp/exception-wrapped-without-inner` — **An exception translated without its cause.** `catch (SqlException
  ex) { throw new OrderException("Could not save"); }` turns a deadlock, a timeout and a constraint violation
  into one message with no cause. Pass `ex` as `innerException`, or rethrow with `throw;`.
- `csharp/reserved-exception-type` — **Throwing `Exception`, `ApplicationException`, `SystemException` or a
  runtime-reserved type** (`NullReferenceException`, `IndexOutOfRangeException`, …). A caller can only handle it
  with `catch (Exception)`, which also swallows real bugs — mapping "not found" to 404 that way turns a null
  dereference into a 404 too. Throw a specific framework or domain exception. Not in test doubles simulating an
  arbitrary failure.
- `csharp/sync-io-in-async` — **A synchronous API inside an async method when an `…Async` overload exists** —
  `File.ReadAllText`, `stream.Read`, `SaveChanges()`, `ToList()`/`First()` on an EF query, `Thread.Sleep`. It
  blocks a pool thread for the whole I/O, so under load the pool starves and unrelated requests time out. Await
  the async overload.
- `csharp/culture-implicit-parse-format` — **Machine-readable values parsed or formatted with the server's
  culture** — `decimal/double/DateTime.Parse(s)`, `ToString()` written to storage or the wire, `ToLower()`
  building a key. On a `de_DE` host `decimal.Parse("1.50")` is `150`. Pass `CultureInfo.InvariantCulture` (or an
  invariant format, `ToLowerInvariant()`); current culture only for text shown to users.
- `csharp/case-compare-via-tolower` — **Case-insensitive comparison via `a.ToUpper() == b.ToUpper()` /
  `.ToLower()`.** It depends on culture — `role.ToUpper() == "ADMIN"` is false for `admin` on a tr-TR host — and
  allocates per comparison. Use `string.Equals(a, b, StringComparison.OrdinalIgnoreCase)`. Not inside EF Core
  `IQueryable` predicates, which cannot translate the overload.
- `csharp/ef-inmemory-test-double` — **Query tests against `UseInMemoryDatabase` or a mocked/list-backed
  `DbSet`.** The fake has no transactions, raw SQL, constraints or provider translation, so the suite is green
  on queries that throw or behave differently in production — Microsoft's own testing guidance discourages it.
  Test against the real provider (Testcontainers, LocalDB) or stub a repository boundary.

## SUGGESTION

- `csharp/records-for-dtos` — Records for immutable DTOs; `required`/init-only properties over constructors with many args.
- `csharp/nullable-reference-types` — Nullable reference types enabled and respected in new projects — no `!` dammit-operator without comment.
- `csharp/structured-logging` — `ILogger<T>` structured logging with message templates, not string interpolation.
- `csharp/api-style-consistency` — Minimal APIs vs controllers — follow whichever the service already uses.