Blog Infos
Author
Published
Topics
Published
Topics

taken from https://arrow-kt.io/

 

In todays article I want to talk about Arrow – the first time since I started writing articles. Arrow offers a lot of different libraries to to bring idiomatic functional programming to Kotlin (taken from the documentation). Because I want to start slow I only choose one topic for today – Exception handling using the Either type.

Exception handling is a critical aspect of building robust and reliable applications. Since I’m from the Java world, I was used to handle exceptions the traditional way using try-catch blocks all over the application. Handling exceptions this way was especially necessary because of the checked exception types I was forced to handle (e.g. if a method is called that throws an IOException). In the Kotlin world the checked exceptions are gone and I no longer need to deal with them.

The Limitations of Try-Catch Exception Handling

While try-catch blocks are a common approach to handle exceptions, they come with certain limitations:

  • Boilerplate CodeTry-catch blocks often result in boilerplate code, making the codebase more verbose and harder to maintain. This can be particularly evident when dealing with multiple nested try-catch blocks.
  • Control Flow Interruption: When an exception occurs within a try – block, the control flow is immediately transferred to the catch – block, potentially disrupting the expected flow of the application.
  • Lack of CompositionalityTry-catch blocks are not inherently composable. Handling multiple exceptions or combining exception-prone operations can become cumbersome and convoluted.
  • Error Reporting and Propagation: Exception messages and error reporting can be limited, making it challenging to provide detailed information about the encountered errors.
Introducing Arrow’s Either Type

Arrow’s Either type provides a powerful alternative to traditional try-catch exception handling. Either represents a value that can be either a success or a failure. In the context of error handling, Either is typically used to represent successful computations or error values. It allows for more expressive and composable error handling, promoting cleaner code and better separation of concerns.

Benefits of Arrow’s Either for Exception Handling

Comparing to the try-catch exception handling there are several advantages using Arrow’s Either (in the following I will only name it Either) type.

  • Functional Error HandlingEither enables a functional approach to error handling. Instead of interrupting the control flow with try-catch blocks, I can use combinators like mapflatMap, and recover to transform and combine error-prone operations. This functional style promotes cleaner and more readable code.
  • Expressive Error ModelingEither allows me to explicitly model and represent errors as values. This approach provides a better understanding of error flows, enhances error reporting, and facilitates targeted error handling.
  • Separation of Concerns: By encapsulating error handling in a generic way using Either, I can separate error handling concerns from the core business logic. This separation enhances code modularity, readability, and testability.
  • Error Recovery and ValidationEither provides mechanisms for error recovery and validation. With combinators like orElse and flatMap, I can recover from specific errors or perform alternative computations. Additionally, Arrow’s Validated type allows for accumulating multiple errors during validation processes, providing comprehensive error reporting.

Now that the differences between traditional exception handling with try-catch blocks and the Either type are clear, let’s have a look how this will look like in a concrete example. For this I created an application using the first approach for exception handling and will show how it can migrated to the second one, providing the same behavior for the consumer of the application.

Example

I use Ktor as framework for providing some http endpoints for creating, updating, finding and deleting an User. The focus is on exception handling, not the complexity of the use-case.

Domain Models

There are 2 domain models available, that contain some validation to prevent creation of invalid objects. From the view of exception handling this means the component, which is responsible for the creation of the domain models, need in some way deal with exceptions.

data class User(
    val id: Identity,
    val firstName: String,
    val lastName: String,
    val birthDate: LocalDate,
    val address: Address,
) {
    init {
        require(firstName.isNotEmpty()) {
            "Firstname must not be empty!"
        }
        require(lastName.isNotEmpty()) {
            "Lastname must not be empty!"
        }
        require(birthDate in LocalDate.of(1900, 1, 1)..LocalDate.now()) {
            "Birthdate must be between 1900 and ${LocalDate.now().year}"
        }
    }
}
data class Address(
    val id: Identity,
    val streetName: String,
    val streetNumber: String,
    val zipCode: Int,
    val city: String,
) {
    init {
        require(streetName.isNotEmpty()) {
            "The streetName must not be empty!"
        }
        require(streetNumber.isNotEmpty()) {
            "The streetNumber must not be empty!"
        }
        require(zipCode in 10000..99999) {
            "The zipCode must be between 10000 and 99999!"
        }
        require(city.isNotEmpty()) {
            "The city must not be empty."
        }
    }
}

