Tuesday, March 29, 2022

Kotlin: Functional Domain Modeling #2

 Kotlin: Functional Domain Modeling #2


In this blog we investigate creation strategies for our domain elements. Factories offer a unified strategy for object creation. But other considerations for object creation include: how do we ensure that the factory returns a valid object; where to put the validation logic; and what happens if the validation fails.



The Integrity of Simple Values

We can create an object in a multitude of ways - the simplest being a direct invocation of the class constructor. But, the simplest technique always has some pitfalls. We need to apply some good software engineering principles when creating domain model objects. We must ensure our object are valid. It is very unusual to have an unbounded Int or String in a real world domain. In the previous blog our product id was represented by an Int but it is unlikely that the business uses a negative Int. Equally, the last name for a customer is represented by a String, but we would not expect it to include a tab character.

We see some of these issues in the following code. The class represents someones last name but there are no constraints on what values we provide.

class LastName(val lastName: String)

val me = LastName("BARCLAY")
val doppelganger = LastName("Ken")

assertEquals("BARCLAY", me.lastName)
assertEquals("Ken", doppelganger.lastName)

We want to ensure the values of these types cannot be created unless they satisfy the domain constraints. Thereafter, because the object is immutable, the value never needs to be checked again.

To ensure the constraints are enforced we make the constructor private/internal and provide a separate function that creates only valid values. The standard technique for the construction of objects that honors some constraint is known as the smart constructor idiom. Here is an example:

@JvmInline
value class LastName private constructor(val lastName: String) {

    companion object {

        fun create(text: String): LastName = LastName(text)

    }

}   // LastName


// compilation error: cannot access private LastName
// val me = LastName("BARCLAY")
val doppelganger = LastName.create("Barclay")

assertEquals("Barclay", doppelganger.lastName)

So now the LastName object cannot be created due to the private constructor. This is shown in the commented code. The create function is our (not yet) smart constructor factory function.

In functional programming, an effect adds some capabilities to a computation. An effect is modeled in the form of a type constructor that constructs types with additional capabilities. Consider some arbitrary type LastName, then, for example, Option<LastName> adds optionality to the type LastName.

In the next example the smart constructor function create makes two checks on the text supplied for the name. If the text contains only one character or if the text is not all uppercase letters then None is returned to indicate that a valid name could not be constructed. Otherwise, a LastName object is created and wrapped in a Some to denote success.

data class LastName(val lastName: String) {

    companion object {

        fun create(lastName: String): Option<LastName> {
            return if(lastName.length < 2)
                none()
            else if (!regex.matches(lastName))
                none()
            else
                some(LastName(lastName))
        }   // create

        private val regex: Regex = Regex("[A-Z][A-Z]*")

    }

}   // LastName


assertEquals(some(LastName("BARCLAY")), LastName.create("BARCLAY"))
assertEquals(none(), LastName.create("Barclay"))



Managing failure with Option type

In a business application a customer name is modeled as a last name and a first name. Like above the last name is expected to be fully capitalized. The first name is expected to have a leading capital letter and any number of lowercase letters. Here is class FirstName modeled as earlier:

data class FirstName(val firstName: String) {

    companion object {

        fun create(firstName: String): Option<FirstName> {
            return if(firstName.length < 2)
               none()
            else if (!regex.matches(firstName))
                none()
            else
                some(FirstName(firstName))
        }   // create

        private val regex: Regex = Regex("[A-Z][a-z]*")

    }

}   // FirstName

The Name class declaration starts with:

data class Name(val lastName: LastName, val firstName: FirstName) ...

It too adopts the smart constructor idiom with its own create function:

data class Name(val lastName: LastName, val firstName: FirstName) {

    companion object {

        fun create(lastName: String, firstName: String): Option<Name> {
            return ...
        }   // create

    }

}   // Name

The body of the function create will pass lastName to the function LastName.create returning an Option<LastName>, and pass firstName to the function FirstName.create returning an Option<FirstName>. Option is a monad as defined in the Dogs library. Monads allow the programmer to build up computations using sequential building blocks. The monad determines how combined computations form a new computation and frees the programmer from having to code the combination manually each time it is required. It is useful to think of a monad as a strategy for combining computations into more complex computations.

We use Option's bind (aka flatMap) function to compose the results from LastName.create and FirstName.create. This bind function makes Option a monad and helps to compose them in a clean manner. Here is the final version of the Name class:

data class Name(val lastName: LastName, val firstName: FirstName) {

