How to Strengthen Requirements for Pre-existing Data

Need to change validation rules but have pre-existing data that doesn’t follow the new rules? In this post, we’ll talk about how to do that properly.

Changing validation requirements

Changing validation requirements is a common practice. To give you an example, let’s say that your company does physical shipping to customers in the US and Canada, but not other countries. This requirement manifests itself in the following validation rules:

public class OrderController
{
    public string Order(int customerId, Address shippingAddress)
    {
        string[] countries = { "US", "Canada" };

        // We don't deliver outside of US and Canada
        if (!countries.Contains(shippingAddress.Country))
            return "Sorry, we can't deliver to your country";

        /* ... */
    }
}

(This is of course an oversimplified example, but you get the idea.)

Let’s also say that your employer decides to expand and start shipping to customers in the UK too. To accommodate this new requirement, you need to change the validation rules by adding "UK" to the list of available countries.

This is an example of validation weakening — the new validation rules are weaker because they allow for a wider range of values.

The opposite of validation weakening is validation strengthening. If your company decides to restrict the shipping to just the US, then you will need to make the validation rules stronger — make the set of allowable values narrower.

Changing validation requirements isn’t a big deal in and of itself. Where it becomes problematic is when you have pre-existing data that doesn’t meet the new requirements.

Strengthening validation requirements for pre-existing data

Let’s take another example. Let’s say that you have a Customer class with the following structure:

public class Customer : Entity
{
    public UserName UserName { get; }

    public Customer(UserName userName)
    {
        UserName = userName;
    }

    public string SayHello()
    {
        return $"Hello from {UserName.Value}!";
    }
}

There are validation requirements associated with customers: their user names should not be empty and should be 50 characters or shorter. These requirements are encapsulated into the UserName value object:

public class UserName : ValueObject
{
    private const int MaxLength = 50;

    public string Value { get; }

    private UserName(string value)
    {
        Value = value;
    }

    public static Result<UserName> Create(string userName)
    {
        if (string.IsNullOrWhiteSpace(userName))
            return Result.Failure<UserName>("User name is empty");

        userName = userName.Trim();

        if (userName.Length > MaxLength)
            return Result.Failure<UserName>("User name is too long");

        return Result.Success(new UserName(userName));
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Value;
    }
}

(The base ValueObject class is taken from this article: Value Object: a better implementation).

Let’s assume that stakeholders decide to change these validation rules: only alphanumericals should be allowed in user names; the system must reject customer registrations with names that contain unsupported characters.

The change itself is pretty simple, you just need to add one more validation to the Create method:

public static Result<UserName> Create(string userName)
{
    if (string.IsNullOrWhiteSpace(userName))
        return Result.Failure<UserName>("User name is empty");

    userName = userName.Trim();

    if (userName.Length > MaxLength)
        return Result.Failure<UserName>("User name is too long");

    // the new validation
    if (Regex.IsMatch(userName, "^[a-zA-Z0-9]+$"))
        return Result.Failure<UserName>("User name is invalid");

    return Result.Success(new UserName(userName));
}

But what to do with all the customers who have already registered? Some of them may have user names that don’t follow the new rules. And you can’t just change those names on your own — it’s up to the customers to decide.

This is another example of requirement strengthening. So, how should you handle it?

Introducing a transition period

Of course, you can’t just add the additional validation to the UserName class because that would render some of the existing user names invalid.

Instead, you need to introduce a second UserName that would inherit all existing validations and add the new one. Use that second class to validate new registrations, but keep the old one when materializing existing customers from the database.

Here’s how this may look:

public class UserNameOld : ValueObject
{
    /* Same as before */
}

public class UserName : ValueObject
{
    public string Value { get; }

    private UserName(string value)
    {
        Value = value;
    }

    public static Result<UserName> Create(string userName)
    {
        Result<UserNameOld> result = UserNameOld.Create(userName);
        if (result.IsFailure)
            return result.ConvertFailure<UserName>();

        // the new validation
        if (Regex.IsMatch(userName, "[a-zA-Z0-9]"))
            return Result.Failure<UserName>("User name is invalid");

        return Result.Success(new UserName(userName));
    }

