📜 ⬆️ ⬇️

Designing Types: How to make invalid states ineffable on C #

As a rule, articles telling about type design contain examples in functional languages ​​- Haskell, F # and others. It may seem that this concept does not apply to object-oriented languages, but it is not.


In this article, I will translate examples from Scott Vlascin’s article Designing Types: How to make invalid states inexpressible on idiomatic C #. I will also try to show that this approach is applicable not only as an experiment, but also in the working code.


Create domain types


First you need to port the types from the previous article in the series , which are used in the examples on F #.


We wrap primitive types in domain


The F # examples use domain types instead of primitives for the email address, US postal code, and state code. Let's try to make a primitive type wrapper on C #:


public sealed class EmailAddress { public string Value { get; } public EmailAddress(string value) { if (value == null) { throw new ArgumentNullException(nameof(value)); } if (!Regex.IsMatch(value, @"^\S+@\S+\.\S+$")) { throw new ArgumentException("Email address must contain an @ sign"); } Value = value; } public override string ToString() => Value; public override bool Equals(object obj) => obj is EmailAddress otherEmailAddress && Value.Equals(otherEmailAddress.Value); public override int GetHashCode() => Value.GetHashCode(); public static implicit operator string(EmailAddress address) => address?.Value; } 

 var a = new EmailAddress("a@example.com"); var b = new EmailAddress("b@example.com"); var receiverList = String.Join(";", a, b); 

I transferred the address validation check from the factory function to the constructor, since this implementation is more typical for C #. Also, I had to implement a comparison and conversion to a string, which the F # compiler would do.


On the one hand, the implementation looks quite voluminous. On the other hand, the specificity of the e-mail address is expressed here only by checks in the constructor and, possibly, by the logic of comparison. Most of this is occupied by the infrastructure code, which, moreover, is unlikely to change. So, you can either make a template , or, at worst, copy the common code from class to class.


It should be noted that the creation of domain types from primitive values ​​is not the specificity of functional programming. On the contrary, the use of primitive types is considered a sign of bad code in OOP . Examples of such wrappers can be seen, for example, in NLog and NBitcoin , and the standard type of TimeSpan is, in fact, a wrapper over the number of ticks.


Creating Value Objects


Now you need to create an analogue of the record :


 public sealed class EmailContactInfo { public EmailAddress EmailAddress { get; } public bool IsEmailVerified { get; } public EmailContactInfo(EmailAddress emailAddress, bool isEmailVerified) { if (emailAddress == null) { throw new ArgumentNullException(nameof(emailAddress)); } EmailAddress = emailAddress; IsEmailVerified = isEmailVerified; } public override string ToString() => $"{EmailAddress}, {(IsEmailVerified ? "verified" : "not verified")}"; } 

Again, it took more code than in F #, but most of the work can be done through refactorings in the IDE .


Like EmailAddress , EmailContactInfo is an object-value (in the sense of DDD , not value-types in .NET ), long known and used in the simulation object.


The remaining types — StateCode , ZipCode , PostalAddress and PersonalName ported to C # in a similar fashion.


Create a contact


So, the code must express the rule "The contact must contain an e-mail address or postal address (or both)." It is required to express this rule in such a way that the state correctness is visible from the type definition and checked by the compiler.


Express various contact states


Hence, a contact is an object that contains the name of a person and either an email address, or a postal address, or both. Obviously, one class cannot contain three different sets of properties, therefore, it is necessary to define three different classes. All three classes must contain the name of the contact and at the same time it should be possible to process contacts of different types uniformly, not knowing which addresses the contact contains. Consequently, the contact will be represented by an abstract base class containing the name of the contact, and three implementations with a different set of fields.


 public abstract class Contact { public PersonalName Name { get; } protected Contact(PersonalName name) { if (name == null) { throw new ArgumentNullException(nameof(name)); } Name = name; } } public sealed class PostOnlyContact : Contact { private readonly PostalContactInfo post_; public PostOnlyContact(PersonalName name, PostalContactInfo post) : base(name) { if (post == null) { throw new ArgumentNullException(nameof(post)); } post_ = post; } } public sealed class EmailOnlyContact : Contact { private readonly EmailContactInfo email_; public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } email_ = email; } } public sealed class EmailAndPostContact : Contact { private readonly EmailContactInfo email_; private readonly PostalContactInfo post_; public EmailAndPostContact(PersonalName name, EmailContactInfo email, PostalContactInfo post) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } if (post == null) { throw new ArgumentNullException(nameof(post)); } email_ = email; post_ = post; } } 

You can argue that you should use composition , not inheritance, and generally you must inherit the behavior, not the data. The comments are fair, but, in my opinion, the use of the class hierarchy is justified here. First, subclasses not only represent special cases of the base class, the whole hierarchy is one concept - contact. The three contact implementations very accurately reflect the three cases specified by the business rule. Secondly, the relationship of the base class and its heirs, the division of responsibilities between them is easily traced. Third, if the hierarchy becomes a real problem, you can isolate the contact state into a separate hierarchy, as was done in the original example. In F #, the inheritance of records is not possible, but new types are declared quite simply, so splitting was performed immediately. In C #, a more natural solution would be to place the Name fields in the base class.


Create contact


Creating a contact is quite simple.


 public abstract class Contact { public static Contact FromEmail(PersonalName name, string emailStr) { var email = new EmailAddress(emailStr); var emailContactInfo = new EmailContactInfo(email, false); return new EmailOnlyContact(name, emailContactInfo); } } 

 var name = new PersonalName("A", null, "Smith"); var contact = Contact.FromEmail(name, "abc@example.com"); 

If the e-mail address is incorrect, this code will throw an exception, which can be considered an analogue of returning None in the original example.


Contact update


Updating a contact is also not difficult - you just need to add an abstract method to the Contact type.


 public abstract class Contact { public abstract Contact UpdatePostalAddress(PostalContactInfo newPostalAddress); } public sealed class EmailOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); } public sealed class PostOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new PostOnlyContact(Name, newPostalAddress); } public sealed class EmailAndPostContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); } 

 var state = new StateCode("CA"); var zip = new ZipCode("97210"); var newPostalAddress = new PostalAddress("123 Main", "", "Beverly Hills", state, zip); var newPostalContactInfo = new PostalContactInfo(newPostalAddress, false); var newContact = contact.UpdatePostalAddress(newPostalContactInfo); 

As with the use of option.Value in F #, an exception may be thrown from constructors here if the email address, postal code or state is incorrect, but for C # this is a common practice. Of course, in the working code here or somewhere in the calling code, exception handling must be provided.


Handling contacts outside the hierarchy


It is logical to arrange the contact update logic in the Contact hierarchy itself. But what if you want to do something that does not fit into her area of ​​responsibility? Suppose you want to display contacts on the user interface.


You can, of course, again add an abstract method to the base class and continue to add a new method every time you need to somehow process the contacts. But then the principle of sole responsibility will be violated, the Contact hierarchy will be cluttered, and the processing logic will be blurred between Contact implementations and the places responsible for handling contacts. There was no such problem in F #, I would like the C # code to be just as good!


The closest analogue to pattern matching in C # is the switch construct. You could add an enumerated type property in Contact that allows you to determine the actual type of contact and perform the conversion. You could also use the newer features of C # and execute the switch by the type of the Contact instance. But after all, we wanted the compiler itself to suggest when new Contact states were added, where the handling of new cases is not enough, and the switch does not guarantee the processing of all possible cases.


But after all, in the PLO there is a more convenient mechanism for choosing the logic depending on the type, and we just used it when updating the contact. And since now the choice depends on the calling type, it must also be polymorphic. The solution is a Visitor pattern. It allows you to select a handler depending on the Contact implementation, decouple the contact processing methods from their hierarchy, and if a new contact type is added and, accordingly, a new method in the Visitor interface, you will need to write it in all interface implementations. All requirements are met!


 public abstract class Contact { public abstract void AcceptVisitor(IContactVisitor visitor); } public interface IContactVisitor { void Visit(PersonalName name, EmailContactInfo email); void Visit(PersonalName name, PostalContactInfo post); void Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post); } public sealed class EmailOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_); } } public sealed class PostOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, post_); } } public sealed class EmailAndPostContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_, post_); } } 

Now you can write code to display contacts. For simplicity, I will use the console interface.


 public sealed class ContactUi { private sealed class Visitor : IContactVisitor { void IContactVisitor.Visit(PersonalName name, EmailContactInfo email) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); } void IContactVisitor.Visit(PersonalName name, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Postal address: {0}", post); } void IContactVisitor.Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); Console.WriteLine("* Postal address: {0}", post); } } public void Display(Contact contact) => contact.AcceptVisitor(new Visitor()); } 

 var ui = new ContactUi(); ui.Display(newContact); 

Further improvements


If Contact declared in the library and the appearance of new heirs in library clients is undesirable, you can change the scope of the Contact constructor to internal , or even make its heirs nested classes, declare the visibility of the implementations and constructor private , and create instances through only static factory methods.


 public abstract class Contact { private sealed class EmailOnlyContact : Contact { public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { } } private Contact(PersonalName name) { } public static Contact EmailOnly(PersonalName name, EmailContactInfo email) => new EmailOnlyContact(name, email); } 

In this way, the non-expandability of a sum-type can be reproduced, although, as a rule, this is not required.


Conclusion


Hopefully, I was able to show how to use OOP to limit the correct states of business logic using types. The code is more voluminous than in F #. Somewhere this is due to the relative cumbersome decisions of the PLO, somewhere - the verbosity of the language, but the solutions can not be called impractical.


Interestingly, starting with a purely functional solution, we came to the recommendation of subject-oriented programming and OOP patterns. In fact, this is not surprising, because the similarity of types-sums and patterns The visitor has been known for quite some time . The purpose of this article was to show not so much a specific technique as to demonstrate the applicability of the ideas from the "ivory tower" in imperative programming. Of course, not everything will be able to be transferred as easily, but with the advent of more and more new functionalities in mainstream programming languages, the boundaries of the applicable will be expanded.




→ Sample code is available on GitHub


')

Source: https://habr.com/ru/post/431186/


All Articles