Don’t use validators
Say we are building a system where a user can post comments. Consider this code:
fun postComment(comment: String) {
if (!CommentValidator.isValid(comment)) {
throw InvalidCommentException()
}
repository.save(comment)
}
At first glance, there's nothing bad in this code; a CommentValidator
is used to enforce comment invariants. This invariants could be, for instance, "comment must have, at least, a length of ten" or "comment must not contain any symbol, but just letters and numbers".
The problem here is that, whenever we need to create an email, we need to validate it first. And, since it becomes a developers responsability, it makes the code really hard to maintain. I'm sure you know what I'm talking about.
You can think moving validation to data access layer can solve this problem:
class CommentRepository {
fun save(comment: String) {
if (!CommentValidator.isValid(comment)) {
throw InvalidCommentException()
}
doSave(comment)
}
}
But that's not strictly true. This only prevents us to persist an invalid comment. But what about the rest of the application?
Imagine we have a function that receives a comment text and determine whether it contains inappropriate language, by using some kind of NLP algorithm.
fun isAppropriated(comment: String) : Boolean {
//a very complicated algorithm
}
This method, of course, expects comment to be a well-formed comment. i.e. a comment respecting the domain invariants we listed above.
But, actually, developer could invoke this function with any String
that doesn't fit invariants:
val result = isAppropriated("€€€€")
"Comments can't contain any symbol" invariant is violated here. Why did this happen? Because a Comment is not a String, but a Comment.
When a codebase is full of invariants being violated, developers adopt a "defensive" programming approach. And this drives, eventually, to anxiety and lack of motivation. How can we avoid this? Just by treating domain objects as they deserve.
Since a Comment
is not a String
, but a Comment
, we must create a domain object to represent it:
class Comment(private val content: String) {
}
Here, we are passing a String
to Comment
constructor. Comment
will work internally with that String
, like String
works internally with a char array, but with its own rules (invariants) that will be ensured throughout the application.
We can enforce this invariants at construction time:
class Comment(private val content: String) {
init {
if (content.length < 10 || content.containsSymbols()) {
throw InvalidCommentException()
}
}
}
(In Kotlin, init block is run just after constructor execution)
Now, instead of abusing of primitive types, our isAppropriated function looks like this:
fun isAppropriated(comment: Comment) : Boolean {
//a very complicated algorithm
}
And this is not allowed anymore:
val result = isAppropriated(Comment("$$$"))
Since exception is thrown when we instantiate an invalid comment. To sum up, with this domain driven approach:
- Many bugs are avoided
- Rich domain model is provided to developers that have just started working on the project
- Invariants that a domain object must fit are centralized in the object representation itself
- Better developer experience is guaranteed