Functional Domain Modeling in Kotlin - Validation

Functional Domain Modeling in Kotlin - Validation

In a previous blog post about Functional Domain Modeling in Kotlin, we discussed how we can use data class, enum class, sealed class, and inline class to describe our business domain as accurately as possible to achieve more type-safety, maximize the use of the compiler with our domain, and thus, prevent bugs and reduce unit testing.

At the end, we briefly discussed how we could use Arrow’s Either type to bring more type-safety into our business logic. In this blog post, we’ll explore how we can use Either and Validated to achieve different goals.

Let’s continue with the domain from the previous blog post, which was Event.

import java.time.LocalDate

inline class EventId(val value: Long)
inline class Organizer(val value: String)
inline class Title(val value: String)
inline class Description(val value: String)

data class Event(
  val id: EventId,
  val title: Title,
  val organizer: Organizer,
  val description: Description,
  val date: LocalDate
)

To create a proper Event, we need to validate that the properties meet certain conditions. To do so, we can create smart constructors that return Either. They’ll return ValidationError in the Left side in the case a condition is not met, or an Event in the Right side if all conditions were met.

import arrow.core.Either
import arrow.core.Either.Left
import arrow.core.Either.Right

data class ValidationError(val reason: String)

inline class EventId(val value: Long) {
  companion object {
    fun create(value: Long): Either<ValidationError, EventId> =
      if (value > 0) Right(EventId(value))
      else Left(ValidationError("EventId needs to be bigger than 0, but found $value."))
  }
}

inline class Organizer(val value: String) {
  companion object {
  	/* Same implementation for Title and Description */
    fun create(value: String): Either<ValidationError, Organizer> = when {
      value.isEmpty() -> Left(ValidationError("Organizer cannot be empty"))
      value.isBlank() -> Left(ValidationError("Organizer cannot be blank"))
      else -> Right(Organizer(value))
    }
  }
}

In the example above, we add create smart constructors on the companion objects of our domain types. For simplicity, we’ve only shown the implementation for Organizer since we can use an identical implementation for validation for Title and Description. This solution is not yet fool-proof since the constructor of the class is still public. We’ll discuss this, and other solutions, further in the next blog post about type refinement.

We can now easily construct an Event using the either computation block, which allows us to extract the success value to the left-hand side. When encountering a Left case, it immediately returns with the Left result. This behavior is commonly referred to as short circuit behavior, since the either computation block short circuits when encountering a Left case.

These computation blocks have support for suspend. But, in case you don’t want to call it from a suspend fun, you can use either.eager instead, which is an implementation using @RestrictSuspension.

import arrow.core.computations.either

suspend fun generateId(): Long =
  -1L

suspend fun createEvent(): Either<ValidationError, Event> =
  either {
	val id = EventId.create(generateId()).bind()
	val title = Title.create("                ").bind()
	val organizer = Organizer.create("").bind()
	val description = Description.create("").bind()
	Event(id, title, organizer, description, LocalDate.now())
  } // Left(ValidationError("EventId needs to be bigger than 0, but found -1."))

Since the validation of EventId failed, the either block immediately returns with Left for EventId. So, we have no idea that Title, Organizer, and Description were also incorrect because Either short circuits with the first encountered Left.

Imagine using this technique for validating a form to create an Event, and instead of indicating all incorrect filled fields, it tells you that title is incorrect. And when you fix it, it tells you that organizer name is incorrect, and a third time it’ll tell you that the description is not filled out correctly. That wouldn’t be user-friendly at all.

What we’d like to do instead for validation is to accumulate all errors that we’ve encountered, OR return the success value.

So let’s refactor our above defined validation snippet to Validated.

import arrow.core.Valid
import arrow.core.ValidatedNel
import arrow.core.invalidNel

data class ValidationError(val reason: String)

inline class EventId(val value: Long) {
  companion object {
    fun create(value: Long): ValidatedNel<ValidationError, EventId> =
      if (value > 0) Valid(EventId(value))
      else ValidationError("EventId needs to be bigger than 0, but found $value.").invalidNel()
  }
}