On top of the domain models I have an UserUseCase that is responsible for the business logic and the communication with the repository. This component also does throw exceptions for different cases like trying to update or delete an non-existing user.

Use-Case

class UserUseCase(
    private val userRepository: UserRepository
) : UserPort {
    override fun addNewUser(user: User): User {
        val existingUser = userRepository.findBy(user.id)
        require(existingUser == null) {
            "User with id ${user.id.getIdOrNull()} already exists."
        }
        return userRepository.save(user)
    }

    override fun updateUser(user: User): User {
        val existingUser = userRepository.findBy(user.id)
        require(existingUser != null) {
            "User with id ${user.id.getIdOrNull()} does not exists."
        }
        return userRepository.update(user)
    }

    override fun deleteUser(userId: Identity) {
        val existingUser = userRepository.findBy(userId)
        require(existingUser != null) {
            "User with id ${userId.getIdOrNull()} does not exists."
        }
        userRepository.delete(existingUser.id)
    }

    override fun findUser(userId: Identity): User {
        val existingUser = userRepository.findBy(userId)
        require(existingUser != null) {
            "User with id ${userId.getIdOrNull()} does not exists."
        }
        return existingUser
    }
}

For the storage of the data I use an In-Memory repository implementation, that is keeping all data in a mutable list. This is enough for this example to demonstrate the exception handling. There are also exceptional cases that can occur in communication with the repository.

Adapter

class InMemoryUserRepository : UserRepository {
    private val userList = mutableListOf<User>()

    override fun save(user: User): User {
        val successfullyAdded = userList.add(user)
        if (!successfullyAdded) {
            error("User could not be added.")
        }
        return user
    }

    override fun findBy(userId: Identity): User? {
        return userList.find { it.id == userId }
    }

    override fun update(user: User): User {
        val existingUser = findBy(user.id) ?: error("User does not exist.")
        userList.remove(existingUser)
        val updatedUser = existingUser.copy(
            firstName = user.firstName,
            lastName = user.lastName,
            birthDate = user.birthDate,
            address = user.address
        )
        userList.add(
            updatedUser
        )
        return updatedUser
    }

    override fun delete(userId: Identity) {
        userList.removeIf { it.id == userId }
    }
}

The last component that is available is the controller that is responsible for the orchestration of the user-workflow. There is an incoming http request that is deserialized to a DTO object (already this can throw exceptions if not able to deserialize the request body or an request parameter is not available), mapped to the domain model (including the validation during creation) and calling of the use-case. All possible exceptions that can happen on the way are handled by the controller component.

class UserController(
    private val userPort: UserPort
) {
    private val logger = LoggerFactory.getLogger(UserController::class.java)

    suspend fun addNewUser(call: ApplicationCall) {
        return try {
            val user = call.receive<UserDto>().toUser()
            call.respond(HttpStatusCode.Created, userPort.addNewUser(user).toUserDto())
        } catch (e: IllegalArgumentException) {
            logger.error("Invalid input.", e)
            call.respond(HttpStatusCode.BadRequest, "Invalid input: ${e.message}")
        } catch (e: Exception) {
            logger.error("Failed to add new user.", e)
            call.respond(HttpStatusCode.InternalServerError, "Failed to add new user: ${e.message}")
        }
    }

    suspend fun updateUser(call: ApplicationCall) {
        return try {
            val user = call.receive<UserDto>().toUser()
            call.respond(HttpStatusCode.OK, userPort.updateUser(user).toUserDto())
        } catch (e: IllegalArgumentException) {
            logger.error("Invalid input.", e)
            call.respond(HttpStatusCode.BadRequest, "Invalid input: ${e.message}")
        } catch (e: Exception) {
            logger.error("Failed to update user.", e)
            call.respond(HttpStatusCode.InternalServerError, "Failed to update user: ${e.message}")
        }
    }

    suspend fun deleteUser(call: ApplicationCall) {
        return try {
            val userId = call.parameters["userId"] ?: throw IllegalArgumentException("Missing 'userId' parameter.")
            call.respond(HttpStatusCode.Accepted, userPort.deleteUser(UUIDIdentity.fromString(userId)))
        } catch (e: IllegalArgumentException) {
            logger.error("Invalid input.", e)
            call.respond(HttpStatusCode.BadRequest, "Invalid input: ${e.message}")
        } catch (e: Exception) {
            logger.error("Failed to delete user.", e)
            call.respond(HttpStatusCode.BadRequest, "Failed to update user: ${e.message}")
        }
    }

    suspend fun findUser(call: ApplicationCall) {
        return try {
            val userId = call.parameters["userId"] ?: throw IllegalArgumentException("Missing 'userId' parameter.")
            call.respond(HttpStatusCode.Accepted, userPort.findUser(UUIDIdentity.fromString(userId)).toUserDto())
        } catch (e: IllegalArgumentException) {
            logger.error("Invalid input.", e)
            call.respond(HttpStatusCode.BadRequest, "Invalid input: ${e.message}")
        } catch (e: Exception) {
            logger.error("Failed to find user.", e)
            call.respond(HttpStatusCode.BadRequest, "Failed to find user: ${e.message}")
        }
    }
}

