3
\$\begingroup\$

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 "my@example.com" 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()
}
\$\endgroup\$
  • \$\begingroup\$ Side note: Your e-mail validation is severely limiting and would say that a lot of valid e-mails are invalid. I'd recommend checking if it contains a @, after that the best way to check if it is a valid e-mail is to send a confirmation e-mail. \$\endgroup\$ – Simon Forsberg Sep 17 at 10:35
  • \$\begingroup\$ Is there any reason for why you are not exposing the actual string, besides from the toString method, once you have constructed a NonEmptyString or Email object? Why do you need a Converter<Email, String> ? \$\endgroup\$ – Simon Forsberg Sep 17 at 10:56
  • \$\begingroup\$ How are you using these Converter<A, B> objects? What is the definition of the Converter class? \$\endgroup\$ – Simon Forsberg Sep 17 at 10:57
  • \$\begingroup\$ Sorry, thought I specified. The Converter objects are from Spring. Used for anything from Path Variable construction to Mongo objects. It's a FunctionalInterface with T convert(S var). \$\endgroup\$ – Druckles Sep 17 at 19:02
  • 1
    \$\begingroup\$ I've added a short example in case that helps. \$\endgroup\$ – Druckles Oct 4 at 7:41
1
\$\begingroup\$

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
)
\$\endgroup\$
  • 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 a Nullable class, Kotlin has already built-in support for nullable types so I'd steer clear from adding another layer onto that. \$\endgroup\$ – Simon Forsberg Oct 10 at 20:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.