inline class Organizer(val value: String) {
  companion object {
    fun create(value: String): ValidatedNel<ValidationError, Organizer> = when {
      value.isEmpty() -> ValidationError("Organizer cannot be empty").invalidNel()
      value.isBlank() -> ValidationError("Organizer cannot be blank").invalidNel()
      else -> Valid(Organizer(value))
    }
  }
}

As you can see, very little changed between our Either based code and our Validated based code. That’s because they model the same kind of relationship, which we’ve discussed in our previous blog post. The OR relationship and both have two cases: Left/Invalid and Right/Valid.

As you might’ve guessed, the difference between the two is that Either short circuits on Left, and Validated accumulates the errors in Invalid.

So if we try to construct an Event using the Validated type instead of Either, we’ll be able to figure out all errors that occurred trying to construct the Event.

To do so, we’re using ValidatedNel, which is typealias ValidatedNel<E, A> = Validated<NonEmptyList<E>, A>. This allows us to accumulate all errors into NonEmptyList.

Using NonEmptyList instead of List is more precisely modeled since there will always be at least one ValidationError; otherwise we’d expect a Valid Event.

suspend fun generateId(): Long =
  -1L

suspend fun date(): LocalDate =
  LocalDate.now()

suspend fun createEvent(): ValidatedNel<ValidationError, Event> =
  EventId.create(generateId()).zip(
    Title.create(""),
  	Organizer.create(""),
  	Description.create("")
  ) { id, title, organizer, description -> Event(id, title, organizer, description, date()) }

  //Invalid(NonEmptyList(
  //      ValidationError("EventId needs to be bigger than 0, but found -1."),
  //      ValidationError("Title cannot be blank"),
  //      ValidationError("Organizer cannot be empty"),
  //      ValidationError("Description cannot be empty")
  //))

In the resulting value, we find all accumulated errors that occurred while trying to construct an Event.

Delegating our call to zip allows us to combine independent values. In order to combine these values, we also need to provide a way to combine the Invalid cases. Here we use Semigroup.nonEmptyList() by default for ValidatedNel. Semigroup is a functional interface that defines how to combine associatively. In the case of NonEmptyList, it simply delegates to NonEmptyList#plus, which results in a non-empty list of all elements.

We can use suspend functions both in the zip parameters, and in the map lambda since the zip function is inline in its definition. This constructor is also available for Either, while maintaining the short circuit behavior of Either and thus not requiring a Semigroup.

At this point, we may be wondering when to use Either or when to use Validated. Generally speaking, we would choose Either when we want a short-circuiting behavior while validating values; that is for the computation to stop whenever we encounter an invalid case. We would use Validated, especially its ValidatedNel form, when we want to accumulate invalid cases over multiple independent validations and therefore not short-circuiting on failure.

In all use cases, toEither or toValidated serve as conversion functions. Furthermore, both Validated and Either values are accepted inside either computation blocks and they’re able to .bind() and short-circuit via suspension when needed.

suspend fun createEvent(): Validated<NonEmptyList<ValidationError>, Event> = ...

suspend fun createEventAndLog(): Either<NonEmptyList<ValidationError>, Unit> =
  either {
    val event = createEvent().bind()
    println(event)
  }

Now that we’ve seen how we can use either computation blocks and zip, let’s see some other interesting operators and parallel operators.

Often, we have to work with many values, for example a List of identifiers, which we need to process or validate. A common use case with List would be to use map, but this has some undesired results if we use it in combination with another wrapper like Either or Validated.

data class User(val id: Long, val name: String)

fun createUser(id: Long, name: String): Either<ValidationError, User> {
  val id = if(id > 0) Right(id) else Left(ValidationError("Id of name: $name needs to be bigger than 0, but found $id."))
  val name = if (name.isNotBlank()) Right(name) else Left(ValidationError("Name of id: $id cannot be blank"))

  return id.zip(name, ::User)
}

fun Iterable<Pair<Long, String>>.createUsers(): List<Either<ValidationError, User>> =
  map { (id, name) -> createUser(id, name) }

The result of createUsers is of the type List<Either<ValidationError, User>>, which is not very useful because, to do anything with the User, we have to check every value in the list and check if it’s Left or Right, which is cumbersome to do. This also doesn’t take into account the short-circuiting behavior of Either, and instead runs createUser for all values also if previous operations resulted in Left.

