I'm trying to do some rudimentary DDD in validating my objects to ensure they are of a particular type. I.e. an Email
object would always look like "[email protected]" and a NaturalNumber
is always greater than 0.
However, I'm struggling to make it reusable. The closest I've got is a factory:
open class ConstrainedStringFactory<T>(private val creator: (String) -> T, private val validator: (String) -> Boolean) {
operator fun invoke(value: String?): T? = value?.let { if (validator.test(it)) creator.apply(it) else null }
}
This can then be included as a companion object
by any class wishing to verify it's type:
class Email private constructor(val email: String) {
companion object Factory: ConstrainedStringFactory<Email>(::Email, { EMAIL_REGEX.matches(it) }) {
@JvmStatic
private val EMAIL_REGEX = "^.+@.+\\..+".toRegex()
}
}
class NonBlankString private constructor(val value: String) {
companion object Factory : ConstrainedStringFactory<NonBlankString>(::NonBlankString, String::isNotBlank)
}
This does exactly what I want: creating a new object is either valid or it's null (which can be checked safely):
val email = Email("invalid") ?: throw IlllegalArgumentException
Where this falls down is on generalisation. If I have a list of Spring Converter
objects, for instance, I need to convert each individually:
Converter<String, NonBlankString> { value ->
NonBlankString(value) ?: throw IllegalArgumentException("The string cannot be empty")
},
Converter<NonBlankString, String> { nonBlank->
nonBlank.toString()
},
Converter<String, Email> { value ->
Email(value) ?: throw IllegalArgumentException("The email is not valid")
},
Converter<Email, String> { email ->
email.toString()
}
This seems like a lot of duplication given the objects have the same signature. And it can occur in other places. Is there some way to use an interface or a parent class to make this generalise better?
Ignoring any language limitations, something similar to the following which would reduce all the boilerplate is what I'm after:
class Email private constructor(val email: String) extends ConstrainedString(email, { EMAIL_REGEX.matches(it) }) {
}
class NonBlankString private constructor(val value: String) extends ConstrainedString(value, String::isNotBlank) {
}
Converter<ConstrainedString, String> { value ->
value.toString()
}
1 Answer 1
Remark on you DDD
I've never thought this is possible:
val email = Email("invalid") ?: throw IlllegalArgumentException
Kotlin was created with the intention of null-safety / explicit nullability, and your factory violates it. It allowes classes to be instanceated directly as NULL - this is really weird and for people admiring and working with Kotlin it's very confusing.
Improve current solution
I don't see an improvement for your current solution. I can only recommend to make a Factory class which would genereate all the boilterplate code for all of your generates classes, including converters. But I have a suggestion...
Suggestion
I would go away from extending a companion object and define 'Nullable' Builders, which inherit from generic one:
abstract class NullableValue<T, D>(
private val init: () -> T,
private val value: D,
private val check: (D) -> Boolean
) {
fun isValid(): Boolean = check(value)
fun getOrNull(): T? = if (isValid()) init() else null
}
Define class and the builder:
class Email(val email: String)
class NullableEmail(val email: String) : NullableValue<Email, String>(
init = { Email(email) },
value = email,
check = { "^.+@.+\\..+".toRegex().matches(it) }
)
class NonBlankString(val string: String)
class NullableNonBlankString(val string: String) : NullableValue<NonBlankString, String>(
init = { NonBlankString(string) },
value = string,
check = String::isNotBlank
)
And you can use them:
NullableEmail("").getOrNull() // will be null
NullableNonBlankString("abc").getOrNull() // will be NonBlankString.class
Converter<NonBlankString, String> { value ->
value.toString()
}
If you want more reusability, we can use strategy pattern:
interface Nullable<T> {
fun isValid(): Boolean
fun getOrNull() : T?
}
abstract class NullableValue<T, D> (
private val init: () -> T,
private val value: D,
private val check: (D) -> Boolean
) : Nullable<T> {
override fun isValid(): Boolean = check(value)
override fun getOrNull(): T? = if (isValid()) init() else null
}
class NullableEmail(val email: String) : Nullable<Email> by NullableByRegex<Email> (
init = { Email(email) },
value = email
)
-
1\$\begingroup\$ Excellent point about
Email(...) ?: throw ...
- that is really not a Kotlin way to do it and I'm inclined to upvote solely for that, however... I don't really see a reason to add aNullable
class, Kotlin has already built-in support for nullable types so I'd steer clear from adding another layer onto that. \$\endgroup\$Simon Forsberg– Simon Forsberg2019年10月10日 20:53:33 +00:00Commented Oct 10, 2019 at 20:53
@
, after that the best way to check if it is a valid e-mail is to send a confirmation e-mail. \$\endgroup\$toString
method, once you have constructed aNonEmptyString
orEmail
object? Why do you need aConverter<Email, String>
? \$\endgroup\$Converter<A, B>
objects? What is the definition of theConverter
class? \$\endgroup\$Converter
objects are from Spring. Used for anything from Path Variable construction to Mongo objects. It's aFunctionalInterface
withT convert(S var)
. \$\endgroup\$