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:
<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.propswith<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>holds everyPackageVersion; project files carry versionlessPackageReferences, 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–NU1904warnings; withTreatWarningsAsErrorsthey fail the build. CI restores withdotnet restore --locked-modeso a driftedpackages.lock.jsonfails. dotnet formatapplies.editorconfigrules; some repositories use CSharpier for layout instead. Follow what the repository has.EnforceCodeStyleInBuildmakes IDE style rules (IDExxxx) build diagnostics, so the build and the IDE agree.
The gate (warnings are errors through Directory.Build.props):
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 throughCallerArgumentExpression. !(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.CodeAnalysisattributes so callers need no!:
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 typedstring;default(SomeStruct)leaves its reference fields null; a property initialized by neither constructor norrequired. - System.Text.Json writes
nullinto non-nullable properties unless told otherwise. Mark mandatory membersrequired(honoured since .NET 7: a missing property throws) and setJsonSerializerOptions.RespectNullableAnnotations = true(.NET 9) to reject explicitnulls. - Unconstrained generic
T?means "may bedefault": forT = intit isint, with noNullable<int>involved. Constrain withwhere T : classorwhere T : structwhen 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) |
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. AList<T>, array orImmutableArray<T>member compares by reference, so two records with equal contents are unequal: dictionary lookups miss and test assertions fail. OverrideEquals(SpoolEntry? other)andGetHashCodewithSequenceEqual, or keep collections out of records used as keys or compared in tests. record structpositional properties are mutable (get; set;);readonly record structmakes theminit.withcopies 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
readonlymember and use only that. sealedlets the JIT devirtualize calls; CA1852 flags internal types that could be sealed.- A non-exhaustive switch expression warns CS8509 and throws
SwitchExpressionExceptionat runtime. Enums are open sets (anyintcasts to an enum), so a switch naming every member still warns CS8524 until it has a_arm; make that arm throw.
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
fieldkeyword reaches the compiler-generated backing field from an accessor (set => field = value.Trim(););extensionblocks declare extension properties and static members;a?.B = xassigns only whenais 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:
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 catchesOperationCanceledExceptionand turns a cancellation into a phantom error or a silent success.- Own a resource with
using var(disposed at scope end) orawait usingforIAsyncDisposable. A class that owns anIDisposablefield implementsIDisposableitself. Finalizers only for types wrapping raw OS handles, andSafeHandleusually removes the need.
Async / Await
- Async all the way down.
.Result,.Wait()and.GetAwaiter().GetResult()on a thread with aSynchronizationContext(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
ConfigureAwaitOptionsforTask:await loop.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing)waits for a cancelled background loop to finish without rethrowing. async voidonly 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.Resultbefore completion, or await it concurrently. Call.AsTask()when you need any of those.CancellationTokenis 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'sException.InnerExceptions.awaitinsidelockdoes not compile (CS1996). Guard async critical sections withSemaphoreSlim(1, 1)and release infinally. For synchronous locks, .NET 9 / C# 13 addsSystem.Threading.Lock;lockon aLockfield uses its faster scope API.- Bounded concurrency:
Parallel.ForEachAsyncwithMaxDegreeOfParallelism(.NET 6);Channel.CreateBounded<T>(capacity)for producer/consumer with backpressure. IAsyncEnumerable<T>producers take[EnumeratorCancellation] CancellationToken ctsoawait 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 aBackgroundServicethrough aChannel<T>.
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 insideSelectrun lazily, repeatedly, or never. IQueryable<T>andIEnumerable<T>. EF Core translates the expression tree to SQL up to the firstAsEnumerable()/ToList(); every operator after that runs in memory over the rows already fetched. Filter, project and page before materializing:
// 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
Includeor project withSelect; addAsSplitQuery()when several collectionIncludes multiply the row count. Any()stops at the first element;Count() > 0counts them all.Singlethrows on more than one match,Firston none.FirstOrDefaulton a value type returns0/default, indistinguishable from a real zero: pass the fallback explicitly (FirstOrDefault(pred, -1), .NET 6) or use aTryGet-style API.OrderBy(a).OrderBy(b)discards the first sort; chainThenBy.- A
forloop variable is shared by every lambda created in the loop; aforeachvariable 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
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));
}
TypedResultswith aResults<...>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,
CancellationTokenfromHttpContext.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 onValidateOnBuildandValidateScopesin tests too. ABackgroundServicecreates scopes throughIServiceScopeFactory. HttpClient:new HttpClient()per call exhausts sockets; one static instance never sees DNS changes unlessSocketsHttpHandler.PooledConnectionLifetimeis set. UseIHttpClientFactory(typed clients viaAddHttpClient<T>).ValidateOnStart()on options makes bad configuration fail at startup instead of on the first request that reads it.AddProblemDetails()plusUseExceptionHandler()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) |
| Shared expensive state | IClassFixture<T>, ICollectionFixture<T> |
[OneTimeSetUp] fields, [SetUpFixture] |
| Parallelism | classes (collections) in parallel, tests within a class in sequence | off unless [Parallelizable] |
| Assertions | Assert.Equal(expected, actual) |
Assert.That(actual, Is.EqualTo(expected)); classic asserts live in ClassicAssert since NUnit 4 |
public sealed class SpoolWriterTests : IClassFixture<TempDirFixture>
{
private readonly TempDirFixture _dir;
public SpoolWriterTests(TempDirFixture dir) => _dir = dir;
[Fact]
public async Task AppendsRecordToJournal()
{
var writer = new SpoolWriter(_dir.Path, new FakeTimeProvider());
await writer.AppendAsync("a", TestContext.Current.CancellationToken);
Assert.Single(await File.ReadAllLinesAsync(writer.JournalPath));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void RejectsBlankIds(string id) =>
Assert.Throws<ArgumentException>(() => SpoolId.Parse(id));
}
- xUnit v3 test projects build to executables and expose
TestContext.Current(cancellation token, output);async voidtest methods are not supported, returnTask.ITestOutputHelper(constructor-injected) captures per-test output. - Time and randomness through seams: inject
TimeProvider(.NET 8) and advance aFakeTimeProvider(Microsoft.Extensions.TimeProvider.Testing); code under test never readsDateTime.Now. - Prefer fakes and real in-memory collaborators; mock (NSubstitute, Moq) only true external boundaries. For databases, run the production engine in Testcontainers: the EF Core in-memory provider enforces no constraints and translates no SQL, so it accepts queries production rejects.
- HTTP:
WebApplicationFactory<Program>hosts the app in memory; addpublic partial class Program { }when the test project cannot see the generatedProgram. - Assertion libraries: FluentAssertions 8 and later needs a paid license for commercial use; Shouldly and AwesomeAssertions (a fork of FluentAssertions 7) are open. The built-in
Assertcovers most suites. - Filters:
dotnet test --filter "FullyQualifiedName~SpoolWriter"(~is contains),--filter "Category=Integration"(xUnit[Trait("Category", "Integration")], NUnit[Category("Integration")]),--no-buildafter a separate build,--logger "console;verbosity=detailed".
Loom Test Runner Adapter
Adapter: dotnet-test. Loom marks a package csharp when its directory holds any *.csproj or *.sln, and every csharp package gets dotnet-test. loom project detect prints it per package:
tests/Spool.Tests kinds=csharp runner=dotnet-test skills=loom-csharp
A directory holding only a .slnx solution is not a csharp package under that rule; each project directory beneath it still is.
Single-test command, run with the package directory as cwd (dotnet test restores and builds before it runs anything):
dotnet test --filter "FullyQualifiedName={test}"
The test field is Namespace.Class.Method, the test's fully qualified name. = is an exact match, so the value is the whole name: Spool.Tests.SpoolWriterTests.RejectsSymlinkedSpool. The command does not use the contract's file; loom uses it to find the owning package and to freeze the file.
No-match behaviour: when the filter selects nothing, dotnet test exits 0 and prints No test matches the given testcase filter ... on stdout (captured from a real run: xUnit v3, .NET SDK 10.0.112, VSTest 18). Loom reads the runner's summary (Passed! - Failed: 0, Passed: 1, ... or Failed! - ...), so a contract test whose name does not match is classified as not selected and fails the freeze ("the runner did not select the test"). Completion fails the same way.
Writing contract tests:
- Contract tests live in a test project, conventionally
tests/<Project>.Tests/, one<Subject>Tests.csper subject; loom'scsharplanguage profile decides which paths count as test files. SDK-style projects compile every*.csunder the project directory, so a new test file in an existing test project needs no project-file edit. A new test project (its.csproj, the solution entry,Directory.Packages.propschanges) is harness: list those paths in the stage'sharnessglobs. - Make each contract a parameterless
[Fact](xUnit) or[Test](NUnit) in a top-level public class, with a method name unique in that class. Nested classes put+into the fully qualified name (Outer+Inner.Method).[Theory]rows share one name and NUnit appends each[TestCase]row's arguments to it, so an exact filter selects several tests or none. - Keep one solution or project file per package directory. With two (a
.slnbeside a.csproj, or two.csprojfiles)dotnet teststops withMSB1011before building anything. bin/andobj/must be gitignored. The freeze rejects any changed or untracked path that is neither a contractfilenor aharnessmatch, anddotnet testcreates both directories.- The command and its parser target
dotnet test's default VSTest mode. A repository whoseglobal.jsonswitchesdotnet testto Microsoft.Testing.Platform prints a different summary; run the command by hand once and confirm thePassed!/Failed!line appears before relying on it. - Set
runner: dotnet-teston a contract only to override detection.
contracts:
- id: rejects-symlinked-spool
file: tests/Spool.Tests/SpoolWriterTests.cs
test: Spool.Tests.SpoolWriterTests.RejectsSymlinkedSpool
scenario: creates a spool directory whose journal file is a symlink to a file outside the spool root, then calls SpoolWriter.OpenAsync on the directory and appends one record
rejects: an OpenAsync that follows the symlink and appends the record to the file outside the spool root
Build failures: dotnet test compiles the project before running anything. A contract test that calls a type or member the stage has not written yet fails compilation (CS0246, CS1061); loom classifies the run as a build failure, and the freeze accepts that as red. Any other compile error in the project is also a build failure, so read the diagnostics and confirm they name the API the contract is waiting for.
Anti-Patterns
// Sync over async: deadlocks under a SynchronizationContext, starves the pool elsewhere
var data = client.GetStringAsync(url).Result; // BAD
var data = await client.GetStringAsync(url, ct); // GOOD
// async void outside event handlers: exceptions crash the process
public async void Save() { await _repo.SaveAsync(); } // BAD
public Task SaveAsync(CancellationToken ct) => _repo.SaveAsync(ct); // GOOD
// Rethrow that destroys the stack trace
catch (IOException ex) { Log(ex); throw ex; } // BAD
catch (IOException ex) { Log(ex); throw; } // GOOD
// Culture-sensitive comparison of machine data (tr-TR: "TITLE".ToLower() is "tıtle")
if (header.ToLower() == "title") { } // BAD
if (string.Equals(header, "Title", StringComparison.OrdinalIgnoreCase)) { } // GOOD
// Interpolated log message: always allocates, loses the structured field (CA2254)
logger.LogInformation($"Opened {path}"); // BAD
logger.LogInformation("Opened {Path}", path); // GOOD
Quick swaps:
DateTime.Nowin domain logic → an injectedTimeProvider;DateTimefor instants →DateTimeOffsetin UTC.new Random()for tokens or IDs →RandomNumberGenerator.GetBytes/GetInt32.- Public
List<T>properties →IReadOnlyList<T>;nullfor "no items" → an empty collection. lock (this)orlock (typeof(X))→ a privatereadonly Lock(orobject) field.- String concatenation in a loop →
StringBuilderorstring.Join. doublefor money →decimal.catch (Exception) { }→ the specific exception type, or let it propagate.GC.Collect()→ delete it and measure allocations.
Expert Practices
Performance
- Measure before optimizing: BenchmarkDotNet with
[MemoryDiagnoser]for allocations;dotnet-countersanddotnet-traceagainst a running process. Span<T>/ReadOnlySpan<char>parse and slice without allocating;stackallocfor small buffers;ArrayPool<T>.Sharedfor large temporary buffers (return them infinallyand never touch them after). ASpan<T>is aref structand cannot live across anawait; async code usesMemory<T>.FrozenDictionary/FrozenSet(.NET 8) for lookups built once and read often.- Source generators remove reflection and keep trimming and Native AOT working:
[GeneratedRegex](.NET 7),[LoggerMessage](.NET 6),JsonSerializerContextfor System.Text.Json. - A struct passed as an interface or
objectis boxed (an allocation); generic constraints (where T : IComparable<T>) avoid it.
Correctness Gotchas
ConcurrentDictionary.GetOrAdd(key, factory)can run the factory more than once under contention (one result is kept). StoreLazy<T>values when the factory is expensive or has side effects.- Override
EqualsandGetHashCodetogether (HashCode.Combine), and never mutate a field that feeds the hash of an object used as a dictionary key. - Parse and format machine data with
CultureInfo.InvariantCulture: underde-DE,double.Parse("1.5")returns15. - A
staticfield of a generic class exists once per closed type:Cache<int>andCache<string>share nothing. - Calling a non-
readonlymember on areadonlyfield or aninparameter copies the struct first (a defensive copy), and the mutation is lost. Mark structs and their membersreadonly. - An
IEnumerable<T>parameter may be lazy, infinite or single-use; takeIReadOnlyCollection<T>/IReadOnlyList<T>when the method needs a count or a second pass.
Security
- SQL: EF Core
FromSql($"... {id}")andExecuteSqlturn interpolations into parameters;FromSqlRawover concatenated input is injection. Dapper and ADO.NET take parameters, always. - Deserialization:
BinaryFormatteris gone (.NET 9 throws). Newtonsoft.Json with anyTypeNameHandlingother thanNonelets the payload choose the type to instantiate, a known remote-code-execution route. System.Text.Json polymorphism uses an explicit[JsonDerivedType]allow-list. - Paths:
Path.Combine(root, input)returnsinputalone when it is rooted, and..segments walk out of the root. Resolve withPath.GetFullPath(Path.Join(root, input))and check the result starts with the root plus a directory separator. - Secrets: user-secrets in development, a vault or the environment in production, never
appsettings.json. Generate tokens withRandomNumberGeneratorand compare them withCryptographicOperations.FixedTimeEquals. - Processes:
ProcessStartInfowithArgumentList(each argument escaped on its own) andUseShellExecute = false; never assemble a shell command string from input.
Verification Checklists
Before marking C# work done:
-
dotnet format --verify-no-changesclean;dotnet buildwithTreatWarningsAsErrorshas zero warnings (nullable, analyzers, NuGet audit) -
dotnet testgreen; new behaviour has tests; contract tests are parameterless[Fact]/[Test]methods in top-level classes - Every
!anddefault!has a reason visible in the surrounding lines; public entry points guard arguments withThrowIfNulland its siblings - Records used as keys or compared in tests hold no collection members (or override equality)
- Packages added with
dotnet add package; central versions andpackages.lock.jsonupdated with them - No hardcoded secrets; SQL parameterized; no
TypeNameHandling; user-supplied paths resolved and prefix-checked -
bin/andobj/ignored by git
Async and data access review:
- No
.Result,.Wait()orGetAwaiter().GetResult(); noasync voidoutside event handlers - Library awaits use
ConfigureAwait(false); every async API takes and forwards aCancellationToken - No
awaitunderlock; everySemaphoreSlim.WaitAsynchas itsReleaseinfinally - Queries enumerated once; EF filters and projections run before materializing; no N+1 loops; read-only queries use
AsNoTracking() - No scoped service captured by a singleton;
HttpClientinstances come fromIHttpClientFactory