danielwertheim

danielwertheim


notes from a passionate developer

Share


Sections


Tags


Disclaimer

This is a personal blog. The opinions expressed here represent my own and not those of my employer, nor current or previous. All content is published "as is", without warranty of any kind and I don't take any responsibility and can't be liable for any claims, damages or other liabilities that might be caused by the content.

C# - Always valid value objects

Saw a video that was talking about the fact that using value objects provides you with always valid objects. Then it showed usage of the new'ish C# minimal construct for a record, using the positional syntax, defining some properties and then went on with life. This does of course not provide you with an always valid value object. Life is a bit more complex than that. Lets have a look.

Lets define a value object to start reason about. Lets define it using a record with positional syntax:

record PhoneNumber(string CountryCode, string AreaCode, string Extension);

The Video did not use a Phone number but the idea is the same. Now lets start exploring if this provides us with: "An always valid value object".

Can I construct an instance using other characters than digits?
/// <summary>
/// Intentionally fails to show problem.
/// </summary>
public class AlwaysValidStateYouSay_1_Problem
{
    record PhoneNumber(string CountryCode, string AreaCode, string Extension);

    [Theory]
    [InlineData("A", "1", "2")]
    [InlineData("1", "A", "2")]
    [InlineData("1", "2", "A")]
    public void All_parts_allows_digits_only(string countryCode, string areaCode, string extension)
        => FluentActions
            .Invoking(() => new PhoneNumber(countryCode, areaCode, extension))
            .Should().Throw<Exception>();
}

Of course I can create an instance in a non valid state. There is no AI built in that enforces this. And there's to little context for such a solution to work anyway. Lets tweak it a bit so that the test passes.

/// <summary>
/// Shows one solution using constructor.
/// </summary>
public class AlwaysValidStateYouSay_1_Solution
{
    record PhoneNumber
    {
        public string CountryCode { get; }
        public string AreaCode { get; }
        public string Extension { get; }

        public PhoneNumber(string countryCode, string areaCode, string extension)
        {
            CountryCode = countryCode.All(char.IsDigit)
                ? countryCode
                : throw new ArgumentException("Digits only", nameof(countryCode));
            AreaCode = areaCode.All(char.IsDigit)
                ? areaCode
                : throw new ArgumentException("Digits only", nameof(areaCode));
            Extension = extension.All(char.IsDigit)
                ? extension
                : throw new ArgumentException("Digits only", nameof(extension));
        }
    }

    [Theory]
    [InlineData("A", "1", "2")]
    [InlineData("1", "A", "2")]
    [InlineData("1", "2", "A")]
    public void All_parts_allows_digits_only(string countryCode, string areaCode, string extension)
        => FluentActions
            .Invoking(() => new PhoneNumber(countryCode, areaCode, extension))
            .Should().Throw<ArgumentException>();
}

We have introduced a plain old constructor and added some simple validation but still have all of the record's nice features like: immutability and value equality etc. Now lets push it a bit further.

Can I construct an instance with country codes not being supported?

Supported in this sample will be some country codes that we are supposed to support, e.g. "[44, 45, 46]".

/// <summary>
/// Intentionally fails to show slightly more complex problem.
/// </summary>
public class AlwaysValidStateYouSay_2_Problem
{
    record PhoneNumber
    {
        public string CountryCode { get; }
        public string AreaCode { get; }
        public string Extension { get; }

        public PhoneNumber(string countryCode, string areaCode, string extension)
        {
            CountryCode = countryCode.All(char.IsDigit)
                ? countryCode
                : throw new ArgumentException("Digits only", nameof(countryCode));
            AreaCode = areaCode.All(char.IsDigit)
                ? areaCode
                : throw new ArgumentException("Digits only", nameof(areaCode));
            Extension = extension.All(char.IsDigit)
                ? extension
                : throw new ArgumentException("Digits only", nameof(extension));
        }
    }

    [Theory]
    [InlineData("44")]
    [InlineData("48")]
    public void Does_not_allow_country(string countryCode)
        => FluentActions
            .Invoking(() => new PhoneNumber(countryCode, "1", "1"))
            .Should().Throw<ArgumentException>()
            .And.ParamName
            .Should().Be("countryCode");
}

Failing as expected. Lets introduce a first solution.

/// <summary>
/// Shows working solution, but imagine adding more rules...
/// </summary>
public class AlwaysValidStateYouSay_2_Solution
{
    record PhoneNumber
    {
        private static readonly IImmutableSet<string> SupportedCountryCodes =
            ImmutableHashSet.Create("45", "46", "47");