There is a generic Exception catch – block to also catch unexpected exceptions that can occur. This is the way I was used to do the exception handling in applications.

Testing this functionality is working very straight forward. AssertJ provides an function for testing for exceptions and its messages.

@Test
    fun `Address cannot be created with empty streetName`() {
        // given
        val id = UUIDIdentity(UUID.randomUUID())
        val streetName = ""
        val streetNumber = "13b"
        val zipCode = 12345
        val city = "Los Angeles"


        // when + then
        assertThatThrownBy {
            Address(
                id = id,
                streetName = streetName,
                streetNumber = streetNumber,
                zipCode = zipCode,
                city = city
            )
        }.isInstanceOf(IllegalArgumentException::class.java).hasMessage("The streetName must not be empty!")
    }

Writing tests that expects a dependency to throw an exception is also no problem. I can mock the dependency and set the expected condition on the mock.

   @Test
fun `addNewUser throws exception if loading of user fails`() {
    // given
    val id = UUIDIdentity(UUID.randomUUID())
    whenever(userRepository.findBy(id)).thenThrow(IllegalStateException("Failed!"))
    val user = User(
        id = id,
        firstName = "Joe",
        lastName = "Black",
        birthDate = LocalDate.of(1999, 1, 1),
        address = Address(
            id = UUIDIdentity(UUID.randomUUID()),
            streetName = "Main Street",
            streetNumber = "122",
            zipCode = 22222,
            city = "Los Angeles"
        )
    )

    // when + then
    assertThatThrownBy {
        userUseCase.addNewUser(user)
    }.isInstanceOf(IllegalStateException::class.java)
        .hasMessage("Failed!")
}
Migration to Arrow’s Either

Now that the initial project is explained lets migrate the existing exception handling to Either.

First I need to add the necessary dependencies to the build.gradle.kts file:

implementation(platform("io.arrow-kt:arrow-stack:1.2.0-RC"))
implementation("io.arrow-kt:arrow-core")

With this change the Either type is available for usage. What are the next steps? I need to change the return types of all functions to return Either instead of the existing types. With this, the failures that can occur during the flow of the request across the application, can be handled at the correct place.

There are 2 types of exceptions I need to handle:

  • Exceptions that are thrown by external components like database or REST calls.
  • Exceptions that are thrown by me because of validation or invalid state.

For representing this cases I add a sealed interface with 2 types:

  • ValidationFailure for the internal validation failures.
  • GenericFailure for exceptions comming from outside.
sealed interface Failure {
    val message: String

    data class ValidationFailure(override val message: String) : Failure

    data class GenericFailure(val e: Exception) : Failure {
        override val message: String = e.localizedMessage
    }
}

I will first start by migrating the exceptions that are thrown by me.

Domain Models

In the the domain models I used the require – function inside the init – block of the data classes to only allow the creation of valid objects according to the business rules.

