DEV Community

Cover image for Robust Design through Value Objects in C#
max-arshinov
max-arshinov

Posted on

Robust Design through Value Objects in C#

In our previous discussion, we delved into the creation of the UserContact class, honoring idiomatic C# patterns with the utilization of DataAnnotations attributes and the TryParse method. Despite its functionality, the reliance on DataAnnotations attributes has been criticized as a design smell, a perspective that holds merit. How can we ensure the robustness of our UserContact class while avoiding potential design pitfalls?

Enriching C# with F# Insights

An ideal solution seems to be embracing the F# approach of "narrowing" existing types to create self-validating, robust value types:

type EmailAddress = private EmailAddress of string

let createEmailAddress (s:string) =
    if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$")
        then Some (EmailAddress s)
        else None

let emailAddress = createEmailAddress "foo@bar.com"
Enter fullscreen mode Exit fullscreen mode

This F# snippet gives us a glimpse of a design where types guarantee the correctness of the values they hold, reducing runtime errors and simplifying the code.

Bridging the Gap in C#

While C# currently lacks direct support for this kind of functionality, there's a glimmer of hope with an active proposal under discussion that aims to bring this feature to the language. This potential addition promises a future where C# can natively offer similar robust type narrowing.

As we anticipate advancements in C#, we can still forge ahead with crafting resilient applications by utilizing the "ValueOf" package as a provisional solution. Below, we create a validated EmailAddress class leveraging the package:

public class EmailAddress : ValueOf<string, EmailAddress> 
{ 
    protected override bool TryValidate()
    {
        var index = Value.IndexOf('@');

        var isValid =
            index > 0 &&
            index != Value.Length - 1 &&
            index == Value.LastIndexOf('@');

        return isValid;
    }

    protected override void Validate()
    {
        if (!TryValidate())
        {
            throw new ValidationException($"{Value} is not a valid email address");
        }
    }
}

// ...

EmailAddress emailAddress = EmailAddress.From("foo@bar.com");
Enter fullscreen mode Exit fullscreen mode

Not only does this approach centralize validation logic, but it also paves the way for cleaner and more maintainable code.

Revamping the UserContact Class

Let's refactor the UserContact class from the previous article with ValueOf:

public class UserContact
{
    public EmailAddress Email { get; protected set; } // protected for EF

    public Phone Phone { get; protected set; } // protected for EF

    protected UserContact(){} // protected for EF

    public static UserContact Create(EmailAddress email) => 
        new() { Email = email }; 

    public static UserContact Create(Phone phone) =>
        new() { Phone = phone };

    public static UserContact Create(
        EmailAddress email, Phone phone) => 
        new() { Email = email, Phone = phone };
}
Enter fullscreen mode Exit fullscreen mode

In this renovated version, we introduce dedicated types for Email and Phone, ensuring type safety and facilitating various creation methods for different scenarios, be it with an email, a phone number, or both. Also, thanks to the protected access modifier, fields, and the empty constructor are accessible for Entity Framework but not for developers without using reflection.

Revamping the User Class

Let's go further and a SignUp type representing a command from the frontend:

public class User
{
   public int Id { get; protected set; } // protected for EF

   public Contact Contact { get; protected set; } // protected for EF

   public Profile? Profile { get; protected set; } // protected for EF

   protected User() {} // protected for EF

   public User (SignUp command)
   {
       Contact = command.Contact;
       Profile = command.Profile;
   }
}
Enter fullscreen mode Exit fullscreen mode

We can even add a specialized constructor per user creation use case:

public class User
{
   //...

   public User (SignUpByInvite command): this(command)
   {
       Invitee = command.Invitee;
   }    
}
Enter fullscreen mode Exit fullscreen mode

Now it has become clear that a user can register independently (SignUp) or register through a friend's invitation (SignUpByInvite). The invitation mechanism might lead someone reading the code to think that there is a referral program in the system. This change has another unexpected side effect. Imagine that there are two different error messages in the logs:

  1. Something went wrong during user registration
  2. Something went wrong when Alice tried to register through Bob's invitation

The second message is much more informative. It specifies Alice and Bob, and the specific registration process, rather than an abstract registration error. Thus, maintaining the product will be somewhat easier for new developers, who are not familiar with all the requirements.

Conclusion

As we've traversed the nuances of enhancing type safety in our UserContact class, we see how leaning towards a type-centric design, inspired by F# practices and facilitated by the "ValueOf" package, crafts a framework that is both robust and resistant to typical design smells.

Yet, it's pivotal to note a significant consideration when working with the "ValueOf" package in a .NET environment, particularly in context with the ASP.NET Core model binding. "ValueOf" is not supported by default. You have to implement a custom model binder if you want to use these kinds of value objects in your DTOs.

As we wrap up, it would be remiss not to encourage you to experiment with creating a Phone type, akin to the EmailAddress type.

Top comments (0)