        public string CountryCode { get; }
        public string AreaCode { get; }
        public string Extension { get; }

        private static string ValidateCountryCode(string countryCode)
        {
            if (!countryCode.All(char.IsDigit))
                throw new ArgumentException("Digits only", nameof(countryCode));

            if (!SupportedCountryCodes.Contains(countryCode))
                throw new ArgumentOutOfRangeException(
                    nameof(countryCode), countryCode, "Not a supported country code.");

            return countryCode;
        }

        public PhoneNumber(string countryCode, string areaCode, string extension)
        {
            CountryCode = ValidateCountryCode(countryCode);
            AreaCode = areaCode.All(char.IsDigit)
                ? areaCode
                : throw new ArgumentException("Digits only", nameof(areaCode));
            Extension = extension.All(char.IsDigit)
                ? extension
                : throw new ArgumentException("Digits only", nameof(extension));
        }
    }

    [Theory]
    [InlineData("44")]
    [InlineData("48")]
    public void Does_not_allow_country(string countryCode)
        => FluentActions
            .Invoking(() => new PhoneNumber(countryCode, "1", "1"))
            .Should().Throw<ArgumentOutOfRangeException>()
            .And.ParamName
            .Should().Be("countryCode");
}

OK. It passes. But imagine more invariants and simple data validation being added around the Phone number. It will grow. It will have many reasons to change. So lets separate it: Value objects (🐢🐢🐢) all the way; and add some simple factory method to start being able to apply specific rules to specific scenarios (yes, not everything needs an interface with specific factory implementation etc).

/// <summary>
/// If one type is guaranteed to be valid, the dependent does not need to think about validity.
/// </summary>
public class AlwaysValidStateYouSay_2_TurtlesAllTheWay
{
    record CountryCode
    {
        private static readonly IImmutableSet<string> SupportedCodes =
            ImmutableHashSet.Create("45", "46", "47");
        
        private string Value { get; }

        private CountryCode(string value) => Value = value;

        public static CountryCode Create(string countryCode)
        {
            if (!countryCode.All(char.IsDigit))
                throw new ArgumentException("Digits only", nameof(countryCode));

            if (!SupportedCodes.Contains(countryCode))
                throw new ArgumentOutOfRangeException(
                    nameof(countryCode), countryCode, "Not a supported country code.");

            return new CountryCode(countryCode);
        }

        public override string ToString() => Value;
    }

    record PhoneNumber
    {
        public CountryCode CountryCode { get; }
        public string AreaCode { get; }
        public string Extension { get; }

        private PhoneNumber(CountryCode countryCode, string areaCode, string extension)
        {
            CountryCode = countryCode;
            AreaCode = areaCode;
            Extension = extension;
        }

        public static PhoneNumber Create(CountryCode countryCode, string areaCode, string extension)
            => new(
                countryCode,
                areaCode.All(char.IsDigit)
                    ? areaCode
                    : throw new ArgumentException("Digits only", nameof(areaCode)),
                extension.All(char.IsDigit)
                    ? extension
                    : throw new ArgumentException("Digits only", nameof(extension)));
    }

    /// <summary>
    /// Redundant test as responsibility of country code now lies within specific type.
    /// </summary>
    /// <param name="countryCode"></param>
    [Theory]
    [InlineData("44")]
    [InlineData("48")]
    public void Does_not_allow_country_1(string countryCode)
        => FluentActions
            .Invoking(() => PhoneNumber.Create(CountryCode.Create(countryCode), "1", "1"))
            .Should().Throw<ArgumentOutOfRangeException>()
            .And.ParamName
            .Should().Be("countryCode");

    [Theory]
    [InlineData("44")]
    [InlineData("48")]
    public void Does_not_allow_country_2(string countryCode)
        => FluentActions
            .Invoking(() => CountryCode.Create(countryCode))
            .Should().Throw<ArgumentOutOfRangeException>()
            .And.ParamName
            .Should().Be("countryCode");
}

I've kept the "Does_not_allow_country_1" test in, but this is something that now can be removed (at least for country code validation). Why? Because we have extracted the logic around country codes to it's own type that ensures it can not be created in an invalid state. Which is something I like. At least when it comes to the model representing your: "[core|domain|business|(applicable name here)]".

That's all for this time.

//Daniel

View Comments