Because I no longer want to throw exceptions in my code I need to remove this blocks, but still prevent to create invalid objects. There are some problems with this. One problem is that the constructor can not return anything else than an object of the class type itself or throwing an exception. So there are some changes necessary in order to achieve the same behavior as before.

I still want to use a data class to benefit from its advantages (equals and hashCode implementations by default) but I want to create objects and return an Either with the domain model type. The validation logic is placed inside a function inside the companion object of the data class. Instead of require I use ensure inside an either context, that automatically returns the ValidationFailure in case of a missing requirement. To not be able to construct an object without calling this function (copy — constructor) I need to hide the concrete data class behind a sealed interface (making it private) and use an invoke operator to provide a constructor like feeling for the caller.

OUR VIDEO RECOMMENDATION

Jobs

No results found.

sealed interface Address {
    val id: Identity
    val streetName: String
    val streetNumber: String
    val zipCode: Int
    val city: String

    companion object {
        operator fun invoke(id: Identity, streetName: String, streetNumber: String, zipCode: Int, city: String): Either<Failure, Address> {
            return AddressModel.create(id, streetName, streetNumber, zipCode, city)
        }
    }

    private data class AddressModel private constructor(
        val id: Identity,
        val streetName: String,
        val streetNumber: String,
        val zipCode: Int,
        val city: String,
    ) : Address {

        companion object {
            fun create(id: Identity, streetName: String, streetNumber: String, zipCode: Int, city: String): Either<Failure, Address> {
                return either {
                    ensure(streetName.isNotEmpty()) {
                        Failure.ValidationFailure("The streetName must not be empty!")
                    }
                    ensure(streetNumber.isNotEmpty()) {
                        Failure.ValidationFailure("The streetNumber must not be empty!")
                    }
                    ensure(zipCode in 10000..99999) {
                        Failure.ValidationFailure("The zipCode must be between 10000 and 99999!")
                    }
                    ensure(city.isNotEmpty()) {
                        Failure.ValidationFailure("The city must not be empty.")
                    }
                    AddressModel(
                        id = id,
                        streetName = streetName,
                        streetNumber = streetNumber,
                        zipCode = zipCode,
                        city = city
                    )
                }
            }
        }
    }
}

An object of Address can be created by calling the invoke — function in a constructor like way and the copy – function is not leaking the constructor:

Address(
    id = NoIdentity,
    streetName = "Main Street",
    streetNumber = "13b",
    zipCode = 22222,
    city = "Los Angeles"
)

Use-Case

The next component to update is the UserUseCase. I slightly changed the validation. The check for existing/not-existing user I moved to the repository. So the use-case itself has only an exception handling for the findUser — function. I’ve done this to show how the mapping of exceptional cases can be handled in this layer.

The UserUseCase maps as a first step the UserDto to the domain model. Because the possibility exists that this can fail I need to check for a successful result before calling the repository. This can be done by using the flatMap – function, which is only executing its body, if the Either has a right value. In case of a left value (ValidationFailure) the function directly returns.

Thanks to the Kotlin slack channel it got an other option for the un-wrapping of an Either result, you can see for the addNewUser – function. Inside an either – block the result is evaluated. In case of an right value it can directly be used as parameter for the repository call. In case of an left value the repository is not called an the failure is returned.

The findUser – function is working a little different. The result of the repository call needs to be validated. If a user exist, the User object is returned as Either by calling the right() – function on the user object. This is necessary because I need to return an Either no plain User object itself. The same I do for the failure case if no result is available, but instead create a ValidationFailure object and call left() on it.

class UserUseCase(
    private val userRepository: UserRepository
) : UserPort {
    override fun addNewUser(user: UserDto): Either<Failure, User> = either {
        userRepository.save(user.toUser().bind()).bind()
    }

    override fun updateUser(user: UserDto): Either<Failure, User> {
        return user.toUser().flatMap {
            userRepository.update(it)
        }
    }

    override fun deleteUser(userId: Identity): Either<Failure, Unit> {
        return userRepository.delete(userId)
    }

    override fun findUser(userId: Identity): Either<Failure, User> {
        return userRepository.findBy(userId).flatMap { existingUser ->
            existingUser?.right()
                ?: Failure.ValidationFailure("User with id ${userId.getIdOrNull()} does not exists.").left()
        }
    }
}

