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 — justUserName
.Alternatively, you could name them as
UserName
andUserNameNew
respectively, but it’s a good idea to call the new version justUserName
because that would help you to default to this implementation when writing new code. -
I’m not using inheritance,
UserName
reusingUserNameOld
's validation rules by calling itsCreate
method in its ownCreate()
.While you could potentially inherit
UserName
fromUserNameOld
, I don’t quite like the idea of the additional coupling. The eventual removal ofUserNameOld
should be as painless as possible. The less coupling toUserNameOld
you have, the better. To "simulate" inheritance, I’ve added an implicit conversion fromUserName
toUserNameOld
.
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 newUserName
value object ('1
) because all new customers must adhere to the new validation rules. -
Customer
's constructor also accepts the newUserName
but then transforms it into the oldUserNameOld
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.
Eventually, when all customers update their user names, you can close the transition period by modifying the property’s type from UserNameOld
to UserName
.
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
Comments
comments powered by Disqus