Part IV: The Guard Pattern
"Every boundary needs a guard. The question is what happens when someone crosses it."
Validation code is boring. It is repetitive. It is the kind of code that every developer writes hundreds of times and resents writing every single time. Check for null, throw ArgumentNullException. Check for empty string, throw ArgumentException. Check for negative number, throw ArgumentOutOfRangeException. Repeat until the constructor has more validation lines than logic lines.
And yet validation code is critical. It is the boundary between your domain and the outside world. It is the layer that prevents a null customer ID from propagating through three service calls before finally detonating as a NullReferenceException in a repository method at 2 AM. It is the layer that prevents a negative order amount from being persisted to the database and discovered six weeks later during an audit. It is the layer that prevents an undefined enum value from being serialized to JSON and sent to a downstream system that does not know what to do with it.
The problem is not that validation is unnecessary. The problem is that validation code, as typically written in C#, is verbose, inconsistent, and scattered. Every class writes its own null checks. Every method writes its own range checks. Every constructor writes its own empty-string checks. And each developer writes them slightly differently: some throw ArgumentNullException, some throw ArgumentException, some throw InvalidOperationException, some return early, some set defaults, and some do not check at all.
FrenchExDev.Net.Guard solves this with a three-pronged API that covers every validation context you will encounter in a .NET application: exception-based guards for public API boundaries, Result-based guards for functional pipelines, and invariant assertions for internal state. Three classes. One static entry point. Fourteen built-in checks. And every method returns the validated value, so you never write a validation line that does not also assign.
The Problem: Validation Scattered Everywhere
Consider a typical aggregate constructor without a guard library:
public class Order
{
public string CustomerId { get; }
public string CustomerName { get; }
public decimal Amount { get; }
public int Quantity { get; }
public Guid CorrelationId { get; }
public OrderType Type { get; }
public Order(
string customerId,
string customerName,
decimal amount,
int quantity,
Guid correlationId,
OrderType type)
{
if (customerId is null)
throw new ArgumentNullException(nameof(customerId));
if (string.IsNullOrEmpty(customerId))
throw new ArgumentException(
"Value cannot be null or empty.", nameof(customerId));
if (customerName is null)
throw new ArgumentNullException(nameof(customerName));
if (string.IsNullOrWhiteSpace(customerName))
throw new ArgumentException(
"Value cannot be null or whitespace.", nameof(customerName));
if (amount < 0)
throw new ArgumentOutOfRangeException(
nameof(amount), amount, "Amount cannot be negative.");
if (quantity <= 0)
throw new ArgumentOutOfRangeException(
nameof(quantity), quantity, "Quantity must be positive.");
if (correlationId == Guid.Empty)
throw new ArgumentException(
"GUID cannot be empty.", nameof(correlationId));
if (!Enum.IsDefined(typeof(OrderType), type))
throw new ArgumentException(
$"Undefined enum value: {type}", nameof(type));
CustomerId = customerId;
CustomerName = customerName;
Amount = amount;
Quantity = quantity;
CorrelationId = correlationId;
Type = type;
}
}public class Order
{
public string CustomerId { get; }
public string CustomerName { get; }
public decimal Amount { get; }
public int Quantity { get; }
public Guid CorrelationId { get; }
public OrderType Type { get; }
public Order(
string customerId,
string customerName,
decimal amount,
int quantity,
Guid correlationId,
OrderType type)
{
if (customerId is null)
throw new ArgumentNullException(nameof(customerId));
if (string.IsNullOrEmpty(customerId))
throw new ArgumentException(
"Value cannot be null or empty.", nameof(customerId));
if (customerName is null)
throw new ArgumentNullException(nameof(customerName));
if (string.IsNullOrWhiteSpace(customerName))
throw new ArgumentException(
"Value cannot be null or whitespace.", nameof(customerName));
if (amount < 0)
throw new ArgumentOutOfRangeException(
nameof(amount), amount, "Amount cannot be negative.");
if (quantity <= 0)
throw new ArgumentOutOfRangeException(
nameof(quantity), quantity, "Quantity must be positive.");
if (correlationId == Guid.Empty)
throw new ArgumentException(
"GUID cannot be empty.", nameof(correlationId));
if (!Enum.IsDefined(typeof(OrderType), type))
throw new ArgumentException(
$"Undefined enum value: {type}", nameof(type));
CustomerId = customerId;
CustomerName = customerName;
Amount = amount;
Quantity = quantity;
CorrelationId = correlationId;
Type = type;
}
}That is 40 lines of constructor. Twenty-four of them are validation. Six of them are assignments. The signal-to-noise ratio is terrible. And this is a relatively simple aggregate -- a real domain object with ten or fifteen properties would have a constructor that spans an entire screen of nothing but if-throw blocks.
But the verbosity is not the worst part. The worst part is the inconsistency. Look at the six validation blocks above and notice the subtle differences:
Problem 1: Inconsistent exception types. The null check for customerId throws ArgumentNullException, but the empty check throws ArgumentException. Is that right? Maybe. Maybe not. Some developers throw ArgumentNullException for both. Some throw ArgumentException for both. Some throw InvalidOperationException for everything. The code compiles either way, and the tests (if they exist) rarely check the specific exception type.
Problem 2: Inconsistent messages. Each exception message is hand-written. One says "Value cannot be null or empty." Another says "Amount cannot be negative." A third says "GUID cannot be empty." The phrasing is different every time. In a large codebase with fifty aggregates, you will find fifty different wordings for the same kind of validation failure. This makes log searching and error categorization harder than it needs to be.
Problem 3: Forgotten parameter names. The nameof(customerId) trick is well-known, but it is also easy to forget. Copy-paste a validation block from one parameter to another, forget to update the nameof, and now your exception says "customerId cannot be negative" when the actual problem is with amount. This happens more often than anyone admits.
Problem 4: Validation and assignment are separated. The validation block checks customerId, and then ten lines later, CustomerId = customerId assigns it. If someone adds code between the check and the assignment -- a common occurrence during refactoring -- the validation might no longer protect the assignment. The temporal coupling between "validate" and "use" is invisible and fragile.
Problem 5: DRY violation. Every class writes the same null checks, the same empty-string checks, the same range checks. There is no reuse. There is no shared vocabulary. Each developer reinvents the same wheel in slightly different ways.
The Guard pattern eliminates all five problems. Here is the same constructor with FrenchExDev.Net.Guard:
public class Order
{
public string CustomerId { get; }
public string CustomerName { get; }
public decimal Amount { get; }
public int Quantity { get; }
public Guid CorrelationId { get; }
public OrderType Type { get; }
public Order(
string customerId,
string customerName,
decimal amount,
int quantity,
Guid correlationId,
OrderType type)
{
CustomerId = Guard.Against.NullOrEmpty(customerId);
CustomerName = Guard.Against.NullOrWhiteSpace(customerName);
Amount = Guard.Against.Negative(amount);
Quantity = Guard.Against.NegativeOrZero(quantity);
CorrelationId = Guard.Against.EmptyGuid(correlationId);
Type = Guard.Against.UndefinedEnum(type);
}
}public class Order
{
public string CustomerId { get; }
public string CustomerName { get; }
public decimal Amount { get; }
public int Quantity { get; }
public Guid CorrelationId { get; }
public OrderType Type { get; }
public Order(
string customerId,
string customerName,
decimal amount,
int quantity,
Guid correlationId,
OrderType type)
{
CustomerId = Guard.Against.NullOrEmpty(customerId);
CustomerName = Guard.Against.NullOrWhiteSpace(customerName);
Amount = Guard.Against.Negative(amount);
Quantity = Guard.Against.NegativeOrZero(quantity);
CorrelationId = Guard.Against.EmptyGuid(correlationId);
Type = Guard.Against.UndefinedEnum(type);
}
}Six lines. Six validations. Six assignments. Each line validates and assigns in a single expression. The exception types are consistent. The messages are consistent. The parameter names are captured automatically. And if a new developer reads this constructor, they understand the constraints immediately -- not by parsing twenty-four lines of if-throw blocks, but by reading six method names that say exactly what they check.
The Three Prongs
Most guard libraries provide a single API surface: check a condition, throw an exception. That works for public API boundaries where ArgumentNullException is the right response. But not every validation context is a public API boundary.
Consider these three scenarios:
Scenario 1: Public constructor. A caller passes a null customer ID to your Order constructor. The correct response is to throw ArgumentNullException immediately. The caller made a programming error, and the exception should identify the offending parameter, include a clear message, and propagate up the call stack to be caught (or not) by the caller. This is a fail-fast scenario.
Scenario 2: Functional pipeline. A mediator handler receives a command and needs to validate five properties before creating a domain object. If any validation fails, the handler should return Result<T>.Failure(error) -- not throw an exception. The caller is not making a programming error; they are submitting data that might be invalid, and the application should respond with a structured error, not an unhandled exception. This is a fail-gracefully scenario.
Scenario 3: Internal invariant. A method inside a domain aggregate needs to verify that an internal state condition holds before proceeding. This is not an argument validation -- the caller did not pass a bad parameter. This is an assertion that the aggregate's own state is consistent. If it fails, the correct exception is InvalidOperationException, not ArgumentException, because the problem is not with the input -- it is with the object's state.
Three scenarios. Three different exception types (or no exception at all). Three different caller expectations. A single Guard.Against API cannot serve all three without forcing awkward workarounds.
FrenchExDev.Net.Guard provides three classes, each purpose-built for one of these scenarios:
| Class | Purpose | On failure | Return type |
|---|---|---|---|
Guard.Against |
Public API boundary: constructor, method parameter | Throws ArgumentException family |
The validated value (T) |
Guard.ToResult |
Functional pipeline: mediator handler, service method | Returns Result<T>.Failure(error) |
Result<T> |
Guard.Ensure |
Internal invariant: postcondition, state assertion | Throws InvalidOperationException |
void (or T for NotNull) |
The entry point is a single static class with three properties:
public static class Guard
{
public static GuardAgainst Against { get; } = new();
public static GuardToResult ToResult { get; } = new();
public static GuardEnsure Ensure { get; } = new();
}public static class Guard
{
public static GuardAgainst Against { get; } = new();
public static GuardToResult ToResult { get; } = new();
public static GuardEnsure Ensure { get; } = new();
}Each property returns a sealed class with an internal constructor. You cannot instantiate GuardAgainst yourself. You cannot subclass it. You access it through Guard.Against, Guard.ToResult, or Guard.Ensure -- and the naming reads like English. Guard.Against.Null(value). Guard.ToResult.NullOrEmpty(name). Guard.Ensure.That(count > 0, "Count must be positive").
Decision Tree
The following diagram shows how to choose which prong to use:
The decision is straightforward. If you are validating a parameter that a caller passed to you, use Guard.Against. If you are validating inside a pipeline that returns Result<T>, use Guard.ToResult. If you are asserting your own internal state, use Guard.Ensure. There is no overlap, no ambiguity, and no scenario where two prongs are equally appropriate. The context determines the prong.
Guard.Against -- Exception-Based Guards
Guard.Against is the prong you will use most often. It covers constructors, public methods, and any boundary where throwing an exception is the appropriate response to invalid input.
The Fluent Entry Point
The first design decision is readability. Guard.Against.NullOrEmpty(customerId) reads like a sentence: "Guard against null-or-empty customerId." Compare this to the if-throw alternative:
// Before: imperative
if (string.IsNullOrEmpty(customerId))
throw new ArgumentException(
"Value cannot be null or empty.", nameof(customerId));
// After: declarative
Guard.Against.NullOrEmpty(customerId);// Before: imperative
if (string.IsNullOrEmpty(customerId))
throw new ArgumentException(
"Value cannot be null or empty.", nameof(customerId));
// After: declarative
Guard.Against.NullOrEmpty(customerId);The declarative version is shorter, but that is not the main advantage. The main advantage is that the intent is immediately visible. A developer scanning the constructor sees NullOrEmpty, NullOrWhiteSpace, Negative, OutOfRange -- the constraint names -- without having to parse the condition and the exception type and the message string. The code communicates what it validates, not how it validates.
CallerArgumentExpression
Every method on GuardAgainst has a signature like this:
public string NullOrEmpty(
string? value,
[CallerArgumentExpression(nameof(value))] string? paramName = null)public string NullOrEmpty(
string? value,
[CallerArgumentExpression(nameof(value))] string? paramName = null)The [CallerArgumentExpression] attribute, introduced in C# 10, captures the textual representation of the expression passed as the value argument. When you write:
Guard.Against.NullOrEmpty(customerId);Guard.Against.NullOrEmpty(customerId);The compiler automatically fills in paramName with "customerId" -- the literal text of the expression you passed. You do not write nameof(customerId). You do not pass a string. The compiler does it for you.
This means the generated exception includes the correct parameter name automatically:
// Calling code
Guard.Against.NullOrEmpty(order.CustomerId);
// Generated exception if order.CustomerId is empty:
// ArgumentException: Value cannot be null or empty. (Parameter 'order.CustomerId')// Calling code
Guard.Against.NullOrEmpty(order.CustomerId);
// Generated exception if order.CustomerId is empty:
// ArgumentException: Value cannot be null or empty. (Parameter 'order.CustomerId')Notice that the parameter name is "order.CustomerId" -- the full expression, not just "CustomerId". This is more useful than nameof in practice, because when you guard a property access or a method result, you get the complete expression in the exception message. Debugging becomes easier because you see exactly what was null or empty, not just a decontextualized parameter name.
The CallerArgumentExpression attribute eliminates the copy-paste bug entirely. There is no string to update when you refactor. There is no nameof to keep in sync with the parameter. The compiler guarantees that the exception identifies the correct expression.
The Return Pattern
This is the single most important design decision in the Guard library: every Guard.Against method returns the validated value.
public T Null<T>(T? value, ...) where T : class
// Returns: value (guaranteed non-null)
public string NullOrEmpty(string? value, ...)
// Returns: value (guaranteed non-null and non-empty)
public T OutOfRange<T>(T value, T min, T max, ...) where T : IComparable<T>
// Returns: value (guaranteed within [min, max])public T Null<T>(T? value, ...) where T : class
// Returns: value (guaranteed non-null)
public string NullOrEmpty(string? value, ...)
// Returns: value (guaranteed non-null and non-empty)
public T OutOfRange<T>(T value, T min, T max, ...) where T : IComparable<T>
// Returns: value (guaranteed within [min, max])Why does this matter? Because it enables inline assignment:
// Validation and assignment in one expression
CustomerId = Guard.Against.NullOrEmpty(customerId);// Validation and assignment in one expression
CustomerId = Guard.Against.NullOrEmpty(customerId);Without the return value, you would need two separate statements:
// Validation only -- still need to assign separately
Guard.Against.NullOrEmpty(customerId);
CustomerId = customerId;// Validation only -- still need to assign separately
Guard.Against.NullOrEmpty(customerId);
CustomerId = customerId;Two statements mean two places to maintain. Two statements mean the validation and the assignment can drift apart during refactoring. Two statements mean a developer can accidentally delete or comment out the validation line while keeping the assignment, or vice versa. The return pattern fuses validation and assignment into an atomic expression. You cannot assign without validating. You cannot validate without assigning. They are the same line of code.
This pattern also composes with object initializers, although the constructor pattern above is more common:
var config = new AppConfig
{
ConnectionString = Guard.Against.NullOrEmpty(connectionString),
MaxRetries = Guard.Against.NegativeOrZero(maxRetries),
Timeout = Guard.Against.OutOfRange(timeout, TimeSpan.Zero, TimeSpan.FromMinutes(5)),
};var config = new AppConfig
{
ConnectionString = Guard.Against.NullOrEmpty(connectionString),
MaxRetries = Guard.Against.NegativeOrZero(maxRetries),
Timeout = Guard.Against.OutOfRange(timeout, TimeSpan.Zero, TimeSpan.FromMinutes(5)),
};And with local variables in methods:
public void ProcessPayment(string accountId, decimal amount, string currency)
{
var validAccountId = Guard.Against.NullOrEmpty(accountId);
var validAmount = Guard.Against.Negative(amount);
var validCurrency = Guard.Against.NullOrWhiteSpace(currency);
// Use validAccountId, validAmount, validCurrency below
// The compiler knows they are non-null and within range
}public void ProcessPayment(string accountId, decimal amount, string currency)
{
var validAccountId = Guard.Against.NullOrEmpty(accountId);
var validAmount = Guard.Against.Negative(amount);
var validCurrency = Guard.Against.NullOrWhiteSpace(currency);
// Use validAccountId, validAmount, validCurrency below
// The compiler knows they are non-null and within range
}The return value also enables something subtle: the compiler narrows the type. When you write Guard.Against.Null(value) where value is T? (nullable reference), the return type is T (non-nullable). The null check happens at runtime inside the guard, and the assignment to a T variable tells the compiler "this is no longer nullable." You get runtime validation and compile-time type narrowing in the same expression.
The Full Catalog
GuardAgainst provides fourteen methods. Each one targets a specific validation need, throws a specific exception type, and returns the validated value. Here is every method with its signature, behavior, and usage example.
1. Null
public T Null<T>(T? value, ...) where T : classpublic T Null<T>(T? value, ...) where T : classGuards against null references. Throws ArgumentNullException. Use for any reference-type parameter that must not be null.
var service = Guard.Against.Null(service);
var logger = Guard.Against.Null(logger);
var options = Guard.Against.Null(options);var service = Guard.Against.Null(service);
var logger = Guard.Against.Null(logger);
var options = Guard.Against.Null(options);The generic constraint where T : class ensures this method is used only for reference types. For nullable value types (int?, DateTime?), use NullValue instead.
2. NullValue
public T NullValue<T>(T? value, ...) where T : structpublic T NullValue<T>(T? value, ...) where T : structGuards against null nullable value types. Throws ArgumentNullException. Returns the unwrapped value.
// input is int? -- output is int
int quantity = Guard.Against.NullValue(nullableQuantity);
DateTime date = Guard.Against.NullValue(nullableDate);// input is int? -- output is int
int quantity = Guard.Against.NullValue(nullableQuantity);
DateTime date = Guard.Against.NullValue(nullableDate);The return type is T, not T?. The guard both validates and unwraps. If nullableQuantity is null, it throws. If it has a value, it returns the unwrapped int. This eliminates the .Value access that would otherwise be needed.
3. NullOrEmpty (string)
public string NullOrEmpty(string? value, ...)public string NullOrEmpty(string? value, ...)Guards against null and empty strings. Throws ArgumentException. This is the most commonly used guard in practice, because string parameters that are null and string parameters that are empty are almost always equally invalid.
var name = Guard.Against.NullOrEmpty(name);
var email = Guard.Against.NullOrEmpty(email);
var connectionString = Guard.Against.NullOrEmpty(connectionString);var name = Guard.Against.NullOrEmpty(name);
var email = Guard.Against.NullOrEmpty(email);
var connectionString = Guard.Against.NullOrEmpty(connectionString);Note that this guard does not check for whitespace. A string consisting entirely of spaces passes this guard. If you need to reject whitespace-only strings, use NullOrWhiteSpace.
4. NullOrWhiteSpace
public string NullOrWhiteSpace(string? value, ...)public string NullOrWhiteSpace(string? value, ...)Guards against null, empty, and whitespace-only strings. Throws ArgumentException. This is the stricter sibling of NullOrEmpty.
var displayName = Guard.Against.NullOrWhiteSpace(displayName);
var description = Guard.Against.NullOrWhiteSpace(description);var displayName = Guard.Against.NullOrWhiteSpace(displayName);
var description = Guard.Against.NullOrWhiteSpace(description);Under the hood, this delegates to string.IsNullOrWhiteSpace, which checks for null, empty (""), and strings containing only whitespace characters (spaces, tabs, newlines, etc.). Use this guard when a string that "looks empty" is as invalid as one that is literally empty.
5. NullOrEmpty (collection)
public IReadOnlyList<T> NullOrEmpty<T>(IReadOnlyList<T>? value, ...)public IReadOnlyList<T> NullOrEmpty<T>(IReadOnlyList<T>? value, ...)Guards against null and empty collections. Throws ArgumentException. The parameter type is IReadOnlyList<T>, which covers List<T>, T[], ImmutableList<T>, and any other type that implements the interface.
var items = Guard.Against.NullOrEmpty(lineItems);
var recipients = Guard.Against.NullOrEmpty(emailRecipients);var items = Guard.Against.NullOrEmpty(lineItems);
var recipients = Guard.Against.NullOrEmpty(emailRecipients);This guard ensures that the collection is both non-null and contains at least one element. It does not validate the elements themselves -- if you need to ensure that no element is null, combine with LINQ:
var items = Guard.Against.NullOrEmpty(lineItems);
// Then validate individual elements if needed
foreach (var item in items)
{
Guard.Against.Null(item);
}var items = Guard.Against.NullOrEmpty(lineItems);
// Then validate individual elements if needed
foreach (var item in items)
{
Guard.Against.Null(item);
}6. OutOfRange
public T OutOfRange<T>(T value, T min, T max, ...) where T : IComparable<T>public T OutOfRange<T>(T value, T min, T max, ...) where T : IComparable<T>Guards against values outside a specified range. Throws ArgumentOutOfRangeException. The range is inclusive on both ends: [min, max].
var percentage = Guard.Against.OutOfRange(percentage, 0m, 100m);
var month = Guard.Against.OutOfRange(month, 1, 12);
var temperature = Guard.Against.OutOfRange(temp, -273.15, 1_000_000.0);var percentage = Guard.Against.OutOfRange(percentage, 0m, 100m);
var month = Guard.Against.OutOfRange(month, 1, 12);
var temperature = Guard.Against.OutOfRange(temp, -273.15, 1_000_000.0);The IComparable<T> constraint means this works with int, long, decimal, double, DateTime, TimeSpan, DateOnly, and any custom type that implements the interface. The exception message includes the value, the min, and the max, so the caller knows exactly what went wrong.
7. Negative
public T Negative<T>(T value, ...) where T : IComparable<T>public T Negative<T>(T value, ...) where T : IComparable<T>Guards against negative values. Throws ArgumentOutOfRangeException. Allows zero.
var balance = Guard.Against.Negative(balance); // 0 is OK
var weight = Guard.Against.Negative(weight); // 0 is OK
var discount = Guard.Against.Negative(discount); // 0 is OKvar balance = Guard.Against.Negative(balance); // 0 is OK
var weight = Guard.Against.Negative(weight); // 0 is OK
var discount = Guard.Against.Negative(discount); // 0 is OKUse this when zero is a valid value but negative is not. For example, a bank account balance can be zero (empty account) but should not be negative (unless overdraft is explicitly modeled). A discount can be zero (no discount) but should not be negative (that would be a surcharge, which is a different concept).
8. Zero
public T Zero<T>(T value, ...) where T : IComparable<T>public T Zero<T>(T value, ...) where T : IComparable<T>Guards against zero values. Throws ArgumentOutOfRangeException. Allows negative values.
var divisor = Guard.Against.Zero(divisor);
var scaleFactor = Guard.Against.Zero(scaleFactor);var divisor = Guard.Against.Zero(divisor);
var scaleFactor = Guard.Against.Zero(scaleFactor);This guard is less common than Negative or NegativeOrZero, but it has a clear use case: preventing division by zero and guarding against scale factors or multipliers that would collapse a calculation to zero.
9. NegativeOrZero
public T NegativeOrZero<T>(T value, ...) where T : IComparable<T>public T NegativeOrZero<T>(T value, ...) where T : IComparable<T>Guards against values that are negative or zero. Throws ArgumentOutOfRangeException. The value must be strictly positive.
var quantity = Guard.Against.NegativeOrZero(quantity); // must be >= 1
var pageSize = Guard.Against.NegativeOrZero(pageSize); // must be >= 1
var retryCount = Guard.Against.NegativeOrZero(retryCount);var quantity = Guard.Against.NegativeOrZero(quantity); // must be >= 1
var pageSize = Guard.Against.NegativeOrZero(pageSize); // must be >= 1
var retryCount = Guard.Against.NegativeOrZero(retryCount);This is the most commonly used numeric guard for counts, quantities, and sizes -- things that must be at least one. "Zero items ordered" is as invalid as "negative three items ordered."
10. Default
public T Default<T>(T value, ...) where T : structpublic T Default<T>(T value, ...) where T : structGuards against the default value of a value type. Throws ArgumentException. This works for any struct: int (default 0), DateTime (default 0001-01-01), Guid (default 00000000-...), custom structs (default all-zeros).
var timestamp = Guard.Against.Default(timestamp); // rejects DateTime.MinValue
var priority = Guard.Against.Default(priority); // rejects 0var timestamp = Guard.Against.Default(timestamp); // rejects DateTime.MinValue
var priority = Guard.Against.Default(priority); // rejects 0Use this when any non-default value is acceptable but the default is suspicious. For DateTime, the default is almost never a meaningful date. For int, the default zero might indicate an uninitialized variable rather than an intentional value.
Note that Default and EmptyGuid overlap for Guid -- both reject Guid.Empty. Use EmptyGuid when the parameter is specifically a GUID (for better readability), and Default when writing generic code that works across multiple struct types.
11. InvalidInput
public T InvalidInput<T>(T value, Func<T, bool> predicate, string message, ...)public T InvalidInput<T>(T value, Func<T, bool> predicate, string message, ...)Guards against values that fail a custom predicate. Throws ArgumentException with the provided message. This is the escape hatch for validations that the built-in methods do not cover.
var email = Guard.Against.InvalidInput(
email,
e => e.Contains('@'),
"Email must contain '@'");
var password = Guard.Against.InvalidInput(
password,
p => p.Length >= 8,
"Password must be at least 8 characters");
var date = Guard.Against.InvalidInput(
date,
d => d > DateTime.UtcNow,
"Date must be in the future");var email = Guard.Against.InvalidInput(
email,
e => e.Contains('@'),
"Email must contain '@'");
var password = Guard.Against.InvalidInput(
password,
p => p.Length >= 8,
"Password must be at least 8 characters");
var date = Guard.Against.InvalidInput(
date,
d => d > DateTime.UtcNow,
"Date must be in the future");The predicate receives the value and returns true if the value is valid, false if it is invalid. When the predicate returns false, the guard throws with the provided message. The value is still returned on success, so inline assignment works:
Email = Guard.Against.InvalidInput(email, e => e.Contains('@'), "Invalid email");Email = Guard.Against.InvalidInput(email, e => e.Contains('@'), "Invalid email");Use InvalidInput sparingly. If you find yourself writing the same predicate in multiple places, consider whether the validation belongs in a value object instead. A EmailAddress value object that validates on construction is better than scattered InvalidInput calls with the same lambda.
12. LengthExceeded
public string LengthExceeded(string value, int maxLength, ...)public string LengthExceeded(string value, int maxLength, ...)Guards against strings that exceed a maximum length. Throws ArgumentException. The check is value.Length > maxLength.
var name = Guard.Against.LengthExceeded(name, 200);
var slug = Guard.Against.LengthExceeded(slug, 50);
var comment = Guard.Against.LengthExceeded(comment, 4000);var name = Guard.Against.LengthExceeded(name, 200);
var slug = Guard.Against.LengthExceeded(slug, 50);
var comment = Guard.Against.LengthExceeded(comment, 4000);This guard is particularly useful when the string will be persisted to a database column with a length constraint. Rather than discovering the truncation error at the database layer (which produces an opaque DbUpdateException), you catch it at the domain layer with a clear message that includes the parameter name, the actual length, and the maximum allowed length.
Note that LengthExceeded does not check for null. If the string might be null, chain it with NullOrEmpty:
var name = Guard.Against.NullOrEmpty(name);
name = Guard.Against.LengthExceeded(name, 200);var name = Guard.Against.NullOrEmpty(name);
name = Guard.Against.LengthExceeded(name, 200);Or combine them using InvalidInput:
var name = Guard.Against.NullOrEmpty(name);
Name = Guard.Against.LengthExceeded(name, 200);var name = Guard.Against.NullOrEmpty(name);
Name = Guard.Against.LengthExceeded(name, 200);13. EmptyGuid
public Guid EmptyGuid(Guid value, ...)public Guid EmptyGuid(Guid value, ...)Guards against Guid.Empty (00000000-0000-0000-0000-000000000000). Throws ArgumentException.
var orderId = Guard.Against.EmptyGuid(orderId);
var correlationId = Guard.Against.EmptyGuid(correlationId);
var tenantId = Guard.Against.EmptyGuid(tenantId);var orderId = Guard.Against.EmptyGuid(orderId);
var correlationId = Guard.Against.EmptyGuid(correlationId);
var tenantId = Guard.Against.EmptyGuid(tenantId);Guid.Empty is the null of the GUID world. It is the default value of the Guid struct, and it almost always indicates an uninitialized or forgotten identifier. In practice, no real entity should have an ID of all zeros. This guard catches that mistake at the boundary rather than letting it propagate into the database or the event store.
14. UndefinedEnum
public T UndefinedEnum<T>(T value, ...) where T : struct, Enumpublic T UndefinedEnum<T>(T value, ...) where T : struct, EnumGuards against enum values that are not defined in the enum type. Throws ArgumentException.
var status = Guard.Against.UndefinedEnum(status);
var priority = Guard.Against.UndefinedEnum(priority);
var direction = Guard.Against.UndefinedEnum(direction);var status = Guard.Against.UndefinedEnum(status);
var priority = Guard.Against.UndefinedEnum(priority);
var direction = Guard.Against.UndefinedEnum(direction);Enums in C# are integers in disguise. You can cast any int to any enum, and the compiler will not complain:
var status = (OrderStatus)999; // Compiles. No warning. No error.var status = (OrderStatus)999; // Compiles. No warning. No error.This guard calls Enum.IsDefined under the hood, which returns false for values that do not correspond to a named member of the enum. It catches the silent data corruption that occurs when an API receives an integer that does not map to a valid enum member.
Important caveat: Enum.IsDefined does not work correctly with [Flags] enums that use bitwise combinations. For example, FileAccess.Read | FileAccess.Write is a valid flags combination, but Enum.IsDefined returns false because the combined value is not a named member. If you need to validate flags enums, use InvalidInput with a custom predicate instead.
Class Diagram
The diagram shows the structural relationship between the four classes. Guard is the static entry point. GuardAgainst, GuardToResult, and GuardEnsure are sealed classes with internal constructors -- they cannot be instantiated or extended by consuming code. The separation is clean: GuardAgainst deals in exceptions and returns T, GuardToResult deals in results and returns Result<T>, and GuardEnsure deals in assertions and returns void (except NotNull, which returns T).
Guard.ToResult -- Functional Guards
Guard.Against throws exceptions. That is the right behavior at public API boundaries where invalid input represents a programming error. But inside a functional pipeline -- a mediator handler, a service method that returns Result<T>, a validation workflow -- exceptions are the wrong tool. Exceptions are for exceptional circumstances. An invalid email address in a form submission is not exceptional; it is expected, and the application should handle it gracefully.
Guard.ToResult provides the same validation checks as Guard.Against, but instead of throwing, it returns Result<T>. A passing check returns Result<T>.Success(value). A failing check returns Result<T>.Failure(error). The calling code can then compose these results using Bind, Map, and the other Result<T> pipeline methods.
Side-by-Side Comparison
Here is the same set of validations expressed with both prongs:
// Guard.Against: throws on failure, returns T on success
public Order(string customerId, decimal amount)
{
CustomerId = Guard.Against.NullOrEmpty(customerId);
Amount = Guard.Against.Negative(amount);
}
// Guard.ToResult: returns Result<T> on both success and failure
public static Result<Order> Create(string? customerId, decimal amount)
{
return Guard.ToResult.NullOrEmpty(customerId)
.Bind(validId => Guard.ToResult.Negative(amount)
.Map(validAmount => new Order(validId, validAmount)));
}// Guard.Against: throws on failure, returns T on success
public Order(string customerId, decimal amount)
{
CustomerId = Guard.Against.NullOrEmpty(customerId);
Amount = Guard.Against.Negative(amount);
}
// Guard.ToResult: returns Result<T> on both success and failure
public static Result<Order> Create(string? customerId, decimal amount)
{
return Guard.ToResult.NullOrEmpty(customerId)
.Bind(validId => Guard.ToResult.Negative(amount)
.Map(validAmount => new Order(validId, validAmount)));
}The Guard.Against version is for constructors that should never receive invalid input -- the caller is responsible for providing valid data, and an exception means a bug in the caller. The Guard.ToResult version is for factory methods or service methods that might receive invalid input from user input, external systems, or deserialized data -- invalid input is a normal case, not an exceptional one.
The Full Catalog
GuardToResult provides ten methods. Each mirrors a GuardAgainst method but returns Result<T> instead of throwing:
1. Null
public Result<T> Null<T>(T? value, string? errorMessage = null) where T : classpublic Result<T> Null<T>(T? value, string? errorMessage = null) where T : classResult<User> result = Guard.ToResult.Null(user);
Result<User> result = Guard.ToResult.Null(user, "User is required");Result<User> result = Guard.ToResult.Null(user);
Result<User> result = Guard.ToResult.Null(user, "User is required");2. NullValue
public Result<T> NullValue<T>(T? value, string? errorMessage = null) where T : structpublic Result<T> NullValue<T>(T? value, string? errorMessage = null) where T : structResult<int> result = Guard.ToResult.NullValue(nullableAge);
Result<DateTime> result = Guard.ToResult.NullValue(nullableDate, "Date is required");Result<int> result = Guard.ToResult.NullValue(nullableAge);
Result<DateTime> result = Guard.ToResult.NullValue(nullableDate, "Date is required");3. NullOrEmpty
public Result<string> NullOrEmpty(string? value, string? errorMessage = null)public Result<string> NullOrEmpty(string? value, string? errorMessage = null)Result<string> result = Guard.ToResult.NullOrEmpty(name);
Result<string> result = Guard.ToResult.NullOrEmpty(name, "Name cannot be empty");Result<string> result = Guard.ToResult.NullOrEmpty(name);
Result<string> result = Guard.ToResult.NullOrEmpty(name, "Name cannot be empty");4. NullOrWhiteSpace
public Result<string> NullOrWhiteSpace(string? value, string? errorMessage = null)public Result<string> NullOrWhiteSpace(string? value, string? errorMessage = null)Result<string> result = Guard.ToResult.NullOrWhiteSpace(description);Result<string> result = Guard.ToResult.NullOrWhiteSpace(description);5. OutOfRange
public Result<T> OutOfRange<T>(T value, T min, T max, string? errorMessage = null)
where T : IComparable<T>public Result<T> OutOfRange<T>(T value, T min, T max, string? errorMessage = null)
where T : IComparable<T>Result<decimal> result = Guard.ToResult.OutOfRange(amount, 0m, 10_000m);
Result<int> result = Guard.ToResult.OutOfRange(month, 1, 12, "Invalid month");Result<decimal> result = Guard.ToResult.OutOfRange(amount, 0m, 10_000m);
Result<int> result = Guard.ToResult.OutOfRange(month, 1, 12, "Invalid month");6. Negative
public Result<T> Negative<T>(T value, string? errorMessage = null) where T : IComparable<T>public Result<T> Negative<T>(T value, string? errorMessage = null) where T : IComparable<T>Result<decimal> result = Guard.ToResult.Negative(balance);Result<decimal> result = Guard.ToResult.Negative(balance);7. NegativeOrZero
public Result<T> NegativeOrZero<T>(T value, string? errorMessage = null) where T : IComparable<T>public Result<T> NegativeOrZero<T>(T value, string? errorMessage = null) where T : IComparable<T>Result<int> result = Guard.ToResult.NegativeOrZero(quantity);Result<int> result = Guard.ToResult.NegativeOrZero(quantity);8. InvalidInput
public Result<T> InvalidInput<T>(T value, Func<T, bool> predicate, string errorMessage)
where T : notnullpublic Result<T> InvalidInput<T>(T value, Func<T, bool> predicate, string errorMessage)
where T : notnullResult<string> result = Guard.ToResult.InvalidInput(
email,
e => e.Contains('@'),
"Email must contain '@'");Result<string> result = Guard.ToResult.InvalidInput(
email,
e => e.Contains('@'),
"Email must contain '@'");9. Default
public Result<T> Default<T>(T value, string? errorMessage = null) where T : structpublic Result<T> Default<T>(T value, string? errorMessage = null) where T : structResult<DateTime> result = Guard.ToResult.Default(timestamp);Result<DateTime> result = Guard.ToResult.Default(timestamp);10. EmptyGuid
public Result<Guid> EmptyGuid(Guid value, string? errorMessage = null)public Result<Guid> EmptyGuid(Guid value, string? errorMessage = null)Result<Guid> result = Guard.ToResult.EmptyGuid(orderId);Result<Guid> result = Guard.ToResult.EmptyGuid(orderId);Notice that GuardToResult has ten methods to GuardAgainst's fourteen. Four guards are absent: Zero, NullOrEmpty<T> (collection), LengthExceeded, and UndefinedEnum. These can be expressed using InvalidInput with a predicate when needed inside a Result pipeline. The choice to omit them was deliberate -- they are less commonly needed in functional pipelines, and keeping the API surface smaller makes it easier to discover and remember.
Error Messages
Every GuardToResult method accepts an optional errorMessage parameter. When omitted, a sensible default is used:
// Default message
Guard.ToResult.NullOrEmpty(null);
// Result.Failure("Value cannot be null or empty.")
// Custom message
Guard.ToResult.NullOrEmpty(null, "Customer name is required.");
// Result.Failure("Customer name is required.")// Default message
Guard.ToResult.NullOrEmpty(null);
// Result.Failure("Value cannot be null or empty.")
// Custom message
Guard.ToResult.NullOrEmpty(null, "Customer name is required.");
// Result.Failure("Customer name is required.")Custom messages are important in functional pipelines because the error message is what the user sees. In a Guard.Against call, the exception message is for developers (it appears in logs and stack traces). In a Guard.ToResult call, the error message is for users (it appears in API responses and form validation messages). The message should describe the problem in domain language, not implementation language.
Pipeline Integration
The real power of Guard.ToResult emerges when you chain multiple guards using Result<T>.Bind:
public Result<CreateOrderResponse> Handle(CreateOrderCommand command)
{
return Guard.ToResult.NullOrEmpty(command.CustomerId, "Customer ID is required")
.Bind(customerId =>
Guard.ToResult.NullOrWhiteSpace(command.CustomerName, "Customer name is required")
.Map(customerName => (customerId, customerName)))
.Bind(tuple =>
Guard.ToResult.Negative(command.Amount, "Amount cannot be negative")
.Map(amount => (tuple.customerId, tuple.customerName, amount)))
.Bind(tuple =>
Guard.ToResult.NegativeOrZero(command.Quantity, "Quantity must be positive")
.Map(quantity => new Order(
tuple.customerId,
tuple.customerName,
tuple.amount,
quantity)))
.Map(order =>
{
_repository.Save(order);
return new CreateOrderResponse(order.Id);
});
}public Result<CreateOrderResponse> Handle(CreateOrderCommand command)
{
return Guard.ToResult.NullOrEmpty(command.CustomerId, "Customer ID is required")
.Bind(customerId =>
Guard.ToResult.NullOrWhiteSpace(command.CustomerName, "Customer name is required")
.Map(customerName => (customerId, customerName)))
.Bind(tuple =>
Guard.ToResult.Negative(command.Amount, "Amount cannot be negative")
.Map(amount => (tuple.customerId, tuple.customerName, amount)))
.Bind(tuple =>
Guard.ToResult.NegativeOrZero(command.Quantity, "Quantity must be positive")
.Map(quantity => new Order(
tuple.customerId,
tuple.customerName,
tuple.amount,
quantity)))
.Map(order =>
{
_repository.Save(order);
return new CreateOrderResponse(order.Id);
});
}If any guard fails, the entire pipeline short-circuits. The first failure becomes the result, and no subsequent guards or maps execute. This is the same short-circuit behavior as Option<T>.Bind -- monadic composition at work.
The pipeline reads top-to-bottom: validate customer ID, then customer name, then amount, then quantity, then create the order, then save it. Each step depends on the previous step succeeding. If the customer ID is empty, the pipeline stops immediately. The customer name is never checked. The order is never created. The repository is never called. The result is Result<CreateOrderResponse>.Failure("Customer ID is required").
Sequence Diagram
The following diagram illustrates the happy path of a Result-based guard pipeline:
The short-circuit behavior means that error messages are always precise. You do not get a list of five errors because all five guards ran against an object where only the first property was invalid. You get the first error, which is typically enough for the caller to fix the problem and resubmit.
If you need all errors at once (for form validation where you want to display all errors simultaneously), Guard.ToResult is not the right tool. Use a dedicated validation library like FluentValidation, or build a validation pipeline that collects all errors into a list. Guard.ToResult is designed for sequential validation where the first failure stops processing -- the same semantics as early return in imperative code.
Guard.Ensure -- Invariant Assertions
The third prong is the least commonly used but the most important when you need it. Guard.Ensure asserts invariants -- conditions about internal state that should always be true if the code is correct. It is the equivalent of Debug.Assert, but with two differences: it throws in release mode (not just debug mode), and it throws InvalidOperationException (not an assertion failure).
That
public void That(bool condition, string message)public void That(bool condition, string message)The simplest and most versatile method. Assert that a condition is true. If it is false, throw InvalidOperationException with the provided message.
Guard.Ensure.That(items.Count > 0, "Cannot calculate average of empty collection");
Guard.Ensure.That(state == State.Initialized, "Service must be initialized before use");
Guard.Ensure.That(remainingCapacity >= 0, "Capacity underflow detected");Guard.Ensure.That(items.Count > 0, "Cannot calculate average of empty collection");
Guard.Ensure.That(state == State.Initialized, "Service must be initialized before use");
Guard.Ensure.That(remainingCapacity >= 0, "Capacity underflow detected");Use Guard.Ensure.That for postconditions -- conditions that should be true after a method has done its work. If the postcondition fails, the method has a bug. The exception is not for the caller; it is for the developer who maintains the method.
public void Withdraw(decimal amount)
{
Guard.Against.Negative(amount); // precondition: input validation
Guard.Against.OutOfRange(amount, 0m, Balance); // precondition: sufficient funds
Balance -= amount;
Guard.Ensure.That(Balance >= 0, "Balance underflow after withdrawal");
// ^ postcondition: if this fires, the method has a bug
}public void Withdraw(decimal amount)
{
Guard.Against.Negative(amount); // precondition: input validation
Guard.Against.OutOfRange(amount, 0m, Balance); // precondition: sufficient funds
Balance -= amount;
Guard.Ensure.That(Balance >= 0, "Balance underflow after withdrawal");
// ^ postcondition: if this fires, the method has a bug
}Notice the distinction. The Guard.Against calls validate the caller's input -- they throw ArgumentException family exceptions, which say "you gave me bad data." The Guard.Ensure.That call validates the method's own correctness -- it throws InvalidOperationException, which says "something went wrong inside this method." The exception type communicates who is at fault.
That (lazy message)
public void That(bool condition, Func<string> messageFactory)public void That(bool condition, Func<string> messageFactory)The same as That(bool, string), but the message is produced by a factory function that is only invoked when the condition is false. Use this when constructing the message is expensive:
Guard.Ensure.That(
index < items.Count,
() => $"Index {index} out of range for collection with {items.Count} items. " +
$"Collection contents: [{string.Join(", ", items)}]");Guard.Ensure.That(
index < items.Count,
() => $"Index {index} out of range for collection with {items.Count} items. " +
$"Collection contents: [{string.Join(", ", items)}]");If the condition is true (the common case), the message factory never runs. The string.Join and the interpolation are not computed. This is a micro-optimization, but in hot paths where the condition almost always passes, it avoids unnecessary string allocations.
NotNull
public T NotNull<T>(T? value, string message) where T : classpublic T NotNull<T>(T? value, string message) where T : classAssert that an internal value is not null. Returns the value if non-null. Throws InvalidOperationException if null.
var connection = Guard.Ensure.NotNull(_connection, "Connection not initialized");
var currentUser = Guard.Ensure.NotNull(_currentUser, "No user in context");var connection = Guard.Ensure.NotNull(_connection, "Connection not initialized");
var currentUser = Guard.Ensure.NotNull(_currentUser, "No user in context");This is the Ensure counterpart to Guard.Against.Null. The difference is in intent and exception type. Guard.Against.Null is for parameters -- "you passed me null, and that is your bug." Guard.Ensure.NotNull is for internal state -- "this field should not be null at this point, and if it is, there is a bug in this class."
The distinction matters for error handling. Callers can catch ArgumentException and know they passed bad data. They should not catch InvalidOperationException from an Ensure -- that means the service itself is broken, and retrying with the same input will not help.
NotAlreadyDone
public void NotAlreadyDone(bool alreadyDone, string operationName)public void NotAlreadyDone(bool alreadyDone, string operationName)Assert that an operation has not already been performed. Throws InvalidOperationException with a message like "Operation '{operationName}' has already been performed."
public class PaymentProcessor
{
private bool _charged;
public void Charge(decimal amount)
{
Guard.Ensure.NotAlreadyDone(_charged, "Charge");
// ... process payment ...
_charged = true;
}
}public class PaymentProcessor
{
private bool _charged;
public void Charge(decimal amount)
{
Guard.Ensure.NotAlreadyDone(_charged, "Charge");
// ... process payment ...
_charged = true;
}
}This guard is for operations that must happen exactly once. A payment should not be charged twice. An event should not be published twice. A resource should not be disposed twice. The NotAlreadyDone guard codifies this constraint in a readable, self-documenting way.
The implementation is trivial -- it checks a boolean and throws if true. But the semantic naming matters. When you see Guard.Ensure.NotAlreadyDone(_published, "PublishEvent"), you immediately understand the invariant: this operation is single-use. When you see Guard.Ensure.That(!_published, "Event already published"), the invariant is the same but the intent is less obvious.
When to Use Ensure vs Against
The choice between Guard.Against and Guard.Ensure is not about the type of check -- both can check for null, both can check conditions. The choice is about the source of the error:
| Question | Guard.Against | Guard.Ensure |
|---|---|---|
| Who made the mistake? | The caller | This class |
| What kind of error? | Bad input | Bad state |
| What exception? | ArgumentException family |
InvalidOperationException |
| Where in the method? | Top (preconditions) | Middle/bottom (invariants, postconditions) |
| Can the caller fix it? | Yes (pass valid input) | No (the class has a bug) |
A useful heuristic: if the check is at the top of a method and validates a parameter, use Guard.Against. If the check is anywhere else and validates a field, a local variable, or a computed value, use Guard.Ensure.
The CallerArgumentExpression Polyfill
FrenchExDev.Net.Guard targets netstandard2.0 for maximum compatibility. This means it can be used in .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+, Unity, Xamarin, and any other runtime that supports .NET Standard 2.0.
The [CallerArgumentExpression] attribute was introduced in .NET 6 (more precisely, in the System.Runtime.CompilerServices namespace of the .NET 6 BCL). It does not exist in netstandard2.0. However, the C# compiler recognizes the attribute by name, not by assembly -- so if the attribute type exists in your compilation, the compiler will use it regardless of the target framework.
The Guard library includes a polyfill:
#if NETSTANDARD2_0
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Parameter)]
internal sealed class CallerArgumentExpressionAttribute : Attribute
{
public CallerArgumentExpressionAttribute(string parameterName)
=> ParameterName = parameterName;
public string ParameterName { get; }
}
}
#endif#if NETSTANDARD2_0
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Parameter)]
internal sealed class CallerArgumentExpressionAttribute : Attribute
{
public CallerArgumentExpressionAttribute(string parameterName)
=> ParameterName = parameterName;
public string ParameterName { get; }
}
}
#endifThis polyfill is conditionally compiled: it exists only when targeting netstandard2.0. When targeting .NET 6 or later, the real attribute from the BCL is used. The polyfill is internal, so it does not conflict with the BCL type in consuming projects that target .NET 6+.
The result is that Guard.Against.NullOrEmpty(customerId) captures "customerId" as the parameter name on every supported target framework, including .NET Framework 4.8 and .NET Core 3.1. The consuming project needs to compile with C# 10 or later for the compiler to recognize the attribute, but the runtime can be anything that supports netstandard2.0.
This is the same polyfill technique used by other FrenchExDev packages and by the wider .NET ecosystem (the IsExternalInit polyfill for init properties, the RequiredMemberAttribute polyfill for required members, etc.). It demonstrates a broader principle: the C# compiler's features and the .NET runtime's features are decoupled. You can use C# 12 language features while targeting a runtime from 2017, as long as the compiler can find the attribute types it needs.
Real-World Example: Aggregate Constructor
Here is a complete domain aggregate that uses all three Guard prongs. The aggregate represents a Subscription in a SaaS application:
public sealed class Subscription
{
public Guid Id { get; }
public string TenantId { get; }
public string PlanName { get; }
public decimal MonthlyPrice { get; }
public int MaxUsers { get; }
public DateOnly StartDate { get; }
public DateOnly? EndDate { get; private set; }
public SubscriptionStatus Status { get; private set; }
private bool _cancelled;
// Guard.Against in the constructor: validate external input
public Subscription(
Guid id,
string tenantId,
string planName,
decimal monthlyPrice,
int maxUsers,
DateOnly startDate)
{
Id = Guard.Against.EmptyGuid(id);
TenantId = Guard.Against.NullOrEmpty(tenantId);
PlanName = Guard.Against.NullOrWhiteSpace(planName);
MonthlyPrice = Guard.Against.Negative(monthlyPrice);
MaxUsers = Guard.Against.NegativeOrZero(maxUsers);
StartDate = Guard.Against.Default(startDate);
Status = SubscriptionStatus.Active;
}
// Guard.Against for method parameters + Guard.Ensure for internal state
public void ChangeMaxUsers(int newMaxUsers)
{
Guard.Against.NegativeOrZero(newMaxUsers);
Guard.Ensure.That(
Status == SubscriptionStatus.Active,
"Cannot change max users on an inactive subscription");
MaxUsers = newMaxUsers;
}
// Guard.Ensure for single-use operation
public void Cancel(DateOnly cancellationDate)
{
Guard.Ensure.NotAlreadyDone(_cancelled, "Cancel");
Guard.Ensure.That(
Status == SubscriptionStatus.Active,
"Cannot cancel an inactive subscription");
Guard.Against.Default(cancellationDate);
EndDate = cancellationDate;
Status = SubscriptionStatus.Cancelled;
_cancelled = true;
Guard.Ensure.That(
EndDate >= StartDate,
"Cancellation date cannot precede start date");
}
// Guard.ToResult for factory method
public static Result<Subscription> Create(
string? tenantId,
string? planName,
decimal monthlyPrice,
int maxUsers,
DateOnly startDate)
{
return Guard.ToResult.NullOrEmpty(tenantId, "Tenant ID is required")
.Bind(tid =>
Guard.ToResult.NullOrWhiteSpace(planName, "Plan name is required")
.Map(pn => (tid, pn)))
.Bind(tuple =>
Guard.ToResult.Negative(monthlyPrice, "Price cannot be negative")
.Map(price => (tuple.tid, tuple.pn, price)))
.Bind(tuple =>
Guard.ToResult.NegativeOrZero(maxUsers, "Max users must be positive")
.Map(mu => new Subscription(
Guid.NewGuid(),
tuple.tid,
tuple.pn,
tuple.price,
mu,
startDate)));
}
}public sealed class Subscription
{
public Guid Id { get; }
public string TenantId { get; }
public string PlanName { get; }
public decimal MonthlyPrice { get; }
public int MaxUsers { get; }
public DateOnly StartDate { get; }
public DateOnly? EndDate { get; private set; }
public SubscriptionStatus Status { get; private set; }
private bool _cancelled;
// Guard.Against in the constructor: validate external input
public Subscription(
Guid id,
string tenantId,
string planName,
decimal monthlyPrice,
int maxUsers,
DateOnly startDate)
{
Id = Guard.Against.EmptyGuid(id);
TenantId = Guard.Against.NullOrEmpty(tenantId);
PlanName = Guard.Against.NullOrWhiteSpace(planName);
MonthlyPrice = Guard.Against.Negative(monthlyPrice);
MaxUsers = Guard.Against.NegativeOrZero(maxUsers);
StartDate = Guard.Against.Default(startDate);
Status = SubscriptionStatus.Active;
}
// Guard.Against for method parameters + Guard.Ensure for internal state
public void ChangeMaxUsers(int newMaxUsers)
{
Guard.Against.NegativeOrZero(newMaxUsers);
Guard.Ensure.That(
Status == SubscriptionStatus.Active,
"Cannot change max users on an inactive subscription");
MaxUsers = newMaxUsers;
}
// Guard.Ensure for single-use operation
public void Cancel(DateOnly cancellationDate)
{
Guard.Ensure.NotAlreadyDone(_cancelled, "Cancel");
Guard.Ensure.That(
Status == SubscriptionStatus.Active,
"Cannot cancel an inactive subscription");
Guard.Against.Default(cancellationDate);
EndDate = cancellationDate;
Status = SubscriptionStatus.Cancelled;
_cancelled = true;
Guard.Ensure.That(
EndDate >= StartDate,
"Cancellation date cannot precede start date");
}
// Guard.ToResult for factory method
public static Result<Subscription> Create(
string? tenantId,
string? planName,
decimal monthlyPrice,
int maxUsers,
DateOnly startDate)
{
return Guard.ToResult.NullOrEmpty(tenantId, "Tenant ID is required")
.Bind(tid =>
Guard.ToResult.NullOrWhiteSpace(planName, "Plan name is required")
.Map(pn => (tid, pn)))
.Bind(tuple =>
Guard.ToResult.Negative(monthlyPrice, "Price cannot be negative")
.Map(price => (tuple.tid, tuple.pn, price)))
.Bind(tuple =>
Guard.ToResult.NegativeOrZero(maxUsers, "Max users must be positive")
.Map(mu => new Subscription(
Guid.NewGuid(),
tuple.tid,
tuple.pn,
tuple.price,
mu,
startDate)));
}
}This example demonstrates the three prongs working together in a single class:
Constructor uses
Guard.Againstfor all parameters. The constructor is called by the factory method and by test code. If either passes invalid data, it is a programming error, and exceptions are appropriate.ChangeMaxUsers uses
Guard.Againstfor the parameter (newMaxUsersis external input) andGuard.Ensurefor the state check (Statusis internal state). The parameter validation says "you gave me bad data." The state validation says "this object is not in the right state for this operation."Cancel uses
Guard.Ensure.NotAlreadyDonefor the idempotency check (cancelling twice is a bug),Guard.Ensure.Thatfor the status check (cancelling an inactive subscription is a bug),Guard.Againstfor the cancellation date parameter (it is external input), andGuard.Ensure.Thatat the end for the postcondition (the cancellation date should not precede the start date after the operation completes).Create uses
Guard.ToResultbecause it is a factory method that might receive invalid input from user-facing code. Instead of throwing, it returnsResult<Subscription>, which the calling code can handle gracefully.
Notice the separation of concerns: the constructor assumes valid data (it throws if the assumption is wrong), and the factory method does not assume valid data (it validates and returns a Result). This is a common pattern in DDD: the constructor is the private enforcement boundary, and the factory method is the public validation boundary.
Real-World Example: Mediator Handler Validation
Here is a complete mediator command handler that uses Guard.ToResult to validate a command before processing it. This pattern is common in CQRS architectures where commands arrive from API controllers and may contain invalid data:
public sealed record CreateInvoiceCommand(
string? CustomerId,
string? CustomerEmail,
string? Description,
decimal Amount,
int LineItemCount,
Guid? CorrelationId) : ICommand<CreateInvoiceResponse>;
public sealed record CreateInvoiceResponse(Guid InvoiceId, string InvoiceNumber);
public sealed class CreateInvoiceHandler
: ICommandHandler<CreateInvoiceCommand, CreateInvoiceResponse>
{
private readonly IInvoiceRepository _repository;
private readonly IInvoiceNumberGenerator _numberGenerator;
public CreateInvoiceHandler(
IInvoiceRepository repository,
IInvoiceNumberGenerator numberGenerator)
{
_repository = Guard.Against.Null(repository);
_numberGenerator = Guard.Against.Null(numberGenerator);
}
public async Task<Result<CreateInvoiceResponse>> Handle(
CreateInvoiceCommand command,
CancellationToken cancellationToken)
{
return await ValidateCommand(command)
.BindAsync(validated => CreateInvoice(validated, cancellationToken));
}
private static Result<ValidatedInvoiceData> ValidateCommand(
CreateInvoiceCommand command)
{
return Guard.ToResult.NullOrEmpty(
command.CustomerId,
"Customer ID is required")
.Bind(customerId =>
Guard.ToResult.InvalidInput(
command.CustomerEmail ?? "",
email => email.Contains('@') && email.Contains('.'),
"A valid email address is required")
.Map(email => (customerId, email)))
.Bind(tuple =>
Guard.ToResult.NullOrWhiteSpace(
command.Description,
"Description cannot be empty")
.Map(desc => (tuple.customerId, tuple.email, desc)))
.Bind(tuple =>
Guard.ToResult.Negative(
command.Amount,
"Invoice amount cannot be negative")
.Map(amount => (tuple.customerId, tuple.email, tuple.desc, amount)))
.Bind(tuple =>
Guard.ToResult.NegativeOrZero(
command.LineItemCount,
"Invoice must have at least one line item")
.Map(count => new ValidatedInvoiceData(
tuple.customerId,
tuple.email,
tuple.desc,
tuple.amount,
count,
command.CorrelationId ?? Guid.NewGuid())));
}
private async Task<Result<CreateInvoiceResponse>> CreateInvoice(
ValidatedInvoiceData data,
CancellationToken cancellationToken)
{
var invoiceNumber = await _numberGenerator.GenerateAsync(cancellationToken);
var invoice = new Invoice(
Guid.NewGuid(),
data.CustomerId,
data.CustomerEmail,
data.Description,
data.Amount,
data.LineItemCount,
invoiceNumber,
data.CorrelationId);
await _repository.SaveAsync(invoice, cancellationToken);
return Result<CreateInvoiceResponse>.Success(
new CreateInvoiceResponse(invoice.Id, invoiceNumber));
}
private sealed record ValidatedInvoiceData(
string CustomerId,
string CustomerEmail,
string Description,
decimal Amount,
int LineItemCount,
Guid CorrelationId);
}public sealed record CreateInvoiceCommand(
string? CustomerId,
string? CustomerEmail,
string? Description,
decimal Amount,
int LineItemCount,
Guid? CorrelationId) : ICommand<CreateInvoiceResponse>;
public sealed record CreateInvoiceResponse(Guid InvoiceId, string InvoiceNumber);
public sealed class CreateInvoiceHandler
: ICommandHandler<CreateInvoiceCommand, CreateInvoiceResponse>
{
private readonly IInvoiceRepository _repository;
private readonly IInvoiceNumberGenerator _numberGenerator;
public CreateInvoiceHandler(
IInvoiceRepository repository,
IInvoiceNumberGenerator numberGenerator)
{
_repository = Guard.Against.Null(repository);
_numberGenerator = Guard.Against.Null(numberGenerator);
}
public async Task<Result<CreateInvoiceResponse>> Handle(
CreateInvoiceCommand command,
CancellationToken cancellationToken)
{
return await ValidateCommand(command)
.BindAsync(validated => CreateInvoice(validated, cancellationToken));
}
private static Result<ValidatedInvoiceData> ValidateCommand(
CreateInvoiceCommand command)
{
return Guard.ToResult.NullOrEmpty(
command.CustomerId,
"Customer ID is required")
.Bind(customerId =>
Guard.ToResult.InvalidInput(
command.CustomerEmail ?? "",
email => email.Contains('@') && email.Contains('.'),
"A valid email address is required")
.Map(email => (customerId, email)))
.Bind(tuple =>
Guard.ToResult.NullOrWhiteSpace(
command.Description,
"Description cannot be empty")
.Map(desc => (tuple.customerId, tuple.email, desc)))
.Bind(tuple =>
Guard.ToResult.Negative(
command.Amount,
"Invoice amount cannot be negative")
.Map(amount => (tuple.customerId, tuple.email, tuple.desc, amount)))
.Bind(tuple =>
Guard.ToResult.NegativeOrZero(
command.LineItemCount,
"Invoice must have at least one line item")
.Map(count => new ValidatedInvoiceData(
tuple.customerId,
tuple.email,
tuple.desc,
tuple.amount,
count,
command.CorrelationId ?? Guid.NewGuid())));
}
private async Task<Result<CreateInvoiceResponse>> CreateInvoice(
ValidatedInvoiceData data,
CancellationToken cancellationToken)
{
var invoiceNumber = await _numberGenerator.GenerateAsync(cancellationToken);
var invoice = new Invoice(
Guid.NewGuid(),
data.CustomerId,
data.CustomerEmail,
data.Description,
data.Amount,
data.LineItemCount,
invoiceNumber,
data.CorrelationId);
await _repository.SaveAsync(invoice, cancellationToken);
return Result<CreateInvoiceResponse>.Success(
new CreateInvoiceResponse(invoice.Id, invoiceNumber));
}
private sealed record ValidatedInvoiceData(
string CustomerId,
string CustomerEmail,
string Description,
decimal Amount,
int LineItemCount,
Guid CorrelationId);
}Several things to note about this pattern:
The constructor uses Guard.Against. The repository and number generator are injected by the DI container. If the container resolves them as null, that is a configuration bug -- an ArgumentNullException is appropriate because the caller (the DI container) made a mistake.
The Handle method uses Guard.ToResult. The command comes from an API controller. The user might submit an empty customer ID, an invalid email, or a negative amount. These are expected validation failures, not programming errors. The handler returns Result<CreateInvoiceResponse> so the controller can map failures to appropriate HTTP status codes (400 Bad Request) rather than catching exceptions.
The validation pipeline short-circuits. If the customer ID is empty, the email validation never runs. If the email is invalid, the description validation never runs. The first failure stops the pipeline. The result contains a single, precise error message.
The validated data is captured in a record. The ValidatedInvoiceData record holds all validated values. Once the pipeline completes successfully, the CreateInvoice method receives a guaranteed-valid data object. There is no need to re-validate inside CreateInvoice. The types tell the story: ValidatedInvoiceData means "already validated."
BindAsync bridges sync validation and async creation. The ValidateCommand method is synchronous (guards do not need async). The CreateInvoice method is asynchronous (repository access needs async). BindAsync bridges the two, running the async method only if the sync validation succeeds.
Composing Guards with Option and Result
The Guard package integrates with both Option<T> and Result<T> from other FrenchExDev packages. This section shows how the three types compose in realistic scenarios.
Guard.ToResult with Option Conversion
Sometimes a guard validation produces a Result<T>, but downstream code expects an Option<T>. The Result<T>.ToOption() extension handles this conversion:
// Guard validates, then convert to Option for downstream consumption
Option<string> validName = Guard.ToResult
.NullOrWhiteSpace(input)
.ToOption();
// Success → Some(value), Failure → None// Guard validates, then convert to Option for downstream consumption
Option<string> validName = Guard.ToResult
.NullOrWhiteSpace(input)
.ToOption();
// Success → Some(value), Failure → NoneThis is useful when you want to validate but do not need to preserve the error message. For example, when populating a cache or a lookup dictionary, you care about whether the value is valid, not why it failed:
public Option<Product> FindProduct(string? sku)
{
return Guard.ToResult
.NullOrEmpty(sku, "SKU is required")
.Bind(validSku => _repository.FindBySku(validSku))
.ToOption();
// Returns Some(product) if SKU is valid and product exists
// Returns None if SKU is invalid or product not found
}public Option<Product> FindProduct(string? sku)
{
return Guard.ToResult
.NullOrEmpty(sku, "SKU is required")
.Bind(validSku => _repository.FindBySku(validSku))
.ToOption();
// Returns Some(product) if SKU is valid and product exists
// Returns None if SKU is invalid or product not found
}Guard Pipeline in a Mediator Handler with Option
Here is a pattern that combines all three types -- Guard, Result, and Option -- in a single handler:
public sealed class GetCustomerDetailsHandler
: IQueryHandler<GetCustomerDetailsQuery, CustomerDetails>
{
private readonly ICustomerRepository _repository;
private readonly IAddressService _addressService;
public GetCustomerDetailsHandler(
ICustomerRepository repository,
IAddressService addressService)
{
_repository = Guard.Against.Null(repository);
_addressService = Guard.Against.Null(addressService);
}
public async Task<Result<CustomerDetails>> Handle(
GetCustomerDetailsQuery query,
CancellationToken cancellationToken)
{
// Step 1: Guard validates the query parameter
return await Guard.ToResult
.NullOrEmpty(query.CustomerId, "Customer ID is required")
// Step 2: Look up the customer (returns Option<Customer>)
.BindAsync(async customerId =>
{
Option<Customer> customer = await _repository
.FindByIdAsync(customerId, cancellationToken);
// Step 3: Convert Option to Result (None → Failure)
return customer.ToResult("Customer not found");
})
// Step 4: Enrich with optional address (Option stays Option)
.MapAsync(async customer =>
{
Option<Address> address = await _addressService
.GetPrimaryAddressAsync(customer.Id, cancellationToken);
return new CustomerDetails(
customer.Id,
customer.Name,
customer.Email,
PrimaryCity: address
.Map(a => a.City)
.OrDefault("No address on file"));
});
}
}public sealed class GetCustomerDetailsHandler
: IQueryHandler<GetCustomerDetailsQuery, CustomerDetails>
{
private readonly ICustomerRepository _repository;
private readonly IAddressService _addressService;
public GetCustomerDetailsHandler(
ICustomerRepository repository,
IAddressService addressService)
{
_repository = Guard.Against.Null(repository);
_addressService = Guard.Against.Null(addressService);
}
public async Task<Result<CustomerDetails>> Handle(
GetCustomerDetailsQuery query,
CancellationToken cancellationToken)
{
// Step 1: Guard validates the query parameter
return await Guard.ToResult
.NullOrEmpty(query.CustomerId, "Customer ID is required")
// Step 2: Look up the customer (returns Option<Customer>)
.BindAsync(async customerId =>
{
Option<Customer> customer = await _repository
.FindByIdAsync(customerId, cancellationToken);
// Step 3: Convert Option to Result (None → Failure)
return customer.ToResult("Customer not found");
})
// Step 4: Enrich with optional address (Option stays Option)
.MapAsync(async customer =>
{
Option<Address> address = await _addressService
.GetPrimaryAddressAsync(customer.Id, cancellationToken);
return new CustomerDetails(
customer.Id,
customer.Name,
customer.Email,
PrimaryCity: address
.Map(a => a.City)
.OrDefault("No address on file"));
});
}
}This handler demonstrates the composition:
Guard.ToResult.NullOrEmptyvalidates the input and returnsResult<string>.- The repository returns
Option<Customer>(the customer might not exist). Option<Customer>.ToResult("Customer not found")convertsNoneto a failure andSometo a success.- The address service returns
Option<Address>(the customer might not have an address). Option<Address>.Map(a => a.City).OrDefault(...)extracts the city or provides a default.
The three types flow naturally: Guard produces Results, the repository produces Options, Options convert to Results when absence is an error, and Options stay as Options when absence is acceptable. Each type communicates a different semantic: Result<T> means "this can fail and you need the error," Option<T> means "this might be absent and that is OK."
Option-First Guard Validation
In some cases, you receive an Option<T> and need to validate the value inside it:
public Result<ProcessedOrder> ProcessOrder(Option<string> maybeOrderId)
{
return maybeOrderId
.ToResult("Order ID was not provided")
.Bind(orderId => Guard.ToResult.NullOrWhiteSpace(orderId, "Order ID cannot be blank"))
.Bind(orderId => Guard.ToResult.LengthExceeded(orderId, 50)
.Map(validId => /* ... process ... */));
}public Result<ProcessedOrder> ProcessOrder(Option<string> maybeOrderId)
{
return maybeOrderId
.ToResult("Order ID was not provided")
.Bind(orderId => Guard.ToResult.NullOrWhiteSpace(orderId, "Order ID cannot be blank"))
.Bind(orderId => Guard.ToResult.LengthExceeded(orderId, 50)
.Map(validId => /* ... process ... */));
}Wait -- Guard.ToResult does not have LengthExceeded. This is one of the four methods omitted from the ToResult surface. You handle it with InvalidInput:
public Result<ProcessedOrder> ProcessOrder(Option<string> maybeOrderId)
{
return maybeOrderId
.ToResult("Order ID was not provided")
.Bind(orderId => Guard.ToResult.NullOrWhiteSpace(
orderId, "Order ID cannot be blank"))
.Bind(orderId => Guard.ToResult.InvalidInput(
orderId,
id => id.Length <= 50,
"Order ID cannot exceed 50 characters"))
.Bind(orderId => LookUpAndProcess(orderId));
}public Result<ProcessedOrder> ProcessOrder(Option<string> maybeOrderId)
{
return maybeOrderId
.ToResult("Order ID was not provided")
.Bind(orderId => Guard.ToResult.NullOrWhiteSpace(
orderId, "Order ID cannot be blank"))
.Bind(orderId => Guard.ToResult.InvalidInput(
orderId,
id => id.Length <= 50,
"Order ID cannot exceed 50 characters"))
.Bind(orderId => LookUpAndProcess(orderId));
}The InvalidInput method is the universal escape hatch. Any validation that the named methods do not cover can be expressed as a predicate. The tradeoff is that you lose the descriptive method name (LengthExceeded vs InvalidInput), but you gain the ability to validate anything.
Testing Guards
Guard methods are deterministic, pure (in the case of Against and ToResult), and have clear success/failure boundaries. This makes them easy to test. Here are the patterns for testing each prong.
Testing Guard.Against
For Guard.Against, the expected behavior is: return the value on valid input, throw a specific exception on invalid input. Use Assert.Throws (or your framework's equivalent) for the failure cases and a simple assertion for the success case.
public class GuardAgainstNullOrEmptyTests
{
[Fact]
public void Returns_value_when_not_null_or_empty()
{
// Arrange
var input = "hello";
// Act
var result = Guard.Against.NullOrEmpty(input);
// Assert
Assert.Equal("hello", result);
}
[Fact]
public void Throws_when_null()
{
// Arrange
string? input = null;
// Act & Assert
var ex = Assert.Throws<ArgumentException>(
() => Guard.Against.NullOrEmpty(input));
Assert.Contains("null or empty", ex.Message);
}
[Fact]
public void Throws_when_empty()
{
// Arrange
var input = "";
// Act & Assert
var ex = Assert.Throws<ArgumentException>(
() => Guard.Against.NullOrEmpty(input));
Assert.Contains("null or empty", ex.Message);
}
[Theory]
[InlineData(" ")]
[InlineData(" \t ")]
public void Does_not_throw_for_whitespace(string input)
{
// NullOrEmpty does NOT reject whitespace -- that's NullOrWhiteSpace
var result = Guard.Against.NullOrEmpty(input);
Assert.Equal(input, result);
}
}public class GuardAgainstNullOrEmptyTests
{
[Fact]
public void Returns_value_when_not_null_or_empty()
{
// Arrange
var input = "hello";
// Act
var result = Guard.Against.NullOrEmpty(input);
// Assert
Assert.Equal("hello", result);
}
[Fact]
public void Throws_when_null()
{
// Arrange
string? input = null;
// Act & Assert
var ex = Assert.Throws<ArgumentException>(
() => Guard.Against.NullOrEmpty(input));
Assert.Contains("null or empty", ex.Message);
}
[Fact]
public void Throws_when_empty()
{
// Arrange
var input = "";
// Act & Assert
var ex = Assert.Throws<ArgumentException>(
() => Guard.Against.NullOrEmpty(input));
Assert.Contains("null or empty", ex.Message);
}
[Theory]
[InlineData(" ")]
[InlineData(" \t ")]
public void Does_not_throw_for_whitespace(string input)
{
// NullOrEmpty does NOT reject whitespace -- that's NullOrWhiteSpace
var result = Guard.Against.NullOrEmpty(input);
Assert.Equal(input, result);
}
}public class GuardAgainstOutOfRangeTests
{
[Theory]
[InlineData(0)]
[InlineData(50)]
[InlineData(100)]
public void Returns_value_when_in_range(int input)
{
var result = Guard.Against.OutOfRange(input, 0, 100);
Assert.Equal(input, result);
}
[Theory]
[InlineData(-1)]
[InlineData(101)]
[InlineData(int.MinValue)]
[InlineData(int.MaxValue)]
public void Throws_when_out_of_range(int input)
{
Assert.Throws<ArgumentOutOfRangeException>(
() => Guard.Against.OutOfRange(input, 0, 100));
}
}public class GuardAgainstOutOfRangeTests
{
[Theory]
[InlineData(0)]
[InlineData(50)]
[InlineData(100)]
public void Returns_value_when_in_range(int input)
{
var result = Guard.Against.OutOfRange(input, 0, 100);
Assert.Equal(input, result);
}
[Theory]
[InlineData(-1)]
[InlineData(101)]
[InlineData(int.MinValue)]
[InlineData(int.MaxValue)]
public void Throws_when_out_of_range(int input)
{
Assert.Throws<ArgumentOutOfRangeException>(
() => Guard.Against.OutOfRange(input, 0, 100));
}
}public class GuardAgainstEmptyGuidTests
{
[Fact]
public void Returns_value_when_not_empty()
{
var guid = Guid.NewGuid();
var result = Guard.Against.EmptyGuid(guid);
Assert.Equal(guid, result);
}
[Fact]
public void Throws_when_empty()
{
Assert.Throws<ArgumentException>(
() => Guard.Against.EmptyGuid(Guid.Empty));
}
}public class GuardAgainstEmptyGuidTests
{
[Fact]
public void Returns_value_when_not_empty()
{
var guid = Guid.NewGuid();
var result = Guard.Against.EmptyGuid(guid);
Assert.Equal(guid, result);
}
[Fact]
public void Throws_when_empty()
{
Assert.Throws<ArgumentException>(
() => Guard.Against.EmptyGuid(Guid.Empty));
}
}public class GuardAgainstUndefinedEnumTests
{
public enum Color { Red, Green, Blue }
[Theory]
[InlineData(Color.Red)]
[InlineData(Color.Green)]
[InlineData(Color.Blue)]
public void Returns_value_when_defined(Color input)
{
var result = Guard.Against.UndefinedEnum(input);
Assert.Equal(input, result);
}
[Fact]
public void Throws_when_undefined()
{
var undefined = (Color)999;
Assert.Throws<ArgumentException>(
() => Guard.Against.UndefinedEnum(undefined));
}
}public class GuardAgainstUndefinedEnumTests
{
public enum Color { Red, Green, Blue }
[Theory]
[InlineData(Color.Red)]
[InlineData(Color.Green)]
[InlineData(Color.Blue)]
public void Returns_value_when_defined(Color input)
{
var result = Guard.Against.UndefinedEnum(input);
Assert.Equal(input, result);
}
[Fact]
public void Throws_when_undefined()
{
var undefined = (Color)999;
Assert.Throws<ArgumentException>(
() => Guard.Against.UndefinedEnum(undefined));
}
}Testing Guard.ToResult
For Guard.ToResult, there are no exceptions to catch. Instead, assert on the Result<T> properties: IsSuccess, IsFailure, Value, and error message.
public class GuardToResultNullOrEmptyTests
{
[Fact]
public void Returns_success_when_not_null_or_empty()
{
var result = Guard.ToResult.NullOrEmpty("hello");
Assert.True(result.IsSuccess);
Assert.Equal("hello", result.Value);
}
[Fact]
public void Returns_failure_when_null()
{
var result = Guard.ToResult.NullOrEmpty(null);
Assert.True(result.IsFailure);
}
[Fact]
public void Returns_failure_when_empty()
{
var result = Guard.ToResult.NullOrEmpty("");
Assert.True(result.IsFailure);
}
[Fact]
public void Uses_custom_error_message()
{
var result = Guard.ToResult.NullOrEmpty(null, "Name is required");
Assert.True(result.IsFailure);
Assert.Equal("Name is required", result.Error.Message);
}
[Fact]
public void Uses_default_error_message_when_not_specified()
{
var result = Guard.ToResult.NullOrEmpty(null);
Assert.True(result.IsFailure);
Assert.Contains("null or empty", result.Error.Message);
}
}public class GuardToResultNullOrEmptyTests
{
[Fact]
public void Returns_success_when_not_null_or_empty()
{
var result = Guard.ToResult.NullOrEmpty("hello");
Assert.True(result.IsSuccess);
Assert.Equal("hello", result.Value);
}
[Fact]
public void Returns_failure_when_null()
{
var result = Guard.ToResult.NullOrEmpty(null);
Assert.True(result.IsFailure);
}
[Fact]
public void Returns_failure_when_empty()
{
var result = Guard.ToResult.NullOrEmpty("");
Assert.True(result.IsFailure);
}
[Fact]
public void Uses_custom_error_message()
{
var result = Guard.ToResult.NullOrEmpty(null, "Name is required");
Assert.True(result.IsFailure);
Assert.Equal("Name is required", result.Error.Message);
}
[Fact]
public void Uses_default_error_message_when_not_specified()
{
var result = Guard.ToResult.NullOrEmpty(null);
Assert.True(result.IsFailure);
Assert.Contains("null or empty", result.Error.Message);
}
}public class GuardToResultPipelineTests
{
[Fact]
public void Pipeline_succeeds_when_all_guards_pass()
{
var result = Guard.ToResult.NullOrEmpty("cust-123")
.Bind(id => Guard.ToResult.Negative(100m)
.Map(amount => (id, amount)));
Assert.True(result.IsSuccess);
Assert.Equal("cust-123", result.Value.id);
Assert.Equal(100m, result.Value.amount);
}
[Fact]
public void Pipeline_fails_on_first_guard_failure()
{
var result = Guard.ToResult.NullOrEmpty("", "ID is required")
.Bind(id => Guard.ToResult.Negative(100m)
.Map(amount => (id, amount)));
Assert.True(result.IsFailure);
Assert.Equal("ID is required", result.Error.Message);
}
[Fact]
public void Pipeline_fails_on_second_guard_failure()
{
var result = Guard.ToResult.NullOrEmpty("cust-123")
.Bind(id => Guard.ToResult.Negative(-50m, "Amount cannot be negative")
.Map(amount => (id, amount)));
Assert.True(result.IsFailure);
Assert.Equal("Amount cannot be negative", result.Error.Message);
}
}public class GuardToResultPipelineTests
{
[Fact]
public void Pipeline_succeeds_when_all_guards_pass()
{
var result = Guard.ToResult.NullOrEmpty("cust-123")
.Bind(id => Guard.ToResult.Negative(100m)
.Map(amount => (id, amount)));
Assert.True(result.IsSuccess);
Assert.Equal("cust-123", result.Value.id);
Assert.Equal(100m, result.Value.amount);
}
[Fact]
public void Pipeline_fails_on_first_guard_failure()
{
var result = Guard.ToResult.NullOrEmpty("", "ID is required")
.Bind(id => Guard.ToResult.Negative(100m)
.Map(amount => (id, amount)));
Assert.True(result.IsFailure);
Assert.Equal("ID is required", result.Error.Message);
}
[Fact]
public void Pipeline_fails_on_second_guard_failure()
{
var result = Guard.ToResult.NullOrEmpty("cust-123")
.Bind(id => Guard.ToResult.Negative(-50m, "Amount cannot be negative")
.Map(amount => (id, amount)));
Assert.True(result.IsFailure);
Assert.Equal("Amount cannot be negative", result.Error.Message);
}
}Testing Guard.Ensure
For Guard.Ensure, test the success case (no exception thrown) and the failure case (InvalidOperationException thrown):
public class GuardEnsureTests
{
[Fact]
public void That_does_not_throw_when_condition_is_true()
{
var exception = Record.Exception(
() => Guard.Ensure.That(true, "Should not throw"));
Assert.Null(exception);
}
[Fact]
public void That_throws_when_condition_is_false()
{
var ex = Assert.Throws<InvalidOperationException>(
() => Guard.Ensure.That(false, "Expected failure"));
Assert.Equal("Expected failure", ex.Message);
}
[Fact]
public void NotNull_returns_value_when_not_null()
{
var service = new object();
var result = Guard.Ensure.NotNull(service, "Service is null");
Assert.Same(service, result);
}
[Fact]
public void NotNull_throws_when_null()
{
object? service = null;
var ex = Assert.Throws<InvalidOperationException>(
() => Guard.Ensure.NotNull(service, "Service is null"));
Assert.Equal("Service is null", ex.Message);
}
[Fact]
public void NotAlreadyDone_does_not_throw_when_not_done()
{
var exception = Record.Exception(
() => Guard.Ensure.NotAlreadyDone(false, "Process"));
Assert.Null(exception);
}
[Fact]
public void NotAlreadyDone_throws_when_already_done()
{
var ex = Assert.Throws<InvalidOperationException>(
() => Guard.Ensure.NotAlreadyDone(true, "Process"));
Assert.Contains("Process", ex.Message);
Assert.Contains("already", ex.Message);
}
[Fact]
public void That_lazy_message_is_not_evaluated_on_success()
{
var messageEvaluated = false;
Guard.Ensure.That(true, () =>
{
messageEvaluated = true;
return "Should not be evaluated";
});
Assert.False(messageEvaluated);
}
[Fact]
public void That_lazy_message_is_evaluated_on_failure()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
Guard.Ensure.That(false, () => "Lazy failure message"));
Assert.Equal("Lazy failure message", ex.Message);
}
}public class GuardEnsureTests
{
[Fact]
public void That_does_not_throw_when_condition_is_true()
{
var exception = Record.Exception(
() => Guard.Ensure.That(true, "Should not throw"));
Assert.Null(exception);
}
[Fact]
public void That_throws_when_condition_is_false()
{
var ex = Assert.Throws<InvalidOperationException>(
() => Guard.Ensure.That(false, "Expected failure"));
Assert.Equal("Expected failure", ex.Message);
}
[Fact]
public void NotNull_returns_value_when_not_null()
{
var service = new object();
var result = Guard.Ensure.NotNull(service, "Service is null");
Assert.Same(service, result);
}
[Fact]
public void NotNull_throws_when_null()
{
object? service = null;
var ex = Assert.Throws<InvalidOperationException>(
() => Guard.Ensure.NotNull(service, "Service is null"));
Assert.Equal("Service is null", ex.Message);
}
[Fact]
public void NotAlreadyDone_does_not_throw_when_not_done()
{
var exception = Record.Exception(
() => Guard.Ensure.NotAlreadyDone(false, "Process"));
Assert.Null(exception);
}
[Fact]
public void NotAlreadyDone_throws_when_already_done()
{
var ex = Assert.Throws<InvalidOperationException>(
() => Guard.Ensure.NotAlreadyDone(true, "Process"));
Assert.Contains("Process", ex.Message);
Assert.Contains("already", ex.Message);
}
[Fact]
public void That_lazy_message_is_not_evaluated_on_success()
{
var messageEvaluated = false;
Guard.Ensure.That(true, () =>
{
messageEvaluated = true;
return "Should not be evaluated";
});
Assert.False(messageEvaluated);
}
[Fact]
public void That_lazy_message_is_evaluated_on_failure()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
Guard.Ensure.That(false, () => "Lazy failure message"));
Assert.Equal("Lazy failure message", ex.Message);
}
}Testing Aggregate Constructors That Use Guards
When testing domain aggregates, you test the guards indirectly by passing invalid input and verifying the expected exception:
public class SubscriptionTests
{
[Fact]
public void Constructor_rejects_empty_tenant_id()
{
Assert.Throws<ArgumentException>(() =>
new Subscription(
Guid.NewGuid(),
tenantId: "",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1)));
}
[Fact]
public void Constructor_rejects_negative_price()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new Subscription(
Guid.NewGuid(),
"tenant-123",
"Pro Plan",
monthlyPrice: -1m,
10,
new DateOnly(2026, 1, 1)));
}
[Fact]
public void Constructor_rejects_empty_guid()
{
Assert.Throws<ArgumentException>(() =>
new Subscription(
id: Guid.Empty,
"tenant-123",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1)));
}
[Fact]
public void Cancel_rejects_double_cancellation()
{
var subscription = CreateValidSubscription();
subscription.Cancel(new DateOnly(2026, 6, 1));
Assert.Throws<InvalidOperationException>(() =>
subscription.Cancel(new DateOnly(2026, 7, 1)));
}
[Fact]
public void Create_returns_failure_for_empty_tenant_id()
{
var result = Subscription.Create(
tenantId: "",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1));
Assert.True(result.IsFailure);
Assert.Equal("Tenant ID is required", result.Error.Message);
}
private static Subscription CreateValidSubscription()
{
return new Subscription(
Guid.NewGuid(),
"tenant-123",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1));
}
}public class SubscriptionTests
{
[Fact]
public void Constructor_rejects_empty_tenant_id()
{
Assert.Throws<ArgumentException>(() =>
new Subscription(
Guid.NewGuid(),
tenantId: "",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1)));
}
[Fact]
public void Constructor_rejects_negative_price()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new Subscription(
Guid.NewGuid(),
"tenant-123",
"Pro Plan",
monthlyPrice: -1m,
10,
new DateOnly(2026, 1, 1)));
}
[Fact]
public void Constructor_rejects_empty_guid()
{
Assert.Throws<ArgumentException>(() =>
new Subscription(
id: Guid.Empty,
"tenant-123",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1)));
}
[Fact]
public void Cancel_rejects_double_cancellation()
{
var subscription = CreateValidSubscription();
subscription.Cancel(new DateOnly(2026, 6, 1));
Assert.Throws<InvalidOperationException>(() =>
subscription.Cancel(new DateOnly(2026, 7, 1)));
}
[Fact]
public void Create_returns_failure_for_empty_tenant_id()
{
var result = Subscription.Create(
tenantId: "",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1));
Assert.True(result.IsFailure);
Assert.Equal("Tenant ID is required", result.Error.Message);
}
private static Subscription CreateValidSubscription()
{
return new Subscription(
Guid.NewGuid(),
"tenant-123",
"Pro Plan",
29.99m,
10,
new DateOnly(2026, 1, 1));
}
}Notice that the test for Cancel asserts InvalidOperationException, not ArgumentException. This is because Cancel uses Guard.Ensure.NotAlreadyDone, which throws InvalidOperationException. The exception type tells the test reader what kind of violation is being tested: the first three tests are about bad input (argument exceptions), and the fourth is about bad state (invalid operation exception).
vs. Ardalis.GuardClauses
Ardalis.GuardClauses is the most popular guard library for .NET. It uses a similar Guard.Against.Null(input, nameof(input)) syntax. The key differences:
Parameter name capture. Ardalis requires
nameof(input)as an explicit parameter. FrenchExDev uses[CallerArgumentExpression]to capture it automatically. This eliminates the copy-paste bug wherenameofgets out of sync with the actual parameter.Return value. Ardalis guards return
void(or returnedvoidin earlier versions, with return values added later). FrenchExDev guards have always returned the validated value, enabling inline assignment from day one.Result integration. Ardalis does not provide a Result-returning variant. If you need functional guards, you write your own wrapper. FrenchExDev provides
Guard.ToResultas a first-class citizen, with fullResult<T>integration.Three-prong design. Ardalis has a single
Guard.AgainstAPI. There is no equivalent toGuard.Ensurefor internal invariants. Invariant checks must use the same argument-exception-throwing API, which produces misleading exception types.Extensibility model. Ardalis encourages adding custom guards as extension methods on the
IGuardClauseinterface. FrenchExDev providesInvalidInputas the extensibility mechanism -- a single method with a predicate parameter, rather than an open extension point that every project fills with custom methods.
vs. Raw if-throw
The if-throw pattern has the advantage of complete transparency: there is no library, no method call, no abstraction layer. You see the condition, you see the exception, you see the message. But it has five disadvantages outlined at the start of this article: verbosity, inconsistency, forgotten parameter names, separated validation and assignment, and DRY violation.
For a class with one or two parameters, if-throw is fine. For a class with six or more parameters, or a codebase with dozens of such classes, if-throw becomes a maintenance burden.
vs. Contracts (System.Diagnostics.Contracts)
Code Contracts were a .NET Framework-era experiment in static verification. They used Contract.Requires, Contract.Ensures, and a static analyzer to verify preconditions and postconditions at compile time. The project was abandoned because the static analyzer was too slow, too fragile, and too often wrong.
Guard.Ensure.That provides a lightweight alternative to Contract.Ensures -- without the static analyzer, without the rewriter, and without the abandoned tooling. It is a runtime check, not a compile-time proof. But it is a runtime check that always works, ships in every configuration, and throws a clear exception when violated.
vs. Nullable Reference Types
NRTs and Guard are complementary, not competing. NRTs are a compile-time advisory system: the compiler warns you when you might be passing null. Guards are a runtime enforcement system: the code throws when you actually pass null. Use both:
public Order(string customerId) // NRT: compiler warns if caller passes null
{
CustomerId = Guard.Against.NullOrEmpty(customerId); // Guard: runtime enforcement
}public Order(string customerId) // NRT: compiler warns if caller passes null
{
CustomerId = Guard.Against.NullOrEmpty(customerId); // Guard: runtime enforcement
}NRTs catch mistakes at compile time (in the IDE, during code review). Guards catch mistakes at runtime (in production, in integration tests). The two layers complement each other. Relying on only NRTs means null can sneak through suppression operators, deserialization, or reflection. Relying on only Guards means null mistakes are caught late (at runtime) rather than early (at compile time).
Performance Considerations
Guard methods are thin wrappers around simple checks. The Guard.Against.NullOrEmpty method, for example, is approximately:
public string NullOrEmpty(
string? value,
[CallerArgumentExpression(nameof(value))] string? paramName = null)
{
if (string.IsNullOrEmpty(value))
throw new ArgumentException("Value cannot be null or empty.", paramName);
return value;
}public string NullOrEmpty(
string? value,
[CallerArgumentExpression(nameof(value))] string? paramName = null)
{
if (string.IsNullOrEmpty(value))
throw new ArgumentException("Value cannot be null or empty.", paramName);
return value;
}There is no reflection. There is no allocation on the success path (the exception object is only created when the guard fails, which is the exceptional case). There is no virtual dispatch -- the classes are sealed, and the methods are not virtual. The JIT compiler can inline these methods completely, making them zero-cost abstractions in optimized builds.
The CallerArgumentExpression attribute is resolved at compile time. The parameter name string is baked into the call site as a constant, not computed at runtime. There is no runtime cost for the parameter name capture.
The GuardToResult methods allocate a Result<T> on both the success and failure paths. This is one small object allocation per call. In the context of a mediator handler that is about to make a database call and a network call, one Result<T> allocation is negligible. If you are in a tight loop processing millions of items, use Guard.Against (which allocates nothing on the success path) rather than Guard.ToResult.
The Guard.Ensure.That(condition, Func<string> messageFactory) overload avoids string allocation on the success path by deferring message construction. If the condition is true (which it almost always should be -- ensures are invariants), the factory delegate is not invoked. The only overhead is passing the delegate itself, which the compiler may allocate as a closure if it captures local variables. For hot-path ensures with expensive messages, the lazy overload is worth using.
Why Three Classes Instead of One?
The most common question about the Guard library is: why three classes? Why not one class with a throwOnFail parameter, or one class with a Result<T> return type that callers can choose to throw on?
The answer is clarity of intent. When you see Guard.Against, you know the method throws. When you see Guard.ToResult, you know the method returns a Result. When you see Guard.Ensure, you know the method asserts internal state. There is no cognitive load spent determining what will happen when the guard fails -- the class name tells you.
A single class with a throwOnFail parameter would look like this:
// Hypothetical: one class, boolean switch
Guard.NullOrEmpty(input, throwOnFail: true); // throws
Guard.NullOrEmpty(input, throwOnFail: false); // returns Result?// Hypothetical: one class, boolean switch
Guard.NullOrEmpty(input, throwOnFail: true); // throws
Guard.NullOrEmpty(input, throwOnFail: false); // returns Result?This is worse in every way. The return type changes based on a boolean parameter (which is impossible to express in C#'s type system without generics gymnastics). The caller must remember which mode they are in. A forgotten throwOnFail: false silently swallows validation failures. The code is less readable, less safe, and more error-prone.
Three classes, three concerns, three sets of return types. The API is larger in surface but simpler in each part.
Why Sealed Classes with Internal Constructors?
The three guard classes (GuardAgainst, GuardToResult, GuardEnsure) are sealed with internal constructors. This means:
You cannot create your own instance. The only instances are the three singletons on the
Guardclass. This prevents abuse likevar myGuard = new GuardAgainst()and ensures a single, consistent entry point.You cannot subclass them. This prevents
class MyGuardAgainst : GuardAgainstwith overridden methods that change the behavior. Guard methods should always do what their names say. Subclassing would allow silent behavior changes that violate caller expectations.You cannot add extension methods on instances (well, you can, but they only appear on
GuardAgainst, not onIGuardClause). If you need a custom check, useInvalidInput. The library deliberately limits the extension surface to prevent the "800 extension methods" problem that plagues some guard libraries.
Why Does GuardToResult Have Fewer Methods?
GuardAgainst has fourteen methods. GuardToResult has ten. The four missing methods are Zero, NullOrEmpty<T> (collection), LengthExceeded, and UndefinedEnum.
The rationale is frequency of use. In functional pipelines (the context where Guard.ToResult is used), the most common validations are null checks, empty checks, range checks, and custom predicates. Collection emptiness, string length, zero values, and undefined enums are less commonly needed in pipeline context, and when they are needed, InvalidInput handles them:
// Instead of Guard.ToResult.LengthExceeded(name, 200)
Guard.ToResult.InvalidInput(name, n => n.Length <= 200, "Name too long")
// Instead of Guard.ToResult.UndefinedEnum(status)
Guard.ToResult.InvalidInput(status, Enum.IsDefined, "Invalid status")
// Instead of Guard.ToResult.Zero(divisor)
Guard.ToResult.InvalidInput(divisor, d => d != 0, "Divisor cannot be zero")// Instead of Guard.ToResult.LengthExceeded(name, 200)
Guard.ToResult.InvalidInput(name, n => n.Length <= 200, "Name too long")
// Instead of Guard.ToResult.UndefinedEnum(status)
Guard.ToResult.InvalidInput(status, Enum.IsDefined, "Invalid status")
// Instead of Guard.ToResult.Zero(divisor)
Guard.ToResult.InvalidInput(divisor, d => d != 0, "Divisor cannot be zero")Adding the missing methods would not be wrong, but it would increase the API surface for marginal benefit. The ten methods cover the overwhelming majority of pipeline validation needs. The InvalidInput escape hatch covers the rest.
Why Does Guard.Ensure.That Return Void?
Guard.Against methods return the validated value. Guard.Ensure.That returns void. Why the inconsistency?
Because Guard.Ensure.That takes a bool condition, not a value. There is nothing to return. The condition is a computed expression (items.Count > 0), not a value to assign (customerId). Returning the boolean would be meaningless -- you already know it is true if the method did not throw.
The exception is Guard.Ensure.NotNull, which takes a value and returns it -- the same pattern as Guard.Against.Null. The return value enables the same inline assignment pattern:
var connection = Guard.Ensure.NotNull(_connection, "Not initialized");var connection = Guard.Ensure.NotNull(_connection, "Not initialized");Why Not Use Generic Math (INumber<T>)?
.NET 7 introduced generic math interfaces (INumber<T>, IComparisonOperators<T>, etc.) that could replace the IComparable<T> constraint on numeric guards. The library does not use them because it targets netstandard2.0, which predates .NET 7 by several years.
On .NET 7+, IComparable<T> is still correct for the library's purposes. The guards need to compare values, and IComparable<T> is the universal comparison interface that works across all target frameworks. INumber<T> would be more precise (it excludes non-numeric comparables like string), but the precision gain does not justify dropping netstandard2.0 support.
Summary
The Guard pattern in FrenchExDev.Net.Guard provides three validation APIs for three validation contexts:
| Prong | Class | Returns | Throws | Use when |
|---|---|---|---|---|
| Against | GuardAgainst |
Validated value (T) |
ArgumentException family |
Validating public API parameters |
| ToResult | GuardToResult |
Result<T> |
Never | Validating in functional pipelines |
| Ensure | GuardEnsure |
void (or T) |
InvalidOperationException |
Asserting internal invariants |
The key design decisions:
- Return value. Every
Guard.Againstmethod returns the validated value, enablingProperty = Guard.Against.NullOrEmpty(param)as a single expression. - CallerArgumentExpression. Parameter names are captured automatically by the compiler, eliminating
nameofboilerplate and copy-paste bugs. - Three prongs. Different validation contexts produce different exception types (or no exception). The API makes the distinction structural, not incidental.
- Result integration.
Guard.ToResultreturnsResult<T>, composing cleanly withBind,Map, and the rest of theResult<T>pipeline. - Fourteen built-in checks. Null, NullValue, NullOrEmpty (string), NullOrWhiteSpace, NullOrEmpty (collection), OutOfRange, Negative, Zero, NegativeOrZero, Default, InvalidInput, LengthExceeded, EmptyGuid, UndefinedEnum.
- InvalidInput as escape hatch. Any validation not covered by the named methods can be expressed as a predicate.
- netstandard2.0 compatibility. The library works on .NET Framework 4.6.1+ through .NET 9+ using a
CallerArgumentExpressionpolyfill.
The philosophy is simple: validation should be declarative, consistent, and fused with assignment. The Guard pattern achieves all three by turning each validation check into a method that reads like English, throws the right exception type, and returns the validated value. The three prongs ensure that the right kind of validation is used in the right context, without forcing a single mechanism on every scenario.
Next in the series: Part VII: The Mediator Pattern, where we implement CQRS dispatch with source-generated handler registration, Result<T>-returning handlers, and a zero-reflection pipeline.