    companion object {

        fun create1(lastName: String, firstName: String): Option<Name> {
            val oLastName: Option<LastName> = LastName.create(lastName)
            val oFirstName: Option<FirstName> = FirstName.create(firstName)
            return oLastName.bind{ln ->
                oFirstName.bind{fn ->
                    some(Name(ln, fn))
                }
            }
        }   // create1

        fun create2(lastName: String, firstName: String): Option<Name> {
            val oLastName: Option<LastName> = LastName.create(lastName)
            val oFirstName: Option<FirstName> = FirstName.create(firstName)
            return bind2(oLastName, oFirstName,){ln ->
                {fn ->
                    some(Name(ln, fn))
                }
            }
        }   // create2

        fun create3(lastName: String, firstName: String): Option<Name> {
            val oLastName: Option<LastName> = LastName.create(lastName)
            val oFirstName: Option<FirstName> = FirstName.create(firstName)
            return bind2(oLastName, oFirstName){ln, fn -> some(Name(ln, fn)) }
        }   // create3

        fun create(name: String): Option<Name> {
            val names: List<String> = name.split(" ")
            val size: Int = names.size

            return if (size != 2)
                none()
            else {
                val oLastName: Option<LastName> = LastName.create(names[0])
                val oFirstName: Option<FirstName> = FirstName.create(names[1])
                bind2(oLastName, oFirstName){ln, fn -> some(Name(ln, fn))}
            }
        }   // create

    }

}   // Name

The three functions create1, create2 and create3 are presented to show the different ways of using bind. Here is the signature of (extension) function bind from the Option class:

fun <A, B> Option<A>.bind(f: (A) -> Option<B>): Option<B>

The bind function operates on an Option<A> along with the function parameter that converts an A into an Option<B>. The result type is an Option<B>. If the Option<A> is a Some then the function parameter is applied to its wrapped value, producing the required result. If the Option<A> is a None then its value is bypassed as a None value.

If in Name.create1 the function call LastName.create(lastName) is a Some, then the wrapped LastName is associated with the bind parameter ln. If the function call FirstName.create(firstText) is a Some, then the wrapped FirstName is associated with the bind parameter fn. A Name is created from these two values and wrapped in a Some to denote success.

Where the chain of operations involves no other processes then the function bind2 can be used. In function create2 we use the version of bind2 that expects a curried binary function. The overloaded bind2 in create3 uses an uncurried function to give the most compact coding.

The first assert creates a successful Name. The other assert statements fail with, respectively, invalid last name, invalid first name, and invalid last and first names. Note carefully the final assert. Both names are invalid but only the last name is the cause of the resulting None. The call to LastName.create("Barclay") returns an Option<LastName> which is a None, and the chain of binds breaks. Calls to FirstName.create("ken") is never executed. For that reason monads are said to fail fast.

assertEquals(
    some(Name(LastName("BARCLAY"), FirstName("Ken"))),
    Name.create1("BARCLAY", "Ken")
)
assertEquals(none(), Name.create1("Barclay", "Ken"))   // bad last name
assertEquals(none(), Name.create2("BARCLAY", "ken"))   // bad first name
assertEquals(none(), Name.create3("BARCLAY", "ken"))   // bad last + first name



The monad

We present a formal definition of the monad through the following two functions. Again, F is a place-marker for the actual type for which it applies:

fun <A, B> F<A>.bind(f: (A) -> F<B>): F<B>
fun <A> inject(a: A) -> F<A>

The bind function is the monadic sequencing operation. If the receiver F<A> produces an A, then bind applies the function f converting it to an F<B>. The function bind returns an F<B>. The inject function is like the applicative pure function and injects a value into the F context.

An important aspect of the function bind is understanding how the chaining of computations takes place when you plug multiple binds together. They compose sequentially, and the chain breaks when one of the binds break. We saw exactly this when using the Option monad in the smart constructor for the Name class.

Here it is defined for the List type:

fun <A, B> List<A>.bind(f: (A) -> List<B>): List<B> =
    concat(this.fmap(f))

assertEquals(ListF.of(-1, 1, -2, 2, -3, 3), ListF.of(1, 2, 3).bind{n -> ListF.of(-n, +n)})

When defining this bind function we have a List<A> as the receiver and a function on the elements of the List: (A) -> List<B>. Then a natural thing to do is to map that function over the List as in the sub-expression this.fmap(f). This sub-expression has the type List<List<B>>. The helper function concat transforms this into a List<B> as required.

The next example defines a function pairs that takes two List parameters and returns a List of Pairs of elements from those two Lists. The function is expected to deliver all possible pairs from the two Lists. Since Lists are monadic we can define pairs with:

fun <A, B> pairs(la: List<A>, lb: List<B>): List<Pair<A, B>> =
    la.bind{a: A ->
       lb.bind{b: B ->
            inject(Pair(a, b))
        }
    }

The first application of bind selects one value from the first list while the second application of bind selects one value from the second list. We then inject the Pair into the result List.

The monad as a functional design pattern is, like all others, accompanied with a set of laws which must be satisfied for each implementation. We use KwikCheck to check the monad laws on the monad instances.




Managing failure with Either type

Consider the smart constructor function create in the class LastName. Under normal circumstances the caller supplies a capitalized String of at least two characters. But what about the exceptions?  Both exceptions report failure by returning None but give no indication of the nature of the error.

Our creation process can result in two possible errors: the text is too short or it is not fully capitalized. So to report success for the two types of error we employ the Either<A, B> type which has two type parameters and the two specializations: Left and Right. We inject a value of type A by using the Left constructor, or we inject a value of type B by using the Right constructor. Conventionally, when using Either for validations Left is used to denote failure and Right for success.

