Implementing Validation


If you have completed domain mode, your application is almost ready. You can start solving problems that your modeled domain was designed for right after. Just put a thin layer of logic in an application service API reading user input and communicating it to the domain model scripting and orchestrating all the necessary calls to it. You can avoid too much ceremony and hook up all the UI input fields right to the parameters of the domain model methods.
If your domain is well-designed it will not allow for something bad to happen. It will maintain all the invariants and prevent any kind of system input abuse. But if you do that one thing will suffer and that thing is the user experience. Similarly, a child will suffer if he is given an electric toy car and that car does not have a cover or remote control and all of its wires are sticking out, and to be able to start the engine you need to connect two bare wires. Any mistake in the input of such a system will protect the domain by crashing the application on one of those many exception throws. Any mistake in operating such a car can lead to a burned fuse or break an electronic component. In both cases, you are fine. Your car will not explode, and you won’t get burned. The system won’t send unexpected emails or write incorrect data to the database because it fails fast. However, the user experience will dramatically deteriorate as that person will have to start from scratch.
Here is where the validation comes into play as a handy method to give a user a shield, a second chance, a protection so he can retry without going through the inconveniences of restarting the app (or buying another toy). It is up to you where to put that protection that prohibits calling domain methods before input looks good enough. The validation can be added on the client or server sides, or also on both sides and that is fine if we duplicate validation logic sometimes between too.
Validation does not only filter out bad input that can’t sift through that defense to reach a highly guarded core domain tier. Good validation also provides friendly hints, guidance, clues, and suggestions on how to fix the input that it passes through and engages domain logic without any friction. The domain logic should be very precise and limited and often the input controls allow too many varieties of inputs introducing the same issue as primitive types in a domain model. That is why to make your application successful among users, you need to work hard on changing the behavior of such controls to limit the manifold of all possible inputs according to your core domain expectations (i.e invariants) and to show helpful notifications to correct typing as soon as typo or error is detected. There is a huge space for creativity here as well as many 3d party UI frameworks.
Let’s see the evolution of code from no validation to good validation while writing code for the same use case of adding a customer to an insurance company system.
public class HomeInsuranceService
{
private readonly Database _database;
public HomeInsuranceService(Database database)
{
_database = database ?? throw new ArgumentNullException(nameof(database));
}
//parameters are passed from UI
public void CreateCustomer(
string firstName, string lastName,
string street, string city, string state, string zip,
string email,
DateTime dateOfBirth,
decimal annualIncome)
{
var customerId = CustomerID.NewCustomerID();
var customerName = CustomerName.Create(firstName, lastName);
var customerEmail = Email.Create(email);
var today = DateTime.Today;
var customerDateOfBirth = DateOfBirth.Create(dateOfBirth, today);
var customerHomeAddress = USHomeAddress.Create(street, city, state, zip);
var customerAnnualIncome = AnnualIncome.Create(annualIncome);
var customer = Customer.Create(customerId, customerName, customerHomeAddress, customerEmail, customerDateOfBirth, today, customerAnnualIncome);
_database.SaveCustomer(customer);
}
}
You can see that value objects and Customer entities are created via factory methods instead of using a constructor. That is a typical practice in DDD and is done for many reasons.
The factory method call is easier to read and does not include the programming language keyword “new.” It conveys the intent much better as it is closely related to the ubiquitous language of the domain experts.
The factory method allows for better encapsulation. Depending on the implementation details and object creation strategies, you can return a singleton, a new instance, or various subtypes introducing polymorphic behavior.
By using factory methods, you can separate the concerns of object creation from the object's behavior.
However, the most convenient feature of the factory method in Value Object is closely related to validation because they are a great mechanism for reusing guarding checks. But let’s see a naive method to add validation into our functionality creating a customer from the input that arrived straight from UI. Again our goal is to prevent exception throwing in case of a bad input and possibly add some good error notification so a user can get an idea of what to fix before the next retry. Exceptions are also bad due to an incredibly negative effect on general performance. Thus validation comes in pretty handy for achieving optimal performance as well.
public class Result(string error) { public string Error { get; } = error; public bool Success => string.IsNullOrEmpty(error); public static Result OK = new Result(null); public static Result Failure(string error) { if(string.IsNullOrEmpty(error)) throw new ArgumentNullException(nameof(error)); return new Result(error); } } public class HomeInsuranceService { public Result CreateCustomer( string firstName, string lastName, string street, string city, string state, string zip, string email, DateTime dateOfBirth, decimal annualIncome) { var customerId = CustomerID.NewCustomerID(); var result = CustomerName.CanCreate(firstName, lastName); if(!result.Success) return result; var customerName = CustomerName.Create(firstName, lastName); result = Email.CanCreate(email); if(!result.Success) return result; var customerEmail = Email.Create(email); /* Same approach for other value objects... */ var customer = Customer.Create(customerId, customerName, customerHomeAddress, customerEmail, customerDateOfBirth, today, customerAnnualIncome); _database.SaveCustomer(customer); return Result.OK; } } public record struct CustomerName { private const int MAX_NAME_LENGTH = 100; private const string NamePattern = @"^[A-Z][a-zA-Z]*$"; public string FirstName { get; } public string LastName { get; } private CustomerName(string firstName, string lastName) { if (string.IsNullOrWhiteSpace(firstName)) { throw new ArgumentException("First name cannot be null or whitespace.", nameof(firstName)); } if (string.IsNullOrWhiteSpace(lastName)) { throw new ArgumentException("Last name cannot be null or whitespace.", nameof(lastName)); } if (firstName.Length > MAX_NAME_LENGTH || lastName.Length > MAX_NAME_LENGTH) { throw new ArgumentException($"First and last names cannot exceed {MAX_NAME_LENGTH} characters."); } if (!IsValidName(firstName) || !IsValidName(lastName)) { throw new ArgumentException("Names must contain only letters and start with a capital letter."); } FirstName = firstName; LastName = lastName; } public static Result CanCreate(string firstName, string lastName) { if (string.IsNullOrWhiteSpace(firstName)) { return Result.Failure("First name cannot be null or whitespace."); } if (string.IsNullOrWhiteSpace(lastName)) { return Result.Failure("Last name cannot be null or whitespace."); } if (firstName.Length > MAX_NAME_LENGTH || lastName.Length > MAX_NAME_LENGTH) { return Result.Failure($"First and last names cannot exceed {MAX_NAME_LENGTH} characters."); } if (!IsValidName(firstName) || !IsValidName(lastName)) { return Result.Failure("Names must contain only letters and start with a capital letter."); } return Result.OK; } //default public CustomerName():this(null, null) { } public static CustomerName Create(string firstName, string lastName) { return new CustomerName(firstName, lastName); } public override string ToString() { return $"{FirstName} {LastName}"; } private static bool IsValidName(string name) { return Regex.IsMatch(name, NamePattern); } public static implicit operator string(CustomerName customerName) { return customerName.ToString(); } public static explicit operator CustomerName(string fullName) { var names = fullName.Split(' '); if (names.Length != 2) { throw new ArgumentException("Full name must contain exactly two parts: first name and last name."); } return Create(names[0], names[1]); } } public record struct Email { private const string EMAIL_PATTERN = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; private readonly string _value; private Email(string value) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("Email cannot be null or whitespace.", nameof(value)); } if (!IsValidEmail(value)) { throw new ArgumentException("Invalid email format.", nameof(value)); } _value = value; } public static Result CanCreate(string value) { if (string.IsNullOrWhiteSpace(value)) { return Result.Failure("Email cannot be null or whitespace."); } if (!IsValidEmail(value)) { return Result.Failure("Invalid email format."); } return Result.OK; } public Email():this(null) { } public static Email Create(string value) { return new Email(value); } public override string ToString() { return _value; } private static bool IsValidEmail(string email) { return Regex.IsMatch(email, EMAIL_PATTERN); } public static implicit operator string(Email email) { return email._value; } public static explicit operator Email(string email) { return Create(email); } }
That is quite an improvement as we introduced validating helper methods to tell whether the passed parameters are good to go. The validation result is reported in a Result object that encapsulates the outcome of a validation. The outcome must have error details in a non-empty string if validation fails. The logic only reaches the customer factory method only and only if all prior validation results represent success. We placed validation next to the constructor of Value Objects to show that they have the same guarding checks. However, validation could be placed somewhere else as it is strictly speaking a separate concern. There is no reason to not go DRY here though and make those guarding checks shareable for better maintainability and this is how our factory methods get to our rescue.
If we extend the Result class with a property that can optionally return a value, we can reuse validation code in invariant checks.
public class Result { private readonly string _error; public string Error { get { if (IsSuccess) throw new InvalidOperationException("No error when success"); return _error; } } public bool IsSuccess => string.IsNullOrEmpty(_error); protected Result(string error) { _error = error; } public static Result<T> Success<T>(T value) => new Result<T>(value, null); public static Result Success() => new Result(null); public static Result<T> Failure<T>(string error) => new Result<T>(default, error); public static Result Failure(string error) { if (string.IsNullOrEmpty(error)) throw new ArgumentNullException(nameof(error)); return new Result(error); } public override bool Equals(object obj) { if (obj is Result other) { return IsSuccess == other.IsSuccess && _error == other._error; } return false; } public override int GetHashCode() { return HashCode.Combine(IsSuccess, _error); } } public class Result<T> : Result { private readonly T _value; public T Value { get { if (!IsSuccess) throw new InvalidOperationException("No value when there is an error"); return _value; } } protected internal Result(T value, string error) : base(error) { _value = value; } public override bool Equals(object obj) { if (obj is Result<T> other) { return base.Equals(obj) && EqualityComparer<T>.Default.Equals(_value, other._value); } return false; } public override int GetHashCode() { return HashCode.Combine(base.GetHashCode(), _value); } }
Value Objects
public record struct CustomerName { private const int MAX_NAME_LENGTH = 100; private const string NamePattern = @"^[A-Z][a-zA-Z]*$"; public string FirstName { get; } public string LastName { get; } private CustomerName(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } private static Result CanCreate(string firstName, string lastName) { if (string.IsNullOrWhiteSpace(firstName)) { return Result.Failure("First name cannot be null or whitespace."); } if (string.IsNullOrWhiteSpace(lastName)) { return Result.Failure("Last name cannot be null or whitespace."); } if (firstName.Length > MAX_NAME_LENGTH || lastName.Length > MAX_NAME_LENGTH) { return Result.Failure($"First and last names cannot exceed {MAX_NAME_LENGTH} characters."); } if (!IsValidName(firstName) || !IsValidName(lastName)) { return Result.Failure("Names must contain only letters and start with a capital letter."); } return Result.Success(); } //default public CustomerName() : this(null, null) { } public static Result<CustomerName> Create(string firstName, string lastName) { var result = CanCreate(firstName, lastName); if (!result.IsSuccess) { return Result.Failure<CustomerName>(result.Error); } return Result.Success(new CustomerName(firstName, lastName)); } public override string ToString() { return $"{FirstName} {LastName}"; } private static bool IsValidName(string name) { return Regex.IsMatch(name, NamePattern); } public static implicit operator string(CustomerName customerName) { return customerName.ToString(); } public static explicit operator CustomerName(string fullName) { var names = fullName.Split(' '); if (names.Length != 2) { throw new ArgumentException("Full name must contain exactly two parts: first name and last name."); } var result = Create(names[0], names[1]); if(!result.IsSuccess) { throw new ArgumentException(result.Error); } return result.Value; } }
public record struct Email { private const string EMAIL_PATTERN = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; private readonly string _value; private Email(string value) { _value = value; } private static Result CanCreate(string value) { if (string.IsNullOrWhiteSpace(value)) { return Result.Failure("Email cannot be null or whitespace."); } if (!IsValidEmail(value)) { return Result.Failure("Invalid email format."); } return Result.Success(); } public Email() : this(null) { } public static Result<Email> Create(string value) { var result = CanCreate(value); if(!result.IsSuccess) { return Result.Failure<Email>(result.Error); } return Result.Success(new Email(value)); } public override string ToString() { return _value; } private static bool IsValidEmail(string email) { return Regex.IsMatch(email, EMAIL_PATTERN); } public static implicit operator string(Email email) { return email._value; } public static explicit operator Email(string email) { var result = Create(email); if (!result.IsSuccess) { throw new ArgumentException(result.Error); } return result.Value; } }
Customer Entity and Application Service called from UI layer (it could be WPF view model, Razor view or MVC/WebApi Controller)
public class Customer { public CustomerID ID { get; } public CustomerName Name { get; } public USHomeAddress Address { get; } public Email Email { get; } public DateOfBirth DateOfBirth { get; } public AnnualIncome AnnualIncome { get; } private Customer(CustomerID id, CustomerName name, USHomeAddress address, Email email, DateOfBirth dateOfBirth, AnnualIncome annualIncome) { this.ID = id; this.Name = name; this.Address = address; this.Email = email; this.DateOfBirth = dateOfBirth; this.AnnualIncome = annualIncome; } private const uint MIN_AGE = 18; private const decimal MIN_INCOME = 80_000; public static Result<Customer> Create(CustomerID id, CustomerName name, USHomeAddress address, Email email, DateOfBirth dateOfBirth, DateTime currentDate, AnnualIncome annualIncome) { if (dateOfBirth.GetAge(currentDate) < MIN_AGE) { return Result.Failure<Customer>($"Customer must be at least {MIN_AGE} years old."); } if (annualIncome.Value < MIN_INCOME) { return Result.Failure<Customer>($"Annual gross income should be at least ${MIN_INCOME}$."); } return Result.Success<Customer>(new Customer(id, name, address, email, dateOfBirth, annualIncome)); } } public class HomeInsuranceService { private readonly Database _database; public HomeInsuranceService(Database database) { _database = database ?? throw new ArgumentNullException(nameof(database)); } //parameters are passed from UI public Result CreateCustomer( string firstName, string lastName, string street, string city, string state, string zip, string email, DateTime dateOfBirth, decimal annualIncome) { var customerId = CustomerID.NewCustomerID(); var customerNameResult = CustomerName.Create(firstName, lastName); if (!customerNameResult.IsSuccess) return customerNameResult; var customerEmailResult = Email.Create(email); if (!customerEmailResult.IsSuccess) return customerEmailResult; var customerResult = Customer.Create(customerId, customerNameResult.Value, customerHomeAddressResult.Value, customerEmailResult.Value, customerDateOfBirthResult.Value, today, customerAnnualIncomeResult.Value); if (!customerResult.IsSuccess) return customerResult; _database.SaveCustomer(customerResult.Value); return Result.Success(); } }
Now that this code does not repeat any checks and still has the rigidity or vigilance on invalid input but with the added convenience of validation. Now, the client code or a user will be better off figuring out how to pass valid data to your domain model. We were able to combine validation with checking invariants because we made our domain mode API more condensed. CanCreate methods were removed from public access and Factory extended the return value to Result object. That is pretty much in line with our original motto to provide a bare minimum of what consumer code or users can do with your core domain API. It is still worth stressing one more time that validation and invariant checks are different concerns and it is fine to keep them separate in the code base. Validation can do more sophisticated analysis of data, but oftentimes they are close enough so you can try to combine them in the Result pattern without compromising on DDD rules. This Result design pattern is implementation details and does not ruin primary aspects of observable behavior that should keep objects (value or entity) always valid through the sequence of calls.
I would argue that in addition to the core validation in the Application Layer (i.e. in HomeInsuranceService), it may or even should be added on other architectural levels. The same set of rules can be also evaluated in the javascript code (client-side presentation layer) or be embedded in the WPF/Razor UI controls. These supplemental validations are not meant to replace the core validation and should not be considered as duplicated code. Those layers are designed to enhance users’ experience by giving feedback faster and using more engaging communication. Sometimes technologies such as ASP.NET have gimmicks to share validation code (using various pre-built validator components) but generally, that is not shareable and you don’t need to push for being DRY here. However, some sensible shareable bits would be beneficial. You can reuse a regex expression constants and bind it through code behind or binding expression. Whatever is practical is fine but generally not required.
By looking at the above implementation you can see a “smell” in validation. Formatting or providing the error message is not one of its designated concerns. The message belongs to the Presentation Layer as it knows exactly how to display, format, and present an error message or notification. The application can support multiple languages after all and our domain layer should be language-agnostic in that sense. I will show how this aspect is addressed in “clean” Validation in the next article.
Subscribe to my newsletter
Read articles from Dmitry Dezuk (Dezhurnyuk) directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Dmitry Dezuk (Dezhurnyuk)
Dmitry Dezuk (Dezhurnyuk)
Senior Software Developer with over 10 years of performance-focused .NET development experience. I assist developers in solving architectural challenges and simplifying complex software projects. Writing as Dmitry Dezuk to share everyday productivity tips for developing faster and more reliable software.