NullReferenceException crashes production despite null checks throughout the
codebase. Async methods deadlock because ConfigureAwait(false) wasn't used.
Resources leak because Dispose() isn't called. LINQ queries execute repeatedly
because materialization wasn't understood. These issues compile successfully and
often escape review until production load reveals the problems.
This checklist helps catch what matters: null safety violations, async/await pitfalls, resource management errors, and code that fights modern C# instead of leveraging nullable reference types, records, and pattern matching.
Quick Reference Checklist
Use this checklist as a quick reference during code review.
Null Safety
- Nullable reference types enabled:
<Nullable>enable</Nullable>in project file - Null-conditional operator used:
obj?.Property - Null-coalescing operator for defaults:
value ?? defaultValue - Null-forgiving operator
!only when truly guaranteed non-null - Method parameters annotated:
string?for nullable,stringfor non-null - Return types document nullability:
User?vsUser ArgumentNullExceptionthrown for null guard clauses- Collection methods use null-safe patterns:
??not|| new List()
Async/Await Patterns
asyncmethods suffixed withAsync:GetDataAsync()- Async all the way: no
Task.Wait()orTask.Resultin async code ConfigureAwait(false)in library code to avoid deadlocksValueTask<T>for hot paths when appropriate- Cancellation tokens passed through:
CancellationToken cancellationToken - Async streams for large sequences:
IAsyncEnumerable<T> - No
async voidexcept event handlers - Task-based operations preferred over legacy Begin/End patterns
Resource Management
IDisposableimplemented for managed resourcesusingstatements or declarations for disposable resourcesIAsyncDisposablefor async cleanup withawait using- No finalizers unless managing unmanaged resources
- Dispose pattern implemented correctly when needed
- Resources not stored in fields without disposal
- Static analysis warnings for undisposed resources addressed
HttpClientreused, not created per request
Modern C# Features
- Records used for immutable data:
record User(string Name, int Age) - Init-only properties for immutability:
{ get; init; } - Pattern matching for type checks:
if (obj is string s) - Switch expressions return values:
result = input switch { ... } - Target-typed new:
List<string> list = new() - Global using statements for common namespaces
- File-scoped namespaces reduce indentation
- String interpolation for formatting:
$"Hello {name}"
LINQ & Collections
- LINQ queries materialized intentionally:
.ToList(),.ToArray() - Deferred execution understood and used appropriately
Any()for existence checks, notCount() > 0- Generic collections used:
List<T>,Dictionary<TKey, TValue> - Immutable collections for shared state:
ImmutableList<T> - Collection initializers:
new List<int> { 1, 2, 3 } - Query syntax vs method syntax chosen for clarity
- No multiple enumeration of queries without materialization
Exception Handling
- Specific exceptions caught, not generic
Exception - Exception filters used:
catch (Exception e) when (e.Message.Contains(...)) usingpreferred over try-finally for resources- Custom exceptions inherit from appropriate base
- Exception messages provide context
throw;preserves stack trace, notthrow ex;- No swallowing exceptions with empty catch
- Async exceptions awaited to get actual exception type
Null Safety with Nullable Reference Types
Nullable reference types, introduced in C# 8, make nullability explicit in the
type system. Enabling this feature with <Nullable>enable</Nullable> in the
project file changes how the compiler treats reference types. By default,
reference types become non-nullable, requiring explicit ? annotation for
nullable references. This catches potential null reference exceptions at compile
time rather than runtime.
The null-conditional operator ?. safely accesses members that might be null.
The expression user?.Name returns null if user is null, preventing
NullReferenceException. This chains: user?.Profile?.Email returns null if any
step is null. When we see code with explicit null checks before every property
access, null-conditional operators simplify the logic.
Null-coalescing ?? provides defaults for null values. The expression
name ?? "Guest" uses the name if not null, otherwise "Guest". This works for
any reference type and nullable value types. When combined with
null-conditional, user?.Name ?? "Unknown" provides a default when user is null
or Name is null.
The null-forgiving operator ! tells the compiler "I know this isn't null even
though you can't prove it." The expression user!.Name suppresses null
warnings. This should be rare—valid uses include immediately after
initialization where control flow guarantees non-null but the compiler can't
track it. Most uses of ! indicate we should restructure code or add proper
null checks.
Method parameters and return types should annotate nullability explicitly. A
parameter string? accepts null, while string does not. Return types User?
vs User document whether callers should expect null. This makes null contracts
explicit in signatures rather than requiring documentation.
Guard clauses with ArgumentNullException.ThrowIfNull(parameter) (C# 11+) or
if (parameter == null) throw new ArgumentNullException(nameof(parameter))
validate parameters at method entry. These preconditions catch bugs at the
boundary rather than deep in execution.
Async/Await Best Practices
Async method names conventionally end with Async to signal asynchronous
execution. GetDataAsync() clearly indicates the method returns a Task. This
convention helps developers understand that the method should be awaited and
prevents confusion with synchronous versions.
Async all the way means once we go async, we stay async throughout the call
chain. Using Task.Wait() or Task.Result in async code creates deadlock
risks, especially in UI applications and ASP.NET. When we see these blocking
calls in async contexts, that's a code smell indicating the async pattern isn't
followed correctly.
ConfigureAwait(false) in library code prevents deadlocks by not capturing the
synchronization context. Library methods don't need to resume on the original
context, so await operation.ConfigureAwait(false) improves performance and
prevents deadlocks. In application code (UI or ASP.NET), we usually omit this
because we need the original context.
ValueTask<T> provides performance benefits for synchronous completion paths.
When operations often complete synchronously (cache hits, buffered data),
ValueTask avoids heap allocations. This optimization matters in hot paths but
adds complexity, so we use it only when profiling shows benefit.
Cancellation tokens flow through async operations to enable cooperative
cancellation. Methods accepting CancellationToken cancellationToken parameters
can check for cancellation and stop work early. When reviewing async methods
that might be long-running, we verify cancellation token support.
Async streams using IAsyncEnumerable<T> handle large sequences efficiently.
The pattern await foreach (var item in GetItemsAsync()) processes items as
they arrive without loading everything into memory. When methods return large
sequences, async streams provide better resource usage than returning
Task<List<T>>.
async void should only appear in event handlers. Async void methods can't be
awaited, and exceptions in them can't be caught normally, making them dangerous.
When we see async void outside event handlers, that should be async Task
instead.
Resource Management and IDisposable
IDisposable indicates types that hold resources requiring explicit cleanup.
File handles, database connections, network sockets, and unmanaged memory need
disposal. When we see classes holding these resources without implementing
IDisposable, that's a resource leak waiting to happen.
using statements ensure disposal even when exceptions occur. The pattern
using (var connection = new SqlConnection()) or the declaration form
using var connection = new SqlConnection(); automatically calls Dispose at
scope end. When we see manual Dispose() calls in finally blocks, using
statements simplify the code.
IAsyncDisposable supports asynchronous cleanup for resources requiring async
disposal. The pattern await using var resource = new AsyncResource(); calls
DisposeAsync automatically. When async cleanup is needed (flushing buffers,
closing connections gracefully), IAsyncDisposable provides the correct pattern.
Finalizers (destructors in C# syntax) should only exist for unmanaged resources. Managed resources are cleaned up by the garbage collector without finalizers. Finalizers run on a separate thread at unpredictable times and slow garbage collection. When we see finalizers in code that doesn't handle unmanaged resources, they should be removed.
The full dispose pattern with both IDisposable and finalizer is complex and rarely needed. Most classes just implement IDisposable directly. When reviewing dispose patterns, we verify the implementation matches actual resource management needs rather than copying boilerplate that isn't necessary.
HttpClient should be reused, not created per request. Creating new HttpClient
instances depletes socket connections and causes errors under load. Singleton
HttpClient or IHttpClientFactory in ASP.NET Core provides proper lifecycle
management. When we see HttpClient in using statements, that usually indicates
improper usage.
Modern C# Language Features
Records provide concise syntax for immutable data carriers. The declaration
record User(string Name, int Age); generates a constructor, properties,
equality members, and deconstruction. Records work well for DTOs, value objects,
and any data that shouldn't change after creation. When we see classes with only
init properties and equality overrides, records simplify the code.
Init-only properties allow object initialization but prevent later modification.
The property { get; init; } can be set during object creation but becomes
readonly afterward. This creates immutability without constructor parameters for
every property. When properties should be set once and never changed, init
provides clean syntax.
Pattern matching for type checks eliminates casting. The pattern
if (obj is string s) combines type check and cast in one expression, with s
scoped to the if block. This beats the old pattern of is followed by casting.
Switch expressions and recursive patterns enable sophisticated matching.
Switch expressions return values directly instead of using cases and breaks. The
pattern var result = input switch { 1 => "one", 2 => "two", _ => "other" }; is
more concise than traditional switch statements. When switches just assign
values, switch expressions improve clarity.
Target-typed new expressions infer type from context.
List<string> list = new(); uses the declared type instead of repeating it.
This reduces verbosity when the type is clear from context. When variable
declarations repeat type information, target-typed new simplifies the code.
File-scoped namespaces reduce indentation. namespace MyApp.Services; at file
top scopes the entire file without braces and indentation. This saves a level of
nesting throughout the file. When we see single-namespace files with everything
indented, file-scoped namespaces improve readability.
String interpolation $"Hello {name}" formats strings more readably than
concatenation or Format. Complex expressions work in interpolations:
$"Total: {items.Sum(i => i.Price):C}". When we see string concatenation or
Format calls, interpolation often reads better.
LINQ Patterns and Collections
LINQ queries use deferred execution—they don't execute until enumerated. The
query var query = items.Where(x => x.IsActive) doesn't filter items
immediately. Enumeration with foreach, .ToList(), or .Count() triggers
execution. When queries are enumerated multiple times, each enumeration
re-executes the query. We verify queries are materialized with .ToList() or
.ToArray() when needed more than once.
Any() checks for existence more efficiently than Count() > 0. The expression
items.Any() stops at the first element, while Count() must enumerate
everything. For filtered checks, items.Any(x => x.IsActive) beats
items.Where(x => x.IsActive).Count() > 0. When we see Count for existence
checks, Any performs better.
Generic collections provide type safety and performance. List<T>,
Dictionary<TKey, TValue>, and HashSet<T> should replace non-generic
ArrayList, Hashtable, and legacy collections. Generic collections avoid boxing
value types and enable compile-time type checking.
Immutable collections prevent modification, useful for shared state.
ImmutableList<T>, ImmutableDictionary<TKey, TValue> create new instances for
modifications rather than changing existing instances. When collections are
shared between threads or should never change, immutable collections prevent
bugs from unexpected modifications.
Collection initializers provide clean syntax: new List<int> { 1, 2, 3 } beats
creating then adding elements. Dictionary initializers work similarly:
new Dictionary<string, int> { ["one"] = 1, ["two"] = 2 }. When we see manual
Add calls immediately after construction, initializers improve readability.
Multiple enumeration of the same LINQ query re-executes the query each time.
When a query hits a database, multiple enumerations mean multiple database
calls. If we see the same query variable used in multiple places,
materialization with .ToList() executes once and reuses the results.
Exception Handling Strategies
Specific exceptions communicate what failed precisely. Catching
FileNotFoundException allows different handling than generic IOException.
When we see broad catches like catch (Exception), more specific exception
types usually enable better error handling. Generic catches should be at the
outermost level for logging, not scattered throughout code.
Exception filters using when enable conditional catching. The pattern
catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound)
catches only specific error conditions. This beats catching all exceptions of a
type and rethrowing ones we don't handle.
using statements replace try-finally for resource cleanup. When the only
purpose of finally is calling Dispose, using statements express this more
clearly and prevent forgetting to call Dispose.
Custom exceptions should inherit from appropriate base classes. Domain-specific exceptions that extend Exception or specialized exceptions like ArgumentException communicate errors better than generic exceptions with different messages.
throw; preserves the original stack trace when rethrowing, while throw ex;
resets it. When we see catch blocks that rethrow, using throw; without the
exception variable preserves diagnostic information.
Empty catch blocks swallow exceptions, hiding problems. When we see catch { },
there should be a comment explaining why the exception doesn't matter. Usually,
exceptions should be logged or handled, not ignored.
Async exceptions require await to get the actual exception type. When async
methods throw, the exception wraps in AggregateException unless awaited. Code
using Task.Wait() or Task.Result sees AggregateException instead of the
actual exception, complicating error handling.
Bringing It Together
Effective C# code review balances modern language features with practical concerns. C# evolves rapidly with annual releases adding features like records, nullable reference types, and pattern matching. Code that leverages these features tends to be more concise and safer than code written in older styles.
Not all issues require immediate fixing. A missing ConfigureAwait in application code might not matter. Blocking on async code in a library creates real deadlock risks and should be fixed. The key is understanding which issues affect reliability and which represent opportunities for improvement.
C#'s rich type system and framework enable writing very expressive code. When reviewing C#, we verify the code uses appropriate language features and framework capabilities rather than fighting them. Better understanding of async/await, LINQ, and modern syntax features creates more maintainable codebases that handle production loads reliably.

