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"
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");
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 };
}
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;
}
}
We can even add a specialized constructor per user creation use case:
public class User
{
//...
public User (SignUpByInvite command): this(command)
{
Invitee = command.Invitee;
}
}
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:
- Something went wrong during user registration
- 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)