Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.Ensure exists, how its three patterns work together, and how to choose the right one for each situation. Read this before diving into the API reference.


Every .NET codebase needs to validate inputs. Without a consistent approach, guard clauses drift into a patchwork of styles that are hard to read, hard to test, and easy to get wrong.

Scattered null checks with inconsistent styles

Section titled “Scattered null checks with inconsistent styles”
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger<OrderService> _logger;
public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
// Developer A: manual throw
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
// Developer B: forgot the guard entirely
_logger = logger;
}
public async Task<Order> GetOrderAsync(Guid id)
{
// Developer C: guard-style method but wrong exception
if (id == Guid.Empty) throw new InvalidOperationException("Id cannot be empty");
var order = await _repository.GetByIdAsync(id);
// Developer D: exception for a business case (user not found)
if (order is null) throw new KeyNotFoundException($"Order {id} not found");
return order;
}
}

Four developers, four patterns. The code has at least three problems:

  1. Inconsistent exception types. InvalidOperationException for a parameter check instead of ArgumentException. A production log full of mixed exception types makes triage harder.
  2. Missing guard. _logger is never validated — a NullReferenceException will surface later, far from the root cause.
  3. Exception for business logic. “Order not found” is an expected outcome, not a programming error. Throwing an exception hides the error path from the type system and always produces 500 in an HTTP context.

No distinction between programming errors and expected failures

Section titled “No distinction between programming errors and expected failures”

The standard library provides ArgumentNullException, ArgumentException, and ArgumentOutOfRangeException, but deciding when to throw versus when to return an error object is left entirely to convention. The result is codebases where exceptions are used for everything — bugs and business logic alike — making it impossible to distinguish “this should never happen” from “the user entered an invalid email.”

// Rename 'repository' to 'repo' and forget to update the string:
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
// After rename:
_repo = repo ?? throw new ArgumentNullException(nameof(repository)); // Wrong name!

With nameof, this specific case is safe. But many teams still pass literal strings, and nameof adds visual noise to every guard clause.


Pragmatic.Ensure provides a single static class with three distinct patterns, each designed for a specific category of validation:

PatternWhen to useReturnsOn failure
Ensure.ThrowIf*Programming errors (bugs)The validated value (or void)Throws ArgumentException
Ensure.Is*Conditional logicboolReturns false
Check.*Business validation (expected failures)VoidResult<TError>Returns typed error

The same “validate an order” scenario, rewritten:

public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
// Guards validate and 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)
{
// Programming error: empty GUID should never reach here
Ensure.ThrowIfEmpty(id);
var order = await _repository.GetByIdAsync(id);
// Business case: order not found is expected, return typed error
if (order is null)
return new NotFoundError("Order", id);
return order;
}
}

Every guard uses the same API. Parameter names are captured automatically by [CallerArgumentExpression]. Programming errors throw exceptions. Business failures return typed errors through the Result pattern.


All methods are marked [MethodImpl(MethodImplOptions.AggressiveInlining)]. On the happy path (validation passes), the JIT inlines the check into a single branch instruction with no allocation. The guard is effectively free.

// What you write:
Ensure.ThrowIfNull(repository);
// What the JIT produces (approximately):
if (repository == null) throw new ArgumentNullException("repository");

Every ThrowIf* method has a [CallerArgumentExpression] parameter that the compiler fills in automatically:

public static T ThrowIfNull<T>(
[NotNull] T? value,
[CallerArgumentExpression(nameof(value))] string? paramName = null)
where T : class

This means:

Ensure.ThrowIfNull(repository);
// Exception message: "Value is null. (Parameter 'repository')"
// The compiler fills in "repository" — no magic strings needed.

The ThrowIf* methods use [NotNull] and the Is* methods use [NotNullWhen(true)] to integrate with C#‘s nullable reference type analysis:

string? name = GetName();
// Before the check, the compiler treats 'name' as nullable.
if (Ensure.IsNotNull(name))
{
// After the check, the compiler knows 'name' is not null.
// No warning on name.Length.
Console.WriteLine(name.Length);
}
// ThrowIfNull uses [NotNull] — after the call, the compiler
// treats the value as non-null for the rest of the scope.
Ensure.ThrowIfNull(name);
Console.WriteLine(name.Length); // No nullable warning.

