Pragmatic.Ensure
Guard clauses for parameter validation in .NET. Part of the Pragmatic.Design ecosystem.
The Problem
Section titled “The Problem”Every .NET codebase needs to validate inputs. Without a consistent approach, guard clauses become a patchwork of styles:
public class OrderService{ private readonly IOrderRepository _repository; private readonly ILogger<OrderService> _logger;
public OrderService(IOrderRepository repository, ILogger<OrderService> logger) { // Developer A: manual throw with nameof _repository = repository ?? throw new ArgumentNullException(nameof(repository));
// Developer B: forgot the guard _logger = logger; }
public async Task<Order> GetOrderAsync(Guid id) { // Developer C: wrong exception type if (id == Guid.Empty) throw new InvalidOperationException("Id cannot be empty");
var order = await _repository.GetByIdAsync(id);
// Developer D: exception for a business case if (order is null) throw new KeyNotFoundException($"Order {id} not found");
return order; }}Four developers, four styles. Inconsistent exception types, missing guards, and exceptions used for business logic that should return typed errors.
The deeper problem: there is no clear boundary between programming errors and business failures. A null constructor argument is a bug — it should throw. A missing order is an expected outcome — it should return a typed error. Mixing these two categories leads to try/catch blocks that catch everything, hiding real bugs behind business error handling.
The Solution
Section titled “The Solution”One static class with three patterns, each designed for a specific category of validation:
public class OrderService(IOrderRepository repository, ILogger<OrderService> logger){ // ThrowIf: Guards for programming errors -- validate + assign in one statement private readonly IOrderRepository _repository = Ensure.ThrowIfNull(repository); private readonly ILogger<OrderService> _logger = Ensure.ThrowIfNull(logger);
public async Task<Result<Order, NotFoundError>> GetOrderAsync(Guid id) { Ensure.ThrowIfEmpty(id); // Programming error: empty GUID is a bug
var order = await _repository.GetByIdAsync(id); if (order is null) return new NotFoundError("Order", id); // Business case: typed error, not exception
return order; }}Parameter names are captured automatically. Exception types are always correct. Programming errors throw. Business failures return typed errors.
Features
Section titled “Features”- ThrowIf Pattern: Throws exceptions when preconditions fail (for programming errors)
- Is Pattern: Returns booleans for conditional logic (no exceptions)
- Check Pattern: Returns
VoidResult<TError>for domain validation (expected failures) - Zero dependencies: Layer 0 foundation library
- High performance: Aggressive inlining, no allocations on happy path
- Automatic parameter names: Uses
[CallerArgumentExpression]— no magic strings - Null-state analysis: Uses
[NotNull]and[NotNullWhen]for proper flow analysis
Packages
Section titled “Packages”| Package | Purpose |
|---|---|
| Pragmatic.Ensure | Core: ThrowIf* and Is* patterns. Zero dependencies. |
| Pragmatic.Ensure.Result | Check.* pattern returning VoidResult<TError>. Depends on Pragmatic.Result. |
Installation
Section titled “Installation”# Core package (ThrowIf and Is patterns)dotnet add package Pragmatic.Ensure
# Optional: Result integration (Check pattern)dotnet add package Pragmatic.Ensure.ResultQuick Start
Section titled “Quick Start”ThrowIf Pattern (Constructor Guards)
Section titled “ThrowIf Pattern (Constructor Guards)”Use ThrowIf* methods to validate preconditions. They throw exceptions when validation fails:
using Pragmatic.Ensure;
public class User{ public User(Guid id, string name, string email, int age) { // All methods automatically capture parameter names Ensure.ThrowIfEmpty(id); // ArgumentException Ensure.ThrowIfNullOrWhiteSpace(name); // ArgumentException Ensure.ThrowIfNotEmail(email); // ArgumentException Ensure.ThrowIfNegative(age); // ArgumentOutOfRangeException Ensure.ThrowIfOutOfRange(age, 0, 150);// ArgumentOutOfRangeException
Id = id; Name = name; Email = email; Age = age; }}
// ThrowIfNull returns the validated value for fluent assignmentpublic class OrderService(IOrderRepository repository, ILogger<OrderService> logger){ private readonly IOrderRepository _repository = Ensure.ThrowIfNull(repository); private readonly ILogger<OrderService> _logger = Ensure.ThrowIfNull(logger);}Is Pattern (Conditional Logic)
Section titled “Is Pattern (Conditional Logic)”Use Is* methods for validation without exceptions:
using Pragmatic.Ensure;
// Validation without throwingif (Ensure.IsNotNullOrEmpty(input)){ // Process valid input}
// Combine multiple checksif (Ensure.IsEmail(email) && Ensure.IsLengthInRange(email, 5, 100)){ // Valid email with proper length}
// Null-state analysis works correctlystring? name = GetName();if (Ensure.IsNotNull(name)){ // 'name' is known to be non-null here Console.WriteLine(name.Length);}Check Pattern (Domain Validation with Result)
Section titled “Check Pattern (Domain Validation with Result)”Use Check.* methods for domain validation where failures are expected outcomes:
using Pragmatic.Ensure.Result;
// Domain validation returns VoidResult<TError>var result = Check.NotNull(user, new NotFoundError("User", userId)) .Bind(_ => Check.NotNullOrEmpty(user.Email, new ValidationError("Email required"))) .Bind(_ => Check.Email(user.Email, new ValidationError("Invalid email")));
if (result.IsFailure){ return result.Error; // Handle validation failure}
// Lazy error construction -- factory only invoked on failurevar validation = Check.NotNull(order, () => new NotFoundError("Order", orderId));When to Use What
Section titled “When to Use What”| Question | Answer | Use |
|---|---|---|
| Is this a programming error (bug in caller)? | Yes | Ensure.ThrowIf* |
| Do you need a boolean for conditional logic? | Yes | Ensure.Is* |
| Is failure expected (user input, business rules)? | Yes | Check.* |
| Aspect | ThrowIf* | Is* | Check.* |
|---|---|---|---|
| Returns | The validated value (or void) | bool | VoidResult<TError> |
| On failure | Exception | false | Typed error |
| Use for | Bugs / preconditions | Control flow | Business validation |
| Composable | No | && / || | .Bind() chain |
| Performance | Zero alloc (happy path) | Zero alloc | Zero alloc (happy path) |
| Package | Pragmatic.Ensure | Pragmatic.Ensure | Pragmatic.Ensure.Result |
Practical Examples
Section titled “Practical Examples”Entity Constructor Guards
Section titled “Entity Constructor Guards”public class Reservation{ public Reservation(Guid guestId, Guid propertyId, DateTimeOffset checkIn, DateTimeOffset checkOut, int guests) { Ensure.ThrowIfEmpty(guestId); Ensure.ThrowIfEmpty(propertyId); Ensure.ThrowIfInPast(checkIn); Ensure.ThrowIfTrue(checkOut <= checkIn, "CheckOut must be after CheckIn"); Ensure.ThrowIfOutOfRange(guests, 1, 20);
GuestId = guestId; PropertyId = propertyId; CheckIn = checkIn; CheckOut = checkOut; NumberOfGuests = guests; }}Service Method with Mixed Patterns
Section titled “Service Method with Mixed Patterns”public async Task<Result<Invoice, IError>> CreateInvoice(CreateInvoiceRequest request){ // Programming error: null request is a bug in the caller Ensure.ThrowIfNull(request);
// Business validation: empty line items is an expected failure var lineCheck = Check.NotNullOrEmpty(request.LineItems, new ValidationError("At least one line item is required")); if (lineCheck.IsFailure) return lineCheck.Error;
// Business validation: negative total is an expected failure var totalCheck = Check.Positive(request.Total, new ValidationError("Total must be positive")); if (totalCheck.IsFailure) return totalCheck.Error;
// ... create invoice}Configuration Validation
Section titled “Configuration Validation”public class SmtpSettings{ public SmtpSettings(string host, int port, string? username, string? password) { Host = Ensure.ThrowIfNullOrWhiteSpace(host); Ensure.ThrowIfOutOfRange(port, 1, 65535); Port = port;
// Optional fields: only validate format if present if (Ensure.IsNotNullOrEmpty(username)) { Username = username; Password = Ensure.ThrowIfNull(password); // If username is set, password is required } }}API Reference
Section titled “API Reference”Null Checks
Section titled “Null Checks”| Method | Throws | Description |
|---|---|---|
ThrowIfNull(value) | ArgumentNullException | Reference types. Returns the validated non-null value |
ThrowIfNull(value) | ArgumentNullException | Nullable value types. Returns the unwrapped value |
IsNotNull(value) | - | Returns true if not null |
String Checks
Section titled “String Checks”| Method | Throws | Description |
|---|---|---|
ThrowIfNullOrEmpty(value) | ArgumentException | Null or empty |
ThrowIfNullOrWhiteSpace(value) | ArgumentException | Null, empty, or whitespace |
ThrowIfLongerThan(value, max) | ArgumentException | Exceeds max length |
ThrowIfShorterThan(value, min) | ArgumentException | Below min length |
ThrowIfLengthOutOfRange(value, min, max) | ArgumentException | Outside length range |
ThrowIfNotEmail(value) | ArgumentException | Invalid email format |
ThrowIfNotUrl(value, httpsOnly) | ArgumentException | Invalid URL format |
ThrowIfNotPhone(value) | ArgumentException | Invalid phone format |
ThrowIfNotCreditCard(value) | ArgumentException | Invalid credit card (Luhn) |
ThrowIfNotMatch(value, pattern) | ArgumentException | Doesn’t match regex |
IsNotNullOrEmpty(value) | - | Returns true if valid |
IsNotNullOrWhiteSpace(value) | - | Returns true if valid |
IsLengthInRange(value, min, max) | - | Returns true if in range |
IsEmail(value) | - | Returns true if valid email |
IsUrl(value) | - | Returns true if valid URL |
IsPhone(value) | - | Returns true if valid phone |
IsCreditCard(value) | - | Returns true if valid card |
IsMatch(value, pattern) | - | Returns true if matches |
Numeric Checks
Section titled “Numeric Checks”| Method | Constraint | Throws | Description |
|---|---|---|---|
ThrowIfNegative(value) | INumber<T> | ArgumentOutOfRangeException | Value < 0 |
ThrowIfNegativeOrZero(value) | INumber<T> | ArgumentOutOfRangeException | Value <= 0 |
ThrowIfZero(value) | INumber<T> | ArgumentOutOfRangeException | Value == 0 |
ThrowIfPositive(value) | INumber<T> | ArgumentOutOfRangeException | Value > 0 |
ThrowIfOutOfRange(value, min, max) | IComparable<T> | ArgumentOutOfRangeException | Outside range |
ThrowIfGreaterThan(value, max) | IComparable<T> | ArgumentOutOfRangeException | Exceeds max |
ThrowIfLessThan(value, min) | IComparable<T> | ArgumentOutOfRangeException | Below min |
ThrowIfBelowMin(value, min) | IComparable<T> | ArgumentOutOfRangeException | Below min |
ThrowIfAboveMax(value, max) | IComparable<T> | ArgumentOutOfRangeException | Above max |
IsPositive(value) | INumber<T> | - | Returns true if > 0 |
IsNegative(value) | INumber<T> | - | Returns true if < 0 |
IsZero(value) | INumber<T> | - | Returns true if == 0 |
IsNotZero(value) | IComparable<T> | - | Returns true if != 0 |
IsNotNegative(value) | IComparable<T> | - | Returns true if >= 0 |
IsInRange(value, min, max) | IComparable<T> | - | Returns true if in range |
IsAtLeastMin(value, min) | IComparable<T> | - | Returns true if >= min |
IsAtMostMax(value, max) | IComparable<T> | - | Returns true if <= max |
Collection Checks
Section titled “Collection Checks”| Method | Throws | Description |
|---|---|---|
ThrowIfEmpty(collection) | ArgumentNullException / ArgumentException | Null or empty |
ThrowIfNullOrEmpty(collection) | ArgumentNullException / ArgumentException | Null or empty |
ThrowIfContainsNull(collection) | ArgumentNullException / ArgumentException | Null or contains null element |
ThrowIfCountGreaterThan(collection, max) | ArgumentOutOfRangeException | Count exceeds max |
ThrowIfCountLessThan(collection, min) | ArgumentOutOfRangeException | Count below min |
ThrowIfCountOutOfRange(collection, min, max) | ArgumentOutOfRangeException | Count outside range |
ThrowIfNotDefined(enumValue) | ArgumentOutOfRangeException | Undefined enum value |
IsNotNullOrEmpty(collection) | - | Returns true if has items |
Optimized overloads for: IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyCollection<T>, T[]
Other Checks
Section titled “Other Checks”| Method | Throws | Description |
|---|---|---|
ThrowIfEmpty(guid) | ArgumentException | Guid.Empty |
ThrowIfDefault(dateTime) | ArgumentOutOfRangeException | default(DateTime) |
ThrowIfInPast(dateTime) | ArgumentOutOfRangeException | Before now |
ThrowIfInFuture(dateTime) | ArgumentOutOfRangeException | After now |
ThrowIfTrue(condition, message?) | ArgumentException | Condition is true |
ThrowIfFalse(condition, message?) | ArgumentException | Condition is false |
IsNotEmpty(guid) | - | Returns true if not empty |
IsNotDefault(dateTime) | - | Returns true if not default |
IsPast(dateTime) | - | Returns true if in past |
IsFuture(dateTime) | - | Returns true if in future |
AreEqual(a, b) | - | Returns true if equal |
AreNotEqual(a, b) | - | Returns true if not equal |
IsDefined(enumValue) | - | Returns true if valid enum |
Check Methods (Pragmatic.Ensure.Result)
Section titled “Check Methods (Pragmatic.Ensure.Result)”Returns VoidResult<TError> — use for domain validation where failures are expected.
| Method | Description |
|---|---|
Check.NotNull(value, error) | Null check (reference and nullable value types) |
Check.NotNullOrEmpty(string, error) | String null/empty check |
Check.NotNullOrWhiteSpace(string, error) | String null/whitespace check |
Check.LengthInRange(string, min, max, error) | String length validation |
Check.Email(value, error) | Email format validation |
Check.Url(value, error, httpsOnly?) | URL format validation |
Check.Phone(value, error) | Phone format validation |
Check.Match(value, pattern, error) | Regex pattern match |
Check.Positive(value, error) | Value > 0 |
Check.NotNegative(value, error) | Value >= 0 |
Check.NotZero(value, error) | Value != 0 |
Check.InRange(value, min, max, error) | Value in range |
Check.AtLeast(value, min, error) | Value >= min |
Check.AtMost(value, max, error) | Value <= max |
Check.NotNullOrEmpty(collection, error) | Collection null/empty |
Check.ContainsNoNull(collection, error) | Collection contains null element |
Check.CountNotGreaterThan(collection, max, error) | Count exceeds max |
Check.CountNotLessThan(collection, min, error) | Count below min |
Check.NotEmpty(guid, error) | Guid not empty |
Check.Defined(enumValue, error) | Enum defined |
Check.That(condition, error) | Custom condition true |
Check.Not(condition, error) | Custom condition false |
Check.InPast(dateTime, error) | DateTime in past |
Check.InFuture(dateTime, error) | DateTime in future |
Check.NotDefault(dateTime, error) | DateTime not default |
Check.Equal(a, b, error) | Values equal |
Check.NotEqual(a, b, error) | Values not equal |
All methods support lazy error construction via Func<TError> overload.
String Methods: Null-Safety Contract
Section titled “String Methods: Null-Safety Contract”Format validation methods (ThrowIfNot*) and ThrowIfLengthOutOfRange are null-safe — they return without throwing when the value is null. This is designed for optional fields where presence is not required but format must be correct when present:
// Pattern: optional field with format validationstring? phone = request.Phone;Ensure.ThrowIfNotPhone(phone); // null -> OK (field is optional) // "" -> OK (empty is not invalid format) // "abc" -> throws ArgumentException // "+1234567890" -> OKPresence checks (ThrowIfNullOr*) and explicit-length checks (ThrowIfLongerThan, ThrowIfShorterThan) throw on null:
// Pattern: required fieldstring name = request.Name;Ensure.ThrowIfNullOrWhiteSpace(name); // null -> throws // " " -> throws // "Alice" -> OK| Method Category | Null Input Behavior |
|---|---|
ThrowIfNullOr* (presence) | Throws ArgumentNullException |
ThrowIfNot* (format) | Returns without throwing (null-safe) |
ThrowIfLongerThan / ThrowIfShorterThan | Throws ArgumentNullException |
ThrowIfLengthOutOfRange | Returns without throwing (null-safe) |
Performance
Section titled “Performance”All guard methods use [MethodImpl(MethodImplOptions.AggressiveInlining)] for zero overhead on the happy path:
// This compiles to essentially the same IL as a manual null checkprivate readonly IRepository _repo = Ensure.ThrowIfNull(repository);The [CallerArgumentExpression] attribute captures parameter names at compile time — no reflection, no string allocation. The string is embedded in the assembly metadata.
Feature Summary
Section titled “Feature Summary”| Problem | Solution |
|---|---|
| Inconsistent guard clause styles | One static class: Ensure |
| Wrong exception types for guards | ArgumentNullException, ArgumentException, ArgumentOutOfRangeException automatically |
Manual nameof() on every guard | [CallerArgumentExpression] captures parameter names automatically |
| Exceptions for business failures | Check.* returns VoidResult<TError> |
| No boolean checks without throwing | Ensure.Is* pattern returns bool |
| Guards don’t compose | Check.* supports .Bind() chaining |
| Allocation on happy path | [AggressiveInlining] + struct returns |
| Nullable flow analysis breaks | [NotNull] / [NotNullWhen] attributes |
| Collection validation boilerplate | ThrowIfEmpty, ThrowIfContainsNull, ThrowIfCountOutOfRange |
| Optional field format validation | ThrowIfNot* methods are null-safe by design |
Diagnostics
Section titled “Diagnostics”Pragmatic.Ensure is a pure runtime library — no source generator, no compile-time diagnostics. Range PRAG0100-0199 is reserved for future analyzers.
All guard methods throw standard .NET exceptions (ArgumentNullException, ArgumentException, ArgumentOutOfRangeException) with automatic parameter name capture via [CallerArgumentExpression].
Testing
Section titled “Testing”[Fact]public void ThrowIfNull_WithNull_ThrowsArgumentNullException(){ string? value = null; Assert.Throws<ArgumentNullException>(() => Ensure.ThrowIfNull(value));}
[Fact]public void ThrowIfNull_WithValue_ReturnsValue(){ var result = Ensure.ThrowIfNull("hello"); Assert.Equal("hello", result);}
[Fact]public void IsEmail_WithValidEmail_ReturnsTrue(){ Ensure.IsEmail("user@example.com").Should().BeTrue();}
[Fact]public void IsEmail_WithInvalidEmail_ReturnsFalse(){ Ensure.IsEmail("not-an-email").Should().BeFalse();}
[Fact]public void Check_NotNull_WithNull_ReturnsError(){ string? value = null; var result = Check.NotNull(value, () => new StringError("required"));
Assert.True(result.IsFailure);}
[Fact]public void Check_Positive_WithNegative_ReturnsError(){ var result = Check.Positive(-5m, new ValidationError("Amount must be positive")); result.IsFailure.Should().BeTrue();}Samples
Section titled “Samples”See samples/Pragmatic.Ensure.Samples/ for 5 runnable scenarios: constructor guards (null, empty, range), conditional logic with Is pattern, domain entity invariants, string format + numeric guards (Email, URL, Regex, Range, Positive), and collection/date/enum guards with fluent return pattern.
Documentation
Section titled “Documentation”- Concepts — Architecture, three patterns explained, when to use what
- Getting Started — Quick start guide
- API Reference — Complete method documentation
- Best Practices — When and how to use guards effectively
- Common Mistakes — Wrong/Right/Why for frequent errors
- Troubleshooting — Problem/checklist debugging guide
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Result | Check.* returns VoidResult<TError> |
| Pragmatic.Validation | Guards for preconditions, validation for business rules |
Requirements
Section titled “Requirements”- .NET 10.0 or later
License
Section titled “License”MIT