    public static implicit operator UserNameOld(UserName userName)
    {
        return UserNameOld.Create(userName.Value).Value;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Value;
    }
}

Note a couple of things:

  • The old class is now called UserNameOld; the new one — just UserName.

    Alternatively, you could name them as UserName and UserNameNew respectively, but it’s a good idea to call the new version just UserName because that would help you to default to this implementation when writing new code.

  • I’m not using inheritance, UserName reusing UserNameOld's validation rules by calling its Create method in its own Create().

    While you could potentially inherit UserName from UserNameOld, I don’t quite like the idea of the additional coupling. The eventual removal of UserNameOld should be as painless as possible. The less coupling to UserNameOld you have, the better. To "simulate" inheritance, I’ve added an implicit conversion from UserName to UserNameOld.

With these two classes, you are introducing a transition period — a period of time when your application supports both the old and the new versions of the validation rules. This is once again because you can’t change the pre-existing user names such that they conform to the new rules.

Note that this is not a technical limitation, but a business one. In theory, you could just remove all unsupported characters from all the user names (and there would be no need for the transition period, then). What stops you is the business decision to make this transition gradual.

The two classes (UserNameOld and UserName) are not a hack, but a reflection of that business decision. As always, it’s important to be as explicit as possible when writing down domain logic. The two value objects help you do exactly that; they represent the application’s domain knowledge.

Here’s how a customer controller and Customer look after the introduction of the second UserName:

public class CustomerController
{
    public string Register(string userName)
    {
        Result<UserName> result = UserName.Create(userName); '1
        if (result.IsFailure)
            return result.Error;

        var customer = new Customer(result.Value);
        _repository.Save(customer);

        return "OK";
    }

    public string SayHello(int customerId)
    {
        Customer customer = _repository.GetById(customerId);
        if (customer == null)
            return "Customer not found";

        return customer.SayHello();
    }
}

public class Customer : Entity
{
    public UserNameOld UserName { get; } // old value object

    public Customer(UserName userName) // new value object
    {
        UserName = userName;
    }

    public string SayHello()
    {
        return $"Hello from {UserName.Value}!";
    }
}

Note that:

  • The Register controller method uses the new UserName value object ('1) because all new customers must adhere to the new validation rules.

  • Customer's constructor also accepts the new UserName but then transforms it into the old UserNameOld property behind the scenes (that’s where the implicit conversion is useful).

By requiring the new UserName in the constructor, you are telling the reader of this code that all new customers must be created using the new rules. At the same time, the existing customers aren’t guaranteed to adhere to those rules (the UserNameOld property). This makes the type system work in your favor — the transition period is essentially enforced by the compiler.

2020 04 28 transition period
Transition period in action

Eventually, when all customers update their user names, you can close the transition period by modifying the property’s type from UserNameOld to UserName.

ORMs and factory methods

Note that the above implementation of Customer assumes the use of an ORM, such as NHibernate or EF Core. When such an ORM materializes an entity, it bypasses its constructor and assigns values directly to the backing fields. That’s why this implementation works, even though I didn’t declare any other constructors in Customer.

If you don’t use an ORM, you’ll need to introduce a couple of static factory methods in Customer:

  • CreateNew(UserName userName) for new customer registrations. Note that is accepts the new UserName.

  • CreateExisting(int customerId, UserNameOld userName) for customers already in the database.

Summary

  • Validation weakening is changing validation rules such that they allow for a wider range of values.

  • Validation strengthening is changing validation rules such that they allow for a narrower range of values.

  • To introduce validation strengthening when you have pre-existing data:

    • Have two versions of the validation rules — a transition period

    • Use the old version when materializing existing entities from the database

    • Use the new version when creating new entities

    • Make the compiler work for you — encapsulate the 2 sets of validation rules into separate classes

Subscribe


I don't post everything on my blog. Don't miss smaller tips and updates. Sign up to my mailing list below.

Comments


comments powered by Disqus