Implementing Cleaner Validation in DDD

In the previous article, I described one approach to validation in DDD. Specifically, I showed how it applies the Result pattern in factory methods of Value Objects to be able to reuse the same guarding checks maintaining their invariants. A drawback in that solution is that the factory methods returning Result<T> have to supply error messages if passed parameters don’t meet pre-conditions for a valid Value Object. Rather you would want to use the error messages in the UI to give a hint or a warning to a user so they can self-correct their input. Thus that validation mixes UI concerns into the core domain while the error/warning/notification communication should typically belong to a presentation or another thin outer layer of an application where you need flexibility to format, visualize, or localize such messages.

One way to factor out the alien responsibility is to use error codes in Validation instead. Those error codes could be nicely organized in the collection of properties of a static class. I will also change the Error property name in Result to ErrorCode to convey my intent. (As you remember you can only improve readability and maintainability if you use maximum explicitness).

public class Result
{
    private readonly string _errorCode;

    public string ErrorCode
    {
        get
        {
            if (IsSuccess)
                throw new InvalidOperationException("No error when success");
            return _errorCode;
        }
    }

    public bool IsSuccess => string.IsNullOrEmpty(_errorCode);

    protected Result(string error)
    {
        _errorCode = 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 && _errorCode == other._errorCode;
        }
        return false;
    }

    public override int GetHashCode()
    {
        return _errorCode?.GetHashCode() ?? 0;
    }
}

public static class Error
{
    public static class InEmail
    {
        public static Result EmailNullOrWhitespace { get; } = Result.Failure("EMAIL_NULL_OR_WHITESPACE");
        public static Result InvalidEmailFormat { get; } = Result.Failure("INVALID_EMAIL_FORMAT");
    }

    public static class InCustomerName
    {
        private const string LAST_NAME = "LAST_NAME";
        public static Result LastNameNullOrWhitespace { get; } = NameNullOrWhitespace(LAST_NAME);
        public static Result LastNameMaxLengthExceeded { get; } = NameMaxLengthExceeded(LAST_NAME);
        public static Result LastNameHasInvalidCharacters { get; } = NameHasInvalidCharacters(LAST_NAME);


        private const string FIRST_NAME = "FIRST_NAME";
        public static Result FirstNameNullOrWhitespace { get; } = NameNullOrWhitespace(FIRST_NAME);
        public static Result FirstNameMaxLengthExceeded { get; } = NameMaxLengthExceeded(FIRST_NAME);
        public static Result FirstNameHasInvalidCharacters { get; } = NameHasInvalidCharacters(FIRST_NAME);

        private static Result NameNullOrWhitespace(string namePart) => Result.Failure($"{namePart}_NULL_OR_WHITESPACE");
        private static Result NameMaxLengthExceeded(string namePart) => Result.Failure($"{namePart}_MAX_LENGTH_EXCEEDED");
        private static Result NameHasInvalidCharacters(string namePart) => Result.Failure($"{namePart}_HAS_INVALID_CHARACTERS");
    }
}

Given those changes I can easily rewrite the factory methods as follows

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 Error.InEmail.EmailNullOrWhitespace;
        }

        if (!IsValidEmail(value))
        {
            return Error.InEmail.InvalidEmailFormat;
        }

        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.ErrorCode);
        }

        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.ErrorCode);
        }

        return result.Value;
    }
}

//----------------------------------------------------

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 Error.InCustomerName.FirstNameNullOrWhitespace;
        }

        if (string.IsNullOrWhiteSpace(lastName))
        {
            return Error.InCustomerName.LastNameNullOrWhitespace;
        }

        if (firstName.Length > MAX_NAME_LENGTH)
        {
            return Error.InCustomerName.FirstNameMaxLengthExceeded;
        }

        if (lastName.Length > MAX_NAME_LENGTH)
        {
            return Error.InCustomerName.LastNameMaxLengthExceeded;
        }

        if (!IsValidName(firstName))
        {
            return Error.InCustomerName.FirstNameHasInvalidCharacters;
        }

        if (!IsValidName(lastName))
        {
            return Error.InCustomerName.LastNameHasInvalidCharacters;
        }

        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.ErrorCode);
        }
        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.ErrorCode);
        }

        return result.Value;
    }
}

