Skip to content

Guard Clauses Done Right

Every .NET codebase has dozens of methods that start with the same ritual: check a parameter, throw if it is null, repeat. The code is correct but verbose, inconsistent, and easy to get wrong. Since .NET 6, there is a better way — and Granit enforces it across all 100+ packages.

Manual null checks are boilerplate. They are also a source of subtle bugs: typos in nameof(), wrong exception types, or checks that simply get forgotten during a late-night commit.

TenantService.cs — Don't do this
public class TenantService
{
public async Task<Tenant> GetTenantAsync(string tenantId, IDbConnection connection)
{
if (tenantId == null)
throw new ArgumentNullException(nameof(tenantId));
if (string.IsNullOrEmpty(tenantId))
throw new ArgumentException("Value cannot be empty.", nameof(tenantId));
if (connection == null)
throw new ArgumentNullException(nameof(connection));
// Business logic starts here — after 8 lines of ceremony.
return await connection.QuerySingleAsync<Tenant>(tenantId);
}
}

Three parameters, eight lines of guard code. Every developer writes these slightly differently: some use is null, some use == null, some throw ArgumentException for empty strings, some do not. The inconsistency adds up.

Worse, if you rename a parameter and forget to update the nameof(), the exception message points to a parameter that no longer exists. The compiler will not catch it.

.NET ships a family of static guard methods on the exception types themselves. They validate the argument and throw the correct exception with the correct parameter name — automatically.

TenantService.cs — Do this instead
public class TenantService
{
public async Task<Tenant> GetTenantAsync(string tenantId, IDbConnection connection)
{
ArgumentException.ThrowIfNullOrEmpty(tenantId);
ArgumentNullException.ThrowIfNull(connection);
return await connection.QuerySingleAsync<Tenant>(tenantId);
}
}

Two lines instead of eight. No nameof(). No chance of a mismatch.

The magic behind these methods is [CallerArgumentExpression], a compiler attribute introduced in C# 10. When you write:

ArgumentNullException.ThrowIfNull(connection);

The compiler rewrites it at compile time to pass "connection" as the parameter name. You get the correct name in the exception message without writing it yourself. Rename the variable, and the exception message updates automatically.

.NET provides guard methods for the most common precondition checks. Here is the complete set available in .NET 10:

MethodThrowsUse case
ArgumentNullException.ThrowIfNull(value)ArgumentNullExceptionNull reference or nullable value type
ArgumentException.ThrowIfNullOrEmpty(value)ArgumentNullException / ArgumentExceptionNull or empty string
ArgumentException.ThrowIfNullOrWhiteSpace(value)ArgumentNullException / ArgumentExceptionNull, empty, or whitespace string
ArgumentOutOfRangeException.ThrowIfZero(value)ArgumentOutOfRangeExceptionNumeric zero
ArgumentOutOfRangeException.ThrowIfNegative(value)ArgumentOutOfRangeExceptionNegative number
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value)ArgumentOutOfRangeExceptionZero or negative
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, other)ArgumentOutOfRangeExceptionValue exceeds upper bound
ArgumentOutOfRangeException.ThrowIfLessThan(value, other)ArgumentOutOfRangeExceptionValue below lower bound
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(value, other)ArgumentOutOfRangeExceptionValue at or above bound
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(value, other)ArgumentOutOfRangeExceptionValue at or below bound
ArgumentOutOfRangeException.ThrowIfEqual(value, other)ArgumentOutOfRangeExceptionValue equals a forbidden value
ArgumentOutOfRangeException.ThrowIfNotEqual(value, other)ArgumentOutOfRangeExceptionValue does not equal expected
ObjectDisposedException.ThrowIf(condition, instance)ObjectDisposedExceptionObject already disposed

These cover the vast majority of parameter validation. For domain-specific preconditions (entity not found, invalid state transitions), Granit uses semantic exceptions like NotFoundException and BusinessException instead — see the Guard Clause pattern page.

Here is how ThrowIf* methods look in practice, taken from a typical Granit service:

BlobStorageService.cs
public async Task<BlobDescriptor> UploadAsync(
string containerName,
Stream content,
string fileName,
string? contentType = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(containerName);
ArgumentNullException.ThrowIfNull(content);
ArgumentException.ThrowIfNullOrEmpty(fileName);
ArgumentOutOfRangeException.ThrowIfZero(content.Length);
// All preconditions validated — business logic starts clean.
var descriptor = new BlobDescriptor(containerName, fileName, contentType);
await _storage.PutAsync(descriptor, content, cancellationToken);
return descriptor;
}

Four guards, four lines. Each one throws the right exception type with the right parameter name. No nameof(), no string literals, no room for error.

This is not just about saving keystrokes. The consistency has real benefits:

  • Correct exception typesThrowIfNullOrEmpty throws ArgumentNullException for null and ArgumentException for empty. Manual code often gets this wrong.
  • Refactoring-safeCallerArgumentExpression tracks the parameter name at compile time. Rename freely.
  • Performance — these methods are implemented with [StackTraceHidden] and aggressive inlining. The JIT optimizes the non-throwing path to near zero overhead.
  • Consistency — across 93 Granit packages, every guard clause looks the same. New contributors read one, they have read them all.