fun UserDto.toUser(): Either<Failure, User> = either {
    val address = this@toUser.address.toAddress().bind()
    return User(
        id = UUIDIdentity.fromNullableString(this@toUser.id),
        firstName = this@toUser.firstName,
        lastName = this@toUser.lastName,
        birthDate = this@toUser.birthDate,
        address = address
    )
}

private fun AddressDto.toAddress() = Address(
    id = UUIDIdentity.fromNullableString(this.id),
    streetName = this.streetName,
    streetNumber = this.streetNumber,
    zipCode = this.zipCode,
    city = this.city
)

Adapter

The repository implementation is the first component I need to update which can throw exceptions that are not under my control. I use an in-memory variant for simplicity but in real world applications accessing a database can throw several exceptions (like connection problems, invalid sql syntax, constraint violations, …). So in this case the exceptions are not under my control and I need to wrap the potential exceptions in an Either left type. To achieve this I created a function that is handling this for me.

fun <T> eval(logger: Logger, exec: () -> T): Either<Failure, T> {
    return try {
        exec().right()
    } catch (e: Exception) {
        logger.error("Failed to execute operation because of - ${e.message}")
        Failure.GenericFailure(e).left()
    }
}

The eval — function is getting a function as parameter and is executing it inside an try-catch block. If the execution is successful an Either right is returned and in exceptional case an Either left. This is the only try-catch I use in the whole application.

In the repository implementation I wrap every operation on the list (which is simulating the database access) inside this function.

open class InMemoryUserRepository : UserRepository {
    private val logger = LoggerFactory.getLogger(InMemoryUserRepository::class.java)

    private val userList = mutableListOf<User>()

    private fun addToList(user: User): Either<Failure, User> {
        val id = getOrCreateId(user)
        eval(logger) {
            userList.add(user)
        }
        return User(
            id = id,
            firstName = user.firstName,
            lastName = user.lastName,
            birthDate = user.birthDate,
            address = user.address
        )
    }
    
    override fun save(user: User): Either<Failure, User> = either {
        val existingUser = findBy(user.id).bind()
        ensure(existingUser == null) {
            Failure.ValidationFailure("User with id '${user.id.getIdOrNull()}' already exists.")
        }
        addToList(user).bind()
    }

    override fun findBy(userId: Identity): Either<Failure, User?> {
        return eval(logger) {
            userList.find { it.id == userId }
        }
    }

    override fun update(user: User): Either<Failure, User> {
        return findBy(user.id).flatMap { existingUser ->
            if (existingUser == null) {
                Failure.ValidationFailure("User with id '${user.id}' does not exists!").left()
            }
            eval(logger) {
                userList.remove(existingUser)
                addToList(user)
                user
            }
        }
    }

    override fun delete(userId: Identity) = eval(logger) {
        userList.removeIf { it.id == userId }
        Unit
    }
}

The other parts of the repository are very straight forward, so no need to further explain in detail.

The last component that is missing is the REST adapter. This is the place where the Either type is mapped to a matching http status code together with a result. The fold -function is the standard way of handling the content of an Either depending on its type. The first parameter is the function that should be executed in case of an Either left. The second one is the function for the successful case.

The success case is very easy I just need to take the domain model object that is returned, map it to an dto and respond with a corresponding http status code.

The failure case is extracted to a separate function where depending of the type of failure — ValidationFailure or GenericFailure a different http status code is returned.