ThrowIf Pattern: Guard Clauses for Programming Errors

Section titled “ThrowIf Pattern: Guard Clauses for Programming Errors”

ThrowIf* methods validate preconditions — conditions that must be true for the code to function correctly. A failure means the caller has a bug.

ThrowIfNull returns the validated non-null value, enabling fluent assignment:

public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
// Validate and assign in one statement
private readonly IOrderRepository _repository = Ensure.ThrowIfNull(repository);
private readonly ILogger<OrderService> _logger = Ensure.ThrowIfNull(logger);
}

All other ThrowIf* methods return void. They are fire-and-forget guards:

public void SetDiscount(decimal percentage)
{
Ensure.ThrowIfOutOfRange(percentage, 0m, 100m); // void — just validates
_discount = percentage;
}

Each method throws the most specific exception for the validation it performs:

Method patternException
ThrowIfNull*ArgumentNullException
ThrowIfNegative*, ThrowIfZero, ThrowIfPositive, ThrowIfOutOfRange, ThrowIfGreaterThan, ThrowIfLessThanArgumentOutOfRangeException
ThrowIfNotDefined (enum)ArgumentOutOfRangeException
ThrowIfDefault, ThrowIfInPast, ThrowIfInFutureArgumentOutOfRangeException
ThrowIfCountGreaterThan, ThrowIfCountLessThan, ThrowIfCountOutOfRangeArgumentOutOfRangeException
All other ThrowIf*ArgumentException

String methods fall into two categories:

Methods that throw on null — use these when a value is required:

MethodOn null
ThrowIfNullOrEmptyThrows ArgumentException
ThrowIfNullOrWhiteSpaceThrows ArgumentException
ThrowIfLongerThanThrows ArgumentNullException (via internal ThrowIfNull)
ThrowIfShorterThanThrows ArgumentNullException (via internal ThrowIfNull)

Null-safe methods — use these for optional fields where null is valid but, if a value is present, it must satisfy the constraint:

MethodOn null
ThrowIfLengthOutOfRangeReturns without throwing
ThrowIfNotEmailReturns without throwing
ThrowIfNotUrlReturns without throwing
ThrowIfNotPhoneReturns without throwing
ThrowIfNotCreditCardReturns without throwing
ThrowIfNotMatchReturns without throwing

Rule of thumb: Format validation methods (ThrowIfNot*) and range checks (ThrowIfLengthOutOfRange) are null-safe. Presence checks (ThrowIfNullOr*) and explicit-length checks (ThrowIfLongerThan, ThrowIfShorterThan) throw on null.

All ThrowIf* collection methods throw ArgumentNullException when the collection itself is null. A null collection is always considered a programming error.

Numeric methods use INumber<T> (for sign checks) or IComparable<T> (for range checks), so they work with any numeric type:

Ensure.ThrowIfNegative(42); // int
Ensure.ThrowIfNegative(3.14); // double
Ensure.ThrowIfNegative(99.99m); // decimal
Ensure.ThrowIfOutOfRange(age, 0, 150); // int
Ensure.ThrowIfOutOfRange(price, 0m, 10_000m); // decimal

DateTime and DateTimeOffset variants exist for temporal checks. For DateTime, local values are converted to UTC before comparison; DateTimeKind.Unspecified is treated as UTC.

Ensure.ThrowIfDefault(startDate);
Ensure.ThrowIfInPast(expirationDate);
Ensure.ThrowIfInFuture(birthDate);
// DateTimeOffset variants
Ensure.ThrowIfDefault(createdAt); // DateTimeOffset
Ensure.ThrowIfInPast(validUntil); // DateTimeOffset

Is Pattern: Boolean Checks for Conditional Logic

Section titled “Is Pattern: Boolean Checks for Conditional Logic”

Is* methods return bool and never throw. They are designed for situations where invalid data is expected and the code should branch on it.

Methods that check for null use [NotNullWhen(true)], so the compiler narrows the type after a successful check:

string? email = GetEmailFromInput();
if (Ensure.IsEmail(email))
{
// Compiler knows email is not null here
SendVerification(email);
}

Is* methods follow two conventions:

Methods that return false for null — these validate presence:

MethodOn null
IsNotNullfalse
IsNotNullOrEmptyfalse
IsNotNullOrWhiteSpacefalse
IsEmailfalse
IsPhonefalse
IsUrlfalse
IsCreditCardfalse
IsNotEmpty (Guid)Not applicable (Guid is a struct)
IsNotNullOrEmpty (collection)false

Methods that return true for null — these are null-safe for optional values:

MethodOn null
IsLengthInRangetrue
IsMatchtrue

Use standard boolean operators to compose multiple checks:

if (Ensure.IsNotNullOrEmpty(name) && Ensure.IsLengthInRange(name, 2, 50))
{
ProcessName(name);
}
// Filter invalid items without throwing
var validItems = items.Where(i =>
Ensure.IsNotNullOrWhiteSpace(i.Name) &&
Ensure.IsPositive(i.Price));

Check Pattern: Domain Validation with Typed Errors

Section titled “Check Pattern: Domain Validation with Typed Errors”

The Check.* methods (from the Pragmatic.Ensure.Result package) bridge guard-style validation with the Result pattern. They return VoidResult<TError> — a value that is either success or a typed error.

Check.* depends on Pragmatic.Result (for VoidResult<TError> and IError). The core Pragmatic.Ensure package has zero dependencies. If you do not need the Result integration, you do not pay for it.

using Pragmatic.Ensure.Result;
// Single check
var result = Check.NotNull(user, new NotFoundError("User", userId));
if (result.IsFailure)
return result.Error;

Chain multiple checks using .Bind(). The first failure short-circuits the chain:

var result = Check.NotNullOrWhiteSpace(dto.Name, new ValidationError("Name required"))
.Bind(_ => Check.Email(dto.Email, new ValidationError("Invalid email")))
.Bind(_ => Check.InRange(dto.Age, 18, 120, new ValidationError("Age must be 18-120")));
if (result.IsFailure)
return result.Error;

Every Check.* method has two overloads: one accepting the error directly, and one accepting a Func<TError> factory. The factory is only invoked when validation fails:

// Eager: error is always constructed (even if check passes)
var result = Check.NotNull(user, new NotFoundError("User", userId));
// Lazy: error is constructed only on failure
var result = Check.NotNull(user, () => new NotFoundError("User", userId));

When to use each:

ScenarioUse
Error is a simple struct or singletonEager — no allocation benefit
Error construction involves string interpolationLazy — avoids string formatting
Error requires database/API lookupLazy — avoids unnecessary I/O
Error constructor is expensiveLazy — defers cost to failure path
Hot path with high success rateLazy — zero alloc on success

All Check.* methods constrain the error type to where TError : IError. This ensures that Check methods produce errors compatible with the Result<T, TError> pipeline used throughout the Pragmatic ecosystem.


When to Use Ensure vs Result vs Pragmatic.Validation

Section titled “When to Use Ensure vs Result vs Pragmatic.Validation”

Three modules can validate data. They serve different purposes:

QuestionAnswerUse
Is the caller violating a contract (bug)?YesEnsure.ThrowIf*
Do you need a quick true/false for branching?YesEnsure.Is*
Is this domain/business validation with typed errors?YesCheck.*
Is this input validation on an endpoint/action with attributes?YesPragmatic.Validation
AspectThrowIf*Check.*
Failure meaningBug in the callerExpected invalid data
Failure behaviorThrows exceptionReturns VoidResult<TError>
Where to useConstructor guards, public API boundariesService methods, domain logic
Error detailException message (for developers)Typed IError (for callers/users)
ComposabilityNot composable.Bind() chains
PackagePragmatic.EnsurePragmatic.Ensure.Result
AspectCheck.*Pragmatic.Validation
ScopeInline, ad-hoc checksDeclarative, attribute-driven
IntegrationManual — call in your methodAutomatic — SG generates validators, pipeline runs before handler
Best forService-layer validation, complex conditional rulesDTO/input validation with [Required], [Email], [MinLength]
Error typeAny IErrorValidationError (standard)

In practice, many applications use all three:

// Endpoint layer: Pragmatic.Validation runs automatically on the DTO
// (attribute-driven, before HandleAsync)
// Service layer: Check.* for business rules
public VoidResult<ValidationError> ValidateTransfer(Account from, Account to, decimal amount)
{
return Check.NotNull(from, new ValidationError("Source account required"))
.Bind(_ => Check.NotNull(to, new ValidationError("Target account required")))
.Bind(_ => Check.That(from.Id != to.Id, new ValidationError("Cannot transfer to the same account")));
}
// Infrastructure layer: Ensure.ThrowIf* for internal contracts
public class TransferService(IAccountRepository accounts)
{
private readonly IAccountRepository _accounts = Ensure.ThrowIfNull(accounts);
}

Collection methods provide optimized overloads for different collection interfaces. The compiler selects the most specific overload automatically:

InterfaceHow count is obtained
IEnumerable<T>Calls .Any() (may enumerate)
ICollection<T>Uses .Count property (O(1))
IList<T>Uses .Count property (O(1))
IReadOnlyCollection<T>Uses .Count property (O(1))
T[]Uses .Length property (O(1))

If your collection implements ICollection<T> (as List<T>, HashSet<T>, and most standard collections do), the optimized overload is selected automatically — no enumeration occurs.


Uses System.Net.Mail.MailAddress for parsing. Rejects the "Display Name <email>" form — only bare local@domain.tld is accepted. This is more robust than a regex but does not validate RFC 5322 fully (no IP literals, no quoted strings).

Pre-compiled regex with 250ms ReDoS timeout. After format validation, verifies E.164-compatible digit count (7-15 digits). Accepts international formats: +1 555 123 4567, (02) 1234-5678, +39.02.1234567. Does not validate country codes or per-country number lengths.

Uses Uri.TryCreate(value, UriKind.Absolute). Accepts http:// and https:// schemes. Set requireHttps: true to accept only https://. Rejects relative URIs and non-HTTP schemes (ftp, file, etc.).

Luhn algorithm (ISO/IEC 7812). Strips spaces and hyphens before validation. Accepts 13-19 digit cards. Validates checksum only — does not verify issuer or account existence.

All regex-based validations use a 250ms timeout. If a regex times out:

  • ThrowIf* methods throw ArgumentException
  • Is* methods return false
  • Check.* methods return failure

A tracing event is emitted via ActivitySource("Pragmatic.Ensure") with tags: ensure.validation_type, ensure.input_length, ensure.timeout_ms.


ScenarioAllocationCost
ThrowIfNull — happy pathZeroOne null check, inlined
ThrowIfNull — failureOne ArgumentNullExceptionException construction + throw
IsEmail — happy pathOne MailAddressMailAddress parsing
IsEmail — failureZero (caught FormatException)try/catch, no alloc on failure
Check.NotNull — happy pathZeroOne null check + VoidResult<T>.Success()
Check.NotNull with lazy error — happy pathZeroFactory delegate is not invoked
ThrowIfNotPhone — happy pathZeroCompiled regex match
Numeric methodsZeroSingle comparison, inlined

All ThrowIf* and Is* methods on the happy path are zero-allocation. The only allocation occurs when an exception is thrown or when an Is* method creates temporary objects for validation (e.g., MailAddress for email).


  1. Exceptions for bugs, Results for business logic. ThrowIf* signals a contract violation that should never happen in correct code. Check.* signals an expected failure that the caller should handle.

  2. No allocation on the happy path. Guard clauses run on every call. They must be free when validation passes.

  3. Compiler-friendly. [NotNull], [NotNullWhen], and [CallerArgumentExpression] integrate with the compiler’s flow analysis and eliminate the need for magic strings.

  4. Self-documenting. Ensure.ThrowIfNegativeOrZero(amount) reads as a specification. The method name is the documentation.

  5. Zero dependencies. The core Pragmatic.Ensure package depends on nothing. It is a Layer 0 foundation library that every other module can reference.


PackageContentsDependencies
Pragmatic.EnsureEnsure.ThrowIf*, Ensure.Is*None
Pragmatic.Ensure.ResultCheck.*Pragmatic.Result

Install only what you need. If you do not use the Result pattern, Pragmatic.Ensure alone covers all guard clause and boolean check needs.