So let’s use traverseEither instead.

import arrow.core.traverseEither

fun Iterable<Pair<Long, String>>.createUsers(): Either<ValidationError, List<User>> =
  ids.traverseEither { (id, name) -> createUser(id, name) }

traverseEither applies a function to every value inside the collection and returns an Either with the List of all successful applications of the function.

On the other hand, traverseEither will short circuit if it encounters a Left value and it immediately returns it and ignores the rest of elements.

We can do the same thing for Validated using traverseValidated and supplying a Semigroup. When doing so with Validated instead of short circuit, it will accumulate all the errors of all the values in the collection.

import arrow.core.traverseValidated

fun createUser(id: Long, name: String): ValidatedNel<ValidationError, User> {
  val id = if(id > 0) Valid(id) else ValidationError("Id of name: $name needs to be bigger than 0, but found $id.").invalidNel()
  val name = if (name.isNotBlank()) Valid(name) else ValidationError("Name of id: $id cannot be blank").invalidNel()

  return id.zip(name, ::User)
}

suspend fun Iterable<Pair<Long, String>>.createUsers(): ValidatedNel<ValidationError, List<User>> =
  ids.traverseValidated { (id, name) -> createUser(id, name) }

As a final example, we’re going to introduce some concurrency with another Arrow library called Arrow Fx Coroutines. In Arrow Fx Coroutines, we can find a traverseXXX variant that runs the supplied function in parallel, called parTraverseXXX.

So let’s see the same example for Either, but let’s introduce parallelism.

import arrow.fx.coroutines.parTraverseEither

fun createUser(id: Long, name: String): Either<ValidationError, User> {
  val id = if(id > 0) Right(id) else Left(ValidationError("Id of name: $name needs to be bigger than 0, but found $id."))
  val name = if (name.isNotBlank()) Right(name) else Left(ValidationError("Name of id: $id cannot be blank"))

  return id.zip(name, ::User)
}

suspend fun Iterable<Pair<Long, String>>.createUsers(): Either<ValidationError, List<User>> =
  ids.parTraverseEither { (id, name) -> createUser(id, name) }

Here we’ll launch N coroutines in parallel, where N is the size of the List, where we run the createUser functions. The short circuit behavior of Either is maintained, and all still-running parallel tasks will be cancelled before the function returns the first encountered Left case.

When applying the same technique to Validated, it will instead run all parallel tasks to finish, and it will accumulate all the parallel encountered errors.

import arrow.core.typeclasses.Semigroup
import arrow.fx.coroutines.parTraverseValidated

fun Iterable<Pair<Long, String>>.createUsers(): ValidatedNel<ValidationError, List<User>> =
  ids.parTraverseValidated(Semigroup.nonEmptyList()) { (id, name) -> createUser(id, name) }

This makes parTraverseValidated a powerful function to do concurrent validation without any boilerplate by just using the Validated data type. And parTraverseEither is a powerful function for combining operations in parallel when we’re only interested in the result if all tasks succeed.

Either, Validated, and traverse can be found inside Arrow Core, and the parallel operators can be found in Arrow Fx Coroutines.

depdendencies {
  def arrowVersion = "0.13.1"

  implementation "io.arrow-kt:arrow-core:$arrowVersion"
  implementation "io.arrow-kt:arrow-fx-coroutines:$arrowVersion"
}

In this post, we’ve seen how we can improve our domain by:

  • Using Either when we’re only interested in the first error OR the success value.
  • Using Validated when we’re interested in all errors OR the success value.
  • Using traverse to map an Iterable that returns Either or Validated
  • Using parTraverse to parallel map an Iterable that returns Either or Validated.

Some of you might have noticed that issues remain with some of the code above, which we’ll discuss in the next and final blog post of functional domain modeling about type refinement in Kotlin! So stay tuned for more functional domain modeling in Kotlin.

If you are interested in learning how to write pure functional programs using Kotlin, keep watching for the next available Kotlin training course offered by the 47 Degrees Academy.

Ensure the success of your project

47 Degrees can work with you to help manage the risks of technology evolution, develop a team of top-tier engaged developers, improve productivity, lower maintenance cost, increase hardware utilization, and improve product quality; all while using the best technologies.