| name | parse-dont-validate |
| description | USE FOR: Designing domain models where invalid states are unrepresentable by parsing raw input into strongly-typed value objects at system boundaries. Use when enforcing invariants through the type system rather than runtime validation checks.
DO NOT USE FOR: Quick prototypes where validation overhead is premature, thin CRUD layers that pass DTOs directly to the database, or scenarios where FluentValidation with error collection is required for user-facing form feedback.
|
| license | MIT |
| metadata | {"displayName":"Parse Don't Validate","author":"Tyler-R-Kendrick","version":"1.0.0"} |
| compatibility | ["claude","copilot","cursor"] |
| references | [{"title":"Parse, Don't Validate - Original Article by Alexis King","url":"https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/"},{"title":"Parse, Don't Validate - DevIQ Reference","url":"https://deviq.com/practices/parse-dont-validate/"}] |
Parse Don't Validate
Overview
"Parse, Don't Validate" is a design principle from functional programming (coined by Alexis King) that advocates transforming unstructured or weakly-typed input into strongly-typed domain objects at system boundaries. Instead of validating a string and then passing the same string through the system with the hope that it remains valid, you parse it into a type that makes invalid states unrepresentable. Once parsed, the type guarantees correctness for all downstream consumers, eliminating redundant validation and reducing the surface area for bugs. In C#, this is implemented using value objects with private constructors, factory methods, and result types.
Basic Value Object with Factory Method
Create a value object with a private constructor and a static TryCreate or Create factory that returns a result type.
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace MyApp.Domain.ValueObjects;
public sealed partial class EmailAddress : IEquatable<EmailAddress>
{
private static readonly Regex Pattern = ValidEmailRegex();
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static bool TryCreate(string? input, [NotNullWhen(true)] out EmailAddress? result)
{
result = null;
if (string.IsNullOrWhiteSpace(input) || input.Length > 254)
return false;
if (!Pattern.IsMatch(input))
return false;
result = new EmailAddress(input.Trim().ToLowerInvariant());
return true;
}
public static EmailAddress Create(string input)
{
(!TryCreate(input, email))
ArgumentException(, (input));
email;
}
=> Value;
=> Value.GetHashCode(StringComparison.Ordinal);
=> Equals(obj EmailAddress);
=> other && Value == other.Value;
=> email.Value;
[]
;
}
Result Type for Parsing Outcomes
Use a result type to propagate parse failures without exceptions, allowing callers to handle errors gracefully.
namespace MyApp.Domain;
public readonly struct Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess { get; }
private Result(T value) { Value = value; IsSuccess = true; Error = null; }
private Result(string error) { Value = default; IsSuccess = false; Error = error; }
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error) => new(error);
public TOut Match<TOut>(Func<T, TOut> onSuccess, Func<string, TOut> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}
Multiple Value Objects Composed into a Domain Model
Parse all inputs at the boundary and compose them into a domain aggregate that is guaranteed valid.
namespace MyApp.Domain.ValueObjects;
public sealed class PhoneNumber
{
public string Value { get; }
private PhoneNumber(string value) => Value = value;
public static Result<PhoneNumber> Parse(string? input)
{
if (string.IsNullOrWhiteSpace(input))
return Result<PhoneNumber>.Failure("Phone number is required.");
var digits = new string(input.Where(char.IsDigit).ToArray());
if (digits.Length < 10 || digits.Length > 15)
return Result<PhoneNumber>.Failure(
$"Phone number must be 10-15 digits, got {digits.Length}.");
return Result<PhoneNumber>.Success(new PhoneNumber(digits));
}
public override string ToString() => Value;
}
public sealed class NonEmptyString
{
public string Value { get; }
=> Value = ;
{
(.IsNullOrWhiteSpace(input))
Result<NonEmptyString>.Failure();
Result<NonEmptyString>.Success( NonEmptyString(input.Trim()));
}
=> Value;
=> s.Value;
}
{
Value { ; }
=> Value = ;
{
(input <= )
Result<PositiveAmount>.Failure(
);
Result<PositiveAmount>.Success( PositiveAmount(input));
}
=> a.Value;
}
Parsing at the API Boundary
Parse raw DTO input into domain value objects at the controller or endpoint level.
using Microsoft.AspNetCore.Mvc;
namespace MyApp.Api;
public record CreateCustomerDto(
string Name,
string Email,
string Phone,
decimal CreditLimit);
public record CustomerCreated(int Id, string Name, string Email);
app.MapPost("/api/customers", (
CreateCustomerDto dto,
ICustomerService service) =>
{
var nameResult = NonEmptyString.Parse(dto.Name, "Name");
var emailResult = EmailAddress.TryCreate(dto.Email, out var email)
? Result<EmailAddress>.Success(email)
: Result<EmailAddress>.Failure("Invalid email address.");
var phoneResult = PhoneNumber.Parse(dto.Phone);
var creditResult = PositiveAmount.Parse(dto.CreditLimit);
var errors = new List<string>();
if (!nameResult.IsSuccess) errors.Add(nameResult.Error!);
if (!emailResult.IsSuccess) errors.Add(emailResult.Error!);
if (!phoneResult.IsSuccess) errors.Add(phoneResult.Error!);
if (!creditResult.IsSuccess) errors.Add(creditResult.Error!);
if (errors.Count > 0)
{
return Results.BadRequest(new { Errors = errors });
}
customer = Customer(
name: nameResult.Value!,
email: emailResult.Value!,
phone: phoneResult.Value!,
creditLimit: creditResult.Value!);
created = service.Create(customer);
Results.Created(, created);
});
Parse Don't Validate vs Validate-and-Pass
| Aspect | Parse Don't Validate | Validate-and-Pass |
|---|
| Type safety | Invariants in type system | Invariants in comments/docs |
| Re-validation needed | Never (type guarantees) | At every layer boundary |
| Invalid state possible | No (private constructor) | Yes (mutable string/int) |
| Error handling | Result type or exception | Exception or error list |
| Downstream trust | Full (type proves validity) | Trust-but-verify |
| Refactoring safety | Compiler catches misuse | Runtime failures |
| Boilerplate | More types upfront | Less types, more checks |
| Testing | Test parser once | Test every consumer |
Best Practices
-
Make value object constructors private and expose a Parse, TryCreate, or Create factory method so that the only way to obtain an instance is through the validation path; a public constructor allows anyone to create invalid instances, defeating the purpose of the pattern.
-
Return a Result<T> from Parse methods instead of throwing exceptions for expected failure cases (user input, external data), because exceptions are expensive and should be reserved for unexpected programmer errors; use Create that throws only when invalid input represents a bug.
-
Implement IEquatable<T>, override Equals, and override GetHashCode on all value objects using the underlying value, so that two EmailAddress instances with the same parsed value are considered equal in collections, dictionary lookups, and LINQ operations.
-
Parse all inputs at the system boundary (API controller, message handler, CLI parser) and pass only parsed domain types to the service and domain layers; if a service method accepts string email instead of EmailAddress email, any caller can bypass validation.
-
Use implicit operator conversions from the value object to its primitive type (e.g., public static implicit operator string(EmailAddress e) => e.Value;) for read-only access, but never define an implicit conversion from primitive to value object, as that would bypass the parsing step.
-
Create a small set of reusable generic value objects (NonEmptyString, PositiveInt, BoundedString<TMin, TMax>, PositiveAmount) rather than creating hundreds of domain-specific types for every field; compose these building blocks to cover most validation needs.
-
Store parsed value objects in EF Core entities using HasConversion in OnModelCreating to map EmailAddress.Value to a nvarchar column, so the database schema uses primitive types while the C# domain model uses parsed types; this avoids losing type safety at the persistence boundary.
-
Collect all parse errors before returning to the client by evaluating all instances and aggregating failures into a single error response, rather than failing fast on the first invalid field; users expect to see all form errors at once, not one at a time.