class UserController(
    private val userPort: UserPort
) {
    private val logger = LoggerFactory.getLogger(UserController::class.java)

    suspend fun addNewUser(call: ApplicationCall) {
        val user = call.receive<UserDto>()
        userPort.addNewUser(user).fold({ failure -> handleFailure(failure, call) }) {
            call.respond(HttpStatusCode.Created, it.toUserDto())
        }
    }

    suspend fun updateUser(call: ApplicationCall) {
        val user = call.receive<UserDto>()
        userPort.updateUser(user).fold({ failure -> handleFailure(failure, call) }) {
            call.respond(HttpStatusCode.OK, it.toUserDto())
        }
    }

    suspend fun deleteUser(call: ApplicationCall) {
        val userId = call.parameters["userId"]
        val identity = UUIDIdentity.fromNullableString(userId)
        userPort.deleteUser(identity).fold({ failure -> handleFailure(failure, call) }) {
            call.respond(HttpStatusCode.Accepted, Unit)
        }
    }

    suspend fun findUser(call: ApplicationCall) {
        val userId = call.parameters["userId"]
        val identity = UUIDIdentity.fromNullableString(userId)
        userPort.findUser(identity).fold({ failure -> handleFailure(failure, call) }) {
            call.respond(HttpStatusCode.Accepted, it)
        }
    }

    private suspend fun handleFailure(failure: Failure, call: ApplicationCall) {
        when (failure) {
            is Failure.ValidationFailure -> {
                logger.error("Invalid input: ${failure.message}.")
                call.respond(HttpStatusCode.BadRequest, "Invalid input: ${failure.message}")
            }

            is Failure.GenericFailure -> {
                logger.error("Failed to complete operation.", failure.e)
                call.respond(HttpStatusCode.InternalServerError, "Failed to complete operation: ${failure.message}")
            }
        }
    }
}

The existing tests that I’ve written for the try-catch exception handling version also need to be updated because instead of expecting an exception to be thrown an Either is returned.

Because I expect an Either left to be returned I can call the leftOrNull — function on the result to assert against.

@Test
   fun `Address cannot be created with empty streetName`() {
       // given
       val id = UUIDIdentity(UUID.randomUUID())
       val streetName = ""
       val streetNumber = "13b"
       val zipCode = 12345
       val city = "Los Angeles"

       // when
       val actual = Address(
           id = id,
           streetName = streetName,
           streetNumber = streetNumber,
           zipCode = zipCode,
           city = city
       )

       // then
       assertThat(actual.leftOrNull()?.message).isEqualTo("The streetName must not be empty!")
   }

The test, which is using Mockito is working the same way. Just instead of a plain User object I need to set an Either as return value.

@Test
  fun `addNewUser throws exception if user already exists`() {
      // given
      val id = UUIDIdentity(UUID.randomUUID())
      doReturn(
          UserDto(
              id = id.id.toString(),
              firstName = "John",
              lastName = "Doe",
              birthDate = LocalDate.of(2000, 1, 1),
              address = AddressDto(
                  id = id.id.toString(),
                  streetName = "Main Street",
                  streetNumber = "122",
                  zipCode = 22222,
                  city = "Los Angeles"
              )
          ).toUser()
      ).whenever(userRepository).findBy(any())

      val user = UserDto(
          id = id.id.toString(),
          firstName = "Joe",
          lastName = "Black",
          birthDate = LocalDate.of(1999, 1, 1),
          address = AddressDto(
              id = id.id.toString(),
              streetName = "Main Street",
              streetNumber = "122",
              zipCode = 22222,
              city = "Los Angeles"
          )
      )

      // when
      val actual = userUseCase.addNewUser(user)

      // then
      assertThat(actual.leftOrNull()?.message)
          .isEqualTo("User with id '${id.id}' already exists.")
  }

With this the exception handling of my sample application is completely replaced with the Either type.

Conclusion

The migration from the traditional exception handling, using try-catch blocks to handle and throw unexpected behavior to propagate, to Either needs some change in how to deal with the not expected behavior in the application flow.

I was used to do the validation of domain models inside the init – block of data classes using require. This is no longer possible in the same way. For this I need to add some more complexity to the code in order to achieve a similar behavior.

Also I need to extract the result of every function call in order to work on the success type. This is different from the traditional exception handling where I do not need to care about exceptions until the place where I want to handle them.

This switch in how to think about exceptions needs some time and especially when using it in real world applications there are some bigger changes in the existing functionality necessary in order to not change the functionality (e.g. when using SpringBoots transaction handling using annotations). But in the end it is worth taking this step because you get a lot back. After some time struggeling with some more complex cases, for me Either is definitely the first option when starting new projects.

You can find the code used for this article on Github: https://github.com/PoisonedYouth/ktor-either

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu