This week, I accidentally learned a powerful software engineering idea — domain-driven design when LEARNING Union Type.
With this thought in mind, I realized that the JS defensive programming problem that has been bothering me lately has a deeper flaw in that the domain model is not well defined in the first place. Domain models are often associated with the back end, especially Java development. Front-end business logic generally does not require such a complex concept. Still, domain-driven design inspired me to see where the problem lay.
I know domain-driven design (DDD) from a functional programming perspective. The hindley-milner type system of ML languages, as I learned from F#, is not only useful for checking types, but also useful for designing domain models flexibly and completely.
Suppose we want to define a contact type:
The above code is roughly the same length in TypeScript. The problem with this type definition is that it conveys no domain knowledge:
-
You don’t know which fields are optional
-
You don’t know the field limits. For example, FirstName is limited to 50 characters.
-
You don’t know how fields are related to each other. For example, the first three fields should all be in one group.
-
You don’t know the domain logic of the field. For example, if the email address is changed, the email authentication will be false.
These problems should have been addressed when defining the type. You can’t do that with traditional object-oriented type systems like TypeScript. If you try to do this, you get domain model code mixed up with implementation detail code.
Here’s how F#’s type system solves these problems.
There is a term in DDD called Bounded Context. Words in the domain model have meaning only in the Context of the current domain. These terms form the ubiquitous language in the domain model. See the examples:
This module describes a domain model of a card game. Hand, Player, Deck, etc., can only be understood in the finite context of CardGame; And these words make up the universal language. This code defines not only the data type, but also the domain model! This type definition is pretty straightforward. By creating a limited context and a common language, we can do Persistent Ignorance, which means understanding the domain model without having to understand the code implementation. Even more amazing, the code above is not just a model description, but a piece of executable code! This embodies the idea that code is design and design is code.
Let’s reflect on some of the questions we often overlook when defining types. Is the data type of an email address really just a string? Is the order quantity data type really just an integer? Legitimate email addresses should be reged-matched, and there are often upper and lower limits on the number of orders. F# can be expressed as follows:
type EmailAddress = EmailAddress of string
let createEmailAddress (s:string) =
if Regex.IsMatch(s,@"^\S+@\S+\.\S+$")
then Some(EmailAddress s)
else None
createEmailAddress:
string -> EmailAddress option
type OrderLineQty = OrderLineQty of int
let createOrderLineQty qty =
if qty > 0&& < =99
then Some(OrderLineQty qty)
else None
createOrderLineQty:
int -> OrderLineQty option
Copy the code
Some and None explicitly communicate the possible state of the data, returning Some if the model specification is met, and None otherwise. Some and None are built-in algebraic data types (known as composable data types) that can be sensibly combined with other algebraic data types. In contrast to our routine JS development, we return undefined or NULL if it does not meet the requirements, and then do defense processing at the call point. The problem here is that undefined and NULL are not used to convey domain information; they are thrown to the receiver without context. (Here you can see the essential difference between using the Maybe data type and _. Get.)
To return to the original question, the solution is as follows:
type EmailAddress = EmailAddress of string
let createEmailAddress (s:string) =
if Regex.IsMatch(s,@"^\S+@\S+\.\S+$")
then Some(EmailAddress s)
else None
createEmailAddress:
string -> EmailAddress option
type String50 = String50 of string
let createString50 (s:string) =
if s.Length <= 50
then Some(String50 s)
else None
createString50:
string -> String50 option
type PersonalName = {
FirstName: String50
MiddleInitial: String50 | option
LastName: String50
}
type VerifiedEmail = VerifiedEmail of EmailAddress
type VerificationService =
(EmailAddress * VerificationHash) -> VerifiedEmail option
type EmailContactInfo =
| Unverified of EmailAddress
| Verified of VerifiedEmail
type Contact = {
Name: PersonalName
Email: EmailContactInfo
}
Copy the code
The code above is not only a complete domain model, but also compilable and executable. Illegal states, strictly regulated by the domain model, cannot be expressed in a common language (the idea is too powerful). We don’t have to write defense code anymore. The type code above is a compile-time unit test.
It is also worth noting that the common language is expanding as the domain model improves, with words such as VerifiedEmail. The richness of the common language means that understanding with domain experts (typically on the product demand side, such as product managers) is much easier to reach.
The above thinking is just a hasty summary of Domain Modelling Made Functional. The deeper meaning may not be fully expressed. I recommend reading this book if you are interested.
Reference: Domain Modeling Made Functional – Scott Wlaschin