To convert error code to formatted message we can introduce an extension helper method in UI or other outer layer communicating with the external world. The below is just the simplest implementation possible. You can add infinite logic here to format, localize, or discriminate the messages on various conditions.

    public static class UserErrorMessages
    {
        private static string CannotBeNullOrEmpty(string propertyName) => $"{propertyName} cannot be null or whitespace";
        private static string TooLong(string propertyName) => $"{propertyName} too long";
        private static string NotInValidFormat(string propertyName) => $"{propertyName} is not in valid format.";

        private static Dictionary<Result, string> _errorMessageMappings = new Dictionary<Result, string>()
        {
             { Error.InEmail.EmailNullOrWhitespace, CannotBeNullOrEmpty("Email") },
             { Error.InEmail.EmailNullOrWhitespace, NotInValidFormat("Email") },

             { Error.InCustomerName.FirstNameNullOrWhitespace, CannotBeNullOrEmpty("First Name") },
             { Error.InCustomerName.FirstNameMaxLengthExceeded, TooLong("First Name") },
             { Error.InCustomerName.FirstNameHasInvalidCharacters, NotInValidFormat("First Name") },

             { Error.InCustomerName.LastNameNullOrWhitespace, CannotBeNullOrEmpty("Last Name") },
             { Error.InCustomerName.LastNameMaxLengthExceeded, TooLong("Last Name") },
             { Error.InCustomerName.LastNameHasInvalidCharacters, NotInValidFormat("Last Name") },
        };

        public static string GetErrorMessage(this Result result) => _errorMessageMappings[result];
    }

Finally, let’s review the sample presentation logic of a typical WPF application. The presentation logic is typically encapsulated in a view model.

public class CreateCustomerViewModel : INotifyPropertyChanged
{
    private readonly HomeInsuranceService _insuranceService;
    private string _firstName;
    private string _lastName;
    private string _email;
    private string _errorMessage;
    private bool _isProcessing;

    // Implementation of INotifyPropertyChanged for UI updates
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    public CreateCustomerViewModel(HomeInsuranceService insuranceService)
    {
        _insuranceService = insuranceService ?? throw new ArgumentNullException(nameof(insuranceService));

        // Initialize the create customer command
        CreateCustomerCommand = new RelayCommand(
            execute: async () => await CreateCustomerAsync(),
            canExecute: () => CanCreateCustomer()
        );
    }

    // Properties with validation and UI notification
    public string FirstName
    {
        get => _firstName;
        set
        {
            if (_firstName != value)
            {
                _firstName = value;
                OnPropertyChanged(nameof(FirstName));

            }
        }
    }

    public string LastName
    {
        get => _lastName;
        set
        {
            if (_lastName != value)
            {
                _lastName = value;
                OnPropertyChanged(nameof(LastName));

            }
        }
    }


    public string Email
    {
        get => _email;
        set
        {
            if (_email != value)
            {
                _email = value;
                OnPropertyChanged(nameof(Email));

            }
        }
    }

    public string ErrorMessage
    {
        get => _errorMessage;
        private set
        {
            if (_errorMessage != value)
            {
                _errorMessage = value;
                OnPropertyChanged(nameof(ErrorMessage));
            }
        }
    }

    public bool IsProcessing
    {
        get => _isProcessing;
        private set
        {
            if (_isProcessing != value)
            {
                _isProcessing = value;
                OnPropertyChanged(nameof(IsProcessing));

            }
        }
    }


    public ICommand CreateCustomerCommand { get; }

    private bool CanCreateCustomer()
    {
        if (IsProcessing) return false;

        return !string.IsNullOrWhiteSpace(FirstName) &&
               !string.IsNullOrWhiteSpace(LastName) &&
               !string.IsNullOrWhiteSpace(Email)
    }

    // Async method to handle customer creation
    private async Task CreateCustomerAsync()
    {
        try
        {
            IsProcessing = true;
            ErrorMessage = string.Empty;

            // Call the service method
            var result = await Task.Run(() => _insuranceService.CreateCustomer(
                FirstName, LastName,
                Street, City, State, Zip,
                Email,
                DateOfBirth,
                parsedIncome));

            if (result.IsSuccess)
            {
                ErrorMessage = string.Empty;
            }
            else
            {
                //mapping the result to formatted error notification
                ErrorMessage = result.GetErrorMessage();
            }
        }
        catch (Exception ex)
        {
            ErrorMessage = $"An unexpected error occurred";
        }
        finally
        {
            IsProcessing = false;
        }
    }
}

as indicated above the validation bits can be repeated on various levels. They are just needed to enhance user experience. Here there is a CanCreateCustomer method that ensures the required fields and reports validation error on a shorter notification loop. The message would pop up on a screen faster and that is beneficial for the user who does not have to wait for the round trip to the core domain. That is why such validation has good reason to exist even if it partially repeats the core domain validation.

Summary

  • Here we saw a cleaner approach to validation in Domain-Driven Design (DDD) by using error codes instead of error messages in the core domain.

  • The article highlights the drawbacks of mixing UI concerns with core domain logic when using error messages in factory methods of Value Objects.

  • The proposed solution involves organizing error codes in a static class and renaming the Error property in Result to ErrorCode for clarity.

  • An extension helper method can be introduced in the UI or outer layer to convert error codes into formatted messages, allowing for flexibility in message presentation.

  • The article provides an example of presentation logic in a WPF application, emphasizing the importance of validation at various levels to enhance user experience.

  • It explains the benefit of having validation logic in the presentation layer for faster feedback to the user, even if it partially overlaps with core domain validation.

0
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.