The previous article introduced ADTs (Algebraic Data Types). This article will start with an example to introduce how to use ADTs for domain modeling.
Mapping domain knowledge to code
type CreditCard = { cardNo: string firstName: string middleName: string lastName: string contactEmail: Email contactPhone: Phone }
Based on the type definition above, we can easily model the domain of CreditCard. Note that we are not using a class.
But is this a reliable domain model? If not, what’s the issue?
The biggest problem with this code is incomplete domain knowledge. Let me explain with a question:
Can the middle name be empty?
Answer 1: Unsure - I need to check the document.
Answer 2: Maybe - the middle name can be null.
Modeling nullable types
Imagine a domain expert telling you the middle name can exist or be empty. Pay attention to the word "or," as it indicates that we can model the nullable type through the union type. In functional programming languages, the nullable type is defined as Option<T>.
type Option<T> = T | null
A simple Option type is actually a union type. (You can use a more complex Option implementation, though that isn’t within the scope of our article.)
The improved domain model for our Credit Card now looks like this:
type CreditCard = { cardNo: string firstName: string middleName: Option<string> lastName: string contactEmail: Email contactPhone: Phone }
Avoid Primitive Obsession
Can cardNo be represented by a string? If so, can it be any string?
Can firstName be a string of any length?
You cannot answer the questions above because this model doesn’t include such domain knowledge.
In programming languages, cardNo can be expressed as a string, but in our CreditCard domain model, strings don’t fully capture the domain knowledge behind cardNo.
Experts in our domain know cardNo is a 19-digit string starting with 200, and names are strings not exceeding 50 digits. This domain knowledge can be achieved through the following type aliases:
type CardNo = string type Name50 = string
With the above two types, you have the opportunity to include the cardNo business rules in the domain model by defining functions.
type GetCardNo = (cardNo: string) => CardNo
The improved domain model now looks like this:
type CreditCard = { cardNo: CardNo firstName: Name50 middleName: Option<string> lastName: Name50 contactEmail: Email contactPhone: Phone }
This model has more domain knowledge and the rich types also act as unit tests. For example, you will never assign an Email type to contactPhone. They aren’t strings, they represent different domain knowledge.
Error handling
If the user enters a 20-digit string, what does the function GetCardNo return? Throw an exception?
Functional programming languages have more elegant error handling approaches than exceptions, such as Either Monad or Railway oriented programming. For now we can use the Option type to update the function signature:
type GetCardNo = (cardNo: string) => Option<CardNo>
This function type clearly expresses the entire verification process. The user enters a string and returns a CardNo type or null.
Atomicity and aggregation of domain models
Can the three names in the CreditCard domain model be modified separately? For example, could you only modify the middle name? If not, how do we include this atomic modification knowledge in the domain model?
We can easily separate the two types of Name and Contact and combine them:
type Name = { firstName: Name50 middleName: Option<string> lastName: Name50 }
type Contact = { contactEmail: Email contactPhone: Phone }
type CreditCard = { cardNo: CardNo name: Name contact: Contact }
Make illegal states unrepresentable
This is a very important principle of domain modeling. In fact, the entire domain model follows this principle, such as the above Email type and Phone type. Why not use string to represent email? Because the domain knowledge given by string isn’t enough, it lets developers make mistakes.
Let’s look at a simple example to understand how to apply this principle in domain modeling...
A Contact type was defined which contained Email and Phone. After a credit card payment is successful, the system sends a notification to the user through one of these two properties, following the rule that the user must fill in at least Email or Phone to accept the payment notification.
Currently our domain model doesn’t support this business rule, because the Email and Phone types are both required.
Can we change both of them to Option type?
type Contact = { contactEmail: Option<Email> contactPhone: Option<Phone> }
Unfortunately, this violates the "Make illegal states unrepresentable" principle. Your domain model expresses an illegal state that both Email and Phone can be null. Can this rule be expressed in the domain model? Yes, we can simply express this relationship through the union type:
type OnlyContactEmail = Email type OnlyContactPhone = Phone type BothContactEmailAndPhone = Email & Phone type Contact = | OnlyContactEmail | OnlyContactPhone | BothContactEmailAndPhone
Conclusion
Does the domain model contain as much domain knowledge as possible, and can it reflect the business model for domain experts?
Can the domain model become a document, and then become a way for everyone to communicate and share knowledge?
The type system of a functional programming language not only helps developers build a great domain model, it also provides a simple and composable type system as a basis for code as a document.
Disclaimer: The statements and opinions expressed in this article are those of the author(s) and do not necessarily reflect the positions of Thoughtworks.