In the following version of function create we wrap the output with an Either type which returns a String if a problem occurs and a LastName if successful. The first assert is a valid name while the remaining two asserts are malformed names.

data class LastName(val lastName: String) {

    companion object {

        fun create(lastName: String): Either<String, LastName> {
            return if(lastName.length < 2)
                left("Last name too short: $lastName")
            else if (!regex.matches(lastName))
                left("Last name malformed: $lastName")
            else
                right(LastName(lastName))
        }   // create

        private val regex: Regex = Regex("[A-Z][A-Z]*")

    }

}   // LastName


assertEquals(right(LastName("BARCLAY")), LastName.create("BARCLAY"))
assertEquals(left("Last name malformed: Barclay"), LastName.create("Barclay"))
assertEquals(left("Last name too short: B"), LastName.create("B"))

Here is how the Name class when using the Either type:

data class Name(val lastName: LastName, val firstName: FirstName) {

    companion object {

        fun create(lastName: String, firstName: String): Either<String, Name> {
            val eLastName: Either<String, LastName> = LastName.create(lastName)
            val eFirstName: Either<String, FirstName> = FirstName.create(firstName)

            return bind2(eLastName, eFirstName){ln, fn -> right(Name(ln, fn)) }
        }   // create

        fun create(name: String): Either<String, Name> {
            val names: List<String> = name.split(" ")
            val size: Int = names.size

            return if (size == 0)
                left("Name.create: empty name not supported")
            else if (size == 1)
                left("Name.create: name must exceed 1 part")
            else if (size > 3)
                left("Name.create: name must not exceed 3 parts")
            else {
                val eLastName: Either<String, LastName> = LastName.create(names[0])
                val eFirstName: Either<String, FirstName> = FirstName.create(names[1])
                bind2(eLastName, eFirstName){ln, fn -> right(Name(ln, fn))}
            }
        }   // create

    }

}   // Name

We are avoiding using primitives such as Strings and instead create domain-specific types. Errors deserve the same treatment and should be modeled like everything else in the domain. To keep our code samples brief we continue to use Strings but more typically we might model errors as:

sealed class PaymentError {
    data class PaymentRejected(val amount: Int) : PaymentError()
    data class CardTypeNotRecognized(val type: String) : PaymentError()
    // ...
}



Modeling Optional Values

In an application a name is modeled as a last name, a first name and an optional middle name. The last name and the first name are as above. The middle name follows the pattern for the first name. The Option type ensures the correct semantics for a name. Here is the new Name class:

data class Name(val lastName: LastName, val firstName: FirstName, val middleName: Option<MiddleName> = none()) {

    companion object {

        fun create(lastName: String, firstName: String, middleName: String = ""): Either<String, Name> {
            val eLastName: Either<String, LastName> = LastName.create(lastName)
            val eFirstName: Either<String, FirstName> = FirstName.create(firstName)

            return if (middleName == "")
                bind2(eLastName, eFirstName){ln, fn -> right(Name(ln, fn, none())) }
            else {
                val eMiddleName: Either<String, MiddleName> = MiddleName.create(middleName)
                bind3(eLastName, eFirstName, eMiddleName){ln, fn, mn -> right(Name(ln, fn, some(mn))) }
            }
        }   // create

        fun create(name: String): Either<String, Name> {
            val names: List<String> = name.split(" ")
            val size: Int = names.size

            return if (size == 0)
                left("Name.create: empty name not supported")
            else if (size == 1)
                left("Name.create: name must exceed 1 part")
            else if (size > 3)
                left("Name.create: name must not exceed 3 parts")
            else if (size == 2) {
                val eLastName: Either<String, LastName> = LastName.create(names[0])
                val eFirstName: Either<String, FirstName> = FirstName.create(names[1])
                bind2(eLastName, eFirstName){ln, fn -> right(Name(ln, fn))}
            } else {
                val eLastName: Either<String, LastName> = LastName.create(names[0])
                val eFirstName: Either<String, FirstName> = FirstName.create(names[1])
                val eMiddleName: Either<String, MiddleName> = MiddleName.create(names[2])
                bind3(eLastName, eFirstName, eMiddleName){ln, fn, mn -> right(Name(ln, fn, some(mn))) }
            }
        }   // create

    }

}   // Name

The middle name is modeled with Option and is None if absent from the constructor call. The create function chains together three calls to function bind to assemble a valid Name. The following assert statements should be self-explanatory:

assertEquals(
    right(Name(LastName("BARCLAY"), FirstName("Ken"), some(MiddleName("Andrew")))),
    Name.create("BARCLAY", "Ken", "Andrew")
)
assertEquals(left("Last name malformed: Barclay"), Name.create("Barclay", "Ken", "Andrew"))
assertEquals(left("First name malformed: ken"), Name.create("BARCLAY", "ken", "Andrew"))
assertEquals(left("Middle name malformed: andrew"), Name.create("BARCLAY", "Ken", "andrew"))
assertEquals(left("First name malformed: ken"), Name.create("BARCLAY", "ken", "andrew"))



The code for the Dogs library can be found at:

https://github.com/KenBarclay/TBA
https://github.com/KenBarclay/TBA


No comments: