Kotlin + Either con Arrow

0
1580
kotlin_either_arrow

En este breve tutorial vamos explorar un poco la librería de Arrow con Kotlin, en concreto el uso del Either. Si no estas familiarizado con Arrow, siempre puedes echar un vistazo a la documentación oficial aquí.

Índice de contenidos

1. Introducción

Kotlin es un lenguaje de programación que admite el paradigma funcional y orientada a objetos. Cuando desarrollamos líneas y líneas de código solemos realizar un conjunto de llamadas o una secuencia de llamadas para representar un estado como consecuencia de dichas llamadas. Es aquí donde entra en juego Arrow, nos ayuda simplificar ese control de flujo.

2. Entorno

  • Hardware: Portátil MacBook Pro 14 pulgadas (Apple M1 Pro, 32GB RAM)
  • Sistema Operativo: MacOS Monterey 12.5
  • Kotlin 1.7.10
  • Arrow 1.1.2
  • Kotest 1.2.5

3. Arrow

Arrow es una librería para programación funcional tipada en Kotlin que nos proporciona tipos de datos más populares como Option, Either o Validated.

4. Kotest

Kotest es un framework de test para Kotlin con aserciones extensas. Dispone de un módulo para Arrow que nos facilita las aserciones.

5. Ejemplo práctico

Partiremos de una supuesta academia con aulas y estudiantes. Resulta que nos han pedido crear un funcionalidad que dice lo siguiente: «Como administrador de la academia me gustaría poder asignar un estudiante a un aula existente o crear un aula por defecto en caso de que no exista y añadir dicho estudiante».

Para el ejemplo vamos a utilizar como tipo de dato Either<L, R> y las funciones map, flatMap, handleErrorWith y catch.

  • Either: es un tipo de dato que guarda un estado left (L) o right (R).
  • map: es una función que recibe otra función de transformación f: (R) -> R2 para pasar de un Either<L, R> a Either<L, R2>.
  • flatMap: es una función que recibe otra función de transformación f: (R) -> Either<L2, R2> para pasar de un Either<L, R> a Either<L2, R2>.
  • handleErrorWith: es una función que recibe otra funcion de transformación f: (L) -> Either<L2, R>. Su uso es bastante útil cuando queremos pasar de un left a un right.
  • catch: es una función que recibe otra función f: () -> R para evaluarla y devolver un Either<L, R>. Su uso es muy común cuando queremos convertir un estado a un Either<L, R>. En resumidas cuentas no todo devuelve un Either<L, R>, en este mismo ejemplo los servicios de dominio ClassRoomFinder o StudentFinder no tienen implementado Arrow y por ello hacemos uso del catch para realizar la conversión.

5.1. Dependencias


dependencies {
    implementation("io.arrow-kt:arrow-core:1.1.2")

    testImplementation(kotlin("test"))
    testImplementation("org.mockito:mockito-junit-jupiter:4.8.0")
    testImplementation("org.mockito:mockito-inline:4.7.0")
    testImplementation("io.kotest.extensions:kotest-assertions-arrow:1.2.5")
}

5.2. Diseño

Por un lado tenemos un aula:


data class Classroom(
    val name: String,
    val students: Set
) {

    fun add(student: Student) = copy(students = students.plus(student))

    companion object {
        fun create(
            name: String
        ) = Classroom(name, emptySet())
    }
}

Y por otro un estudiante:


data class Student(
    val name: String,
    val age: Int,
    val email: String
)

5.2.1. Implementación sin Arrow

Para realizar la lógica de «asignación» de estudiantes disponemos de un servicio de dominio llamado StudentClassSimpleAssigner.

5.2.1.1. Servicio de dominio


import org.autentia.tutorial.arrow.domain.Classroom
import org.autentia.tutorial.arrow.domain.ClassroomNotFoundException
import org.autentia.tutorial.arrow.domain.ClassroomRepository
import org.autentia.tutorial.arrow.domain.create.ClassroomCreator
import org.autentia.tutorial.arrow.domain.find.ClassRoomFinder
import org.autentia.tutorial.arrow.domain.find.StudentFinder

class StudentClassSimpleAssigner(
    private val classroomFinder: ClassroomFinder,
    private val studentFinder: StudentFinder,
    private val classroomRepository: ClassroomRepository,
    private val classroomCreator: ClassroomCreator
) {
    fun assign(email: String, classRoomName: String): Classroom {
        val classroom = findOrCreateClassroom(classRoomName)
        val student = studentFinder.find(email)

        val classroomUpdated = classRoom.add(student)

        classroomRepository.save(classroomUpdated)

        return classroomUpdated
    }

    private fun findOrCreateClassroom(classRoomName: String): Classroom = try {
        classRoomFinder.find(classRoomName)
    } catch (e: ClassroomNotFoundException) {
        val newClassroom = Classroom.create(classRoomName)
        classroomCreator.create(newClassroom)
        newClassroom
    }
}

Estoy casi seguro que los servicios de dominio recibidos por constructor son bastante claros, pero de todas formas dejo una breve acalaración de cada uno de ellos:

  • ClassRoomFinder: Busca un aula.
  • StudentFinder: Busca un estudiante.
  • ClassroomCreator: Crea un aula.

5.2.1.2. Test unitario


import org.autentia.tutorial.arrow.domain.*
import org.autentia.tutorial.arrow.domain.create.ClassroomCreator
import org.autentia.tutorial.arrow.domain.find.ClassRoomFinder
import org.autentia.tutorial.arrow.domain.find.StudentFinder
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.Mockito.*
import kotlin.test.assertEquals

class StudentClassSimpleAssignerTest {

    private val classroomFinder: ClassroomFinder = mock(ClassroomFinder::class.java)
    private val studentFinder: StudentFinder = mock(StudentFinder::class.java)
    private val classroomRepository: ClassroomRepository = mock(ClassroomRepository::class.java)
    private val classroomCreator: ClassroomCreator = mock(ClassroomCreator::class.java)
    private val sut = StudentClassSimpleAssigner(classroomFinder, studentFinder, classroomRepository, classroomCreator)

    @Test
    fun `should assign student to classroom`() {
        `classroom exists`(classroom)
        `student exists`(student)

        val result = sut.assign(student.email, classroom.name)

        assertEquals(expectedClassroom, result)
        verifyNoInteractions(classroomCreator)
        verify(classroomRepository).save(classroom.copy(students = setOf(student)))
    }

    @Test
    fun `should assign student to classroom when classroom does not exist`() {
        `classroom does not exist`()
        `student exists`(student)

        val classroomName = "classroom does not exist"
        val classroom = Classroom(classroomName, emptySet())
        val expectedClassroom = classroom.copy(students = setOf(student))

        val result = sut.assign(student.email, classroomName)

        assertEquals(expectedClassroom, result)
        verify(classroomCreator).create(classroom)
        verify(classroomRepository).save(expectedClassroom)
    }

    @Test
    fun `should return error when student does not exist`() {
        `classroom exists`(classroom)
        `student does not exist`()

        assertThrows { sut.assign(student.email, classroom.name) }
    }

    private fun `classroom exists`(classroom: Classroom) {
        `when`(classRoomFinder.find(classroom.name)).thenReturn(classroom)
    }

    private fun `classroom does not exist`() {
        `when`(classRoomFinder.find(anyString())).thenThrow(ClassroomNotFoundException)
    }

    private fun `student exists`(student: Student) {
        `when`(studentFinder.find(student.email)).thenReturn(student)
    }

    private fun `student does not exist`() {
        `when`(studentFinder.find(anyString())).thenThrow(StudentNotFoundException)
    }

    private companion object {
        private val classroom = ClassroomMother.sample()
        private val student = StudentMother.sample()
        private val expectedClassroom = classroom.add(student)
    }
}

5.2.2. Implementación con Arrow

Disponemos de un servicio de dominio llamado StudentClassArrowAssigner.

5.2.2.1. Servicio de dominio


import arrow.core.Either
import arrow.core.Either.Companion.catch
import arrow.core.flatMap
import arrow.core.handleErrorWith
import arrow.core.left
import org.autentia.tutorial.arrow.domain.Classroom
import org.autentia.tutorial.arrow.domain.ClassroomNotFoundException
import org.autentia.tutorial.arrow.domain.ClassroomRepository
import org.autentia.tutorial.arrow.domain.Student
import org.autentia.tutorial.arrow.domain.create.ClassroomCreator
import org.autentia.tutorial.arrow.domain.find.ClassRoomFinder
import org.autentia.tutorial.arrow.domain.find.StudentFinder

class StudentClassArrowAssigner(
    private val classroomFinder: ClassroomFinder,
    private val studentFinder: StudentFinder,
    private val classroomRepository: ClassroomRepository,
    private val classroomCreator: ClassroomCreator
) {
    fun assign(email: String, classroomName: String): Either<Throwable, Classroom> =
        findOrCreateClassroom(classroomName)
            .flatMap { classroom -> findStudent(email).map { student -> classroom.add(student) } }
            .flatMap { classroom -> saveClassroom(classroom) }

    private fun findOrCreateClassroom(classRoomName: String): Either<Throwable, Classroom> =
        catch { classRoomFinder.find(classRoomName) }
            .handleErrorWith { error -> if (error is ClassroomNotFoundException) createClassroom(classRoomName) else error.left() }

    private fun saveClassroom(classroom: Classroom): Either<Throwable, Classroom> =
        catch { classroomRepository.save(classroom) }.map { classroom }

    private fun findStudent(email: String): Either<Throwable, Student> =
        catch { studentFinder.find(email) }

    private fun createClassroom(classroomName: String): Either<Throwable, Classroom> =
        catch {
            val classroom = Classroom.create(classroomName)
            classroomCreator.create(classroom)
            classroom
        }
}

Si analizamos un poco el código.

  • Hacemos uso de Either para marcar un estado en la salida del método fun assign(email: String, classRoomName: String): Either<Throwable, Classroom>.
  • Hacemos uso de catch para evaluar y convertir la simple respuesta de cada servicio de dominio a un Either.
  • Hacemos uso de handleErrorWith para convertir de un left a un right cuando el aula no existe.
  • Hacemos uso de flatMap para hacer transformaciones sobre el Either.
  • Hacemos uso de map para hacer transformaciones solo sobre el right. Por ejemplo, en la línea 23 buscamos un estudiante y hacemos un map porque lo que queremos es un right del aula con el estudiante asignado.

5.2.2.2. Test unitario


import io.kotest.assertions.arrow.core.shouldBeLeft
import io.kotest.assertions.arrow.core.shouldBeRight
import org.autentia.tutorial.arrow.domain.*
import org.autentia.tutorial.arrow.domain.create.ClassroomCreator
import org.autentia.tutorial.arrow.domain.find.ClassRoomFinder
import org.autentia.tutorial.arrow.domain.find.StudentFinder
import org.junit.jupiter.api.Test
import org.mockito.Mockito.*

class StudentClassArrowAssignerTest {

    private val classroomFinder: ClassroomFinder = mock(ClassroomFinder::class.java)
    private val studentFinder: StudentFinder = mock(StudentFinder::class.java)
    private val classroomRepository: ClassroomRepository = mock(ClassroomRepository::class.java)
    private val classroomCreator: ClassroomCreator = mock(ClassroomCreator::class.java)
    private val sut = StudentClassArrowAssigner(classroomFinder, studentFinder, classroomRepository, classroomCreator)

    @Test
    fun `should assign student to classroom`() {
        `classroom exists`(classroom)
        `student exists`(student)

        val result = sut.assign(student.email, classroom.name)

        result.shouldBeRight(expectedClassroom)
        verifyNoInteractions(classroomCreator)
        verify(classroomRepository).save(expectedClassroom)
    }

    @Test
    fun `should assign student to classroom when class does not exist`() {
        `classroom does not exist`()
        `student exists`(student)

        val classroomName = "classroom does not exist"
        val classroom = Classroom(classroomName, emptySet())
        val expectedClassroom = classroom.copy(students = setOf(student))

        val result = sut.assign(student.email, classroomName)

        result.shouldBeRight(expectedClassroom)
        verify(classroomCreator).create(classroom)
        verify(classroomRepository).save(expectedClassroom)
    }

    @Test
    fun `should return error when student does not exist`() {
        `classroom exists`(classroom)
        `student does not exist`()

        val result = sut.assign(student.email, classroom.name)

        result.shouldBeLeft(StudentNotFoundException)
    }

    private fun `classroom exists`(classroom: Classroom) {
        `when`(classRoomFinder.find(classroom.name)).thenReturn(classroom)
    }

    private fun `classroom does not exist`() {
        `when`(classRoomFinder.find(anyString())).thenThrow(ClassroomNotFoundException)
    }

    private fun `student exists`(student: Student) {
        `when`(studentFinder.find(student.email)).thenReturn(student)
    }

    private fun `student does not exist`() {
        `when`(studentFinder.find(anyString())).thenThrow(StudentNotFoundException)
    }

    private companion object {
        private val classroom = ClassroomMother.sample()
        private val student = StudentMother.sample()
        private val expectedClassroom = classroom.add(student)
    }
}

Gracias a Kotest podemos evaluar un Either con los métodos shouldBeRight y shouldBeLeft.

6. Pequeña reflexión

Como primera impresión puede ser chocante y poco legible. Pero cuando estamos desarrollando un código más complejo con muchos flujos, estados y errores, podría ser de gran ayuda el uso de Arrow y no solo el tipo de dato Either.

Por ejemplo, podríamos tener el siguiente flujo:


fun example(id: Id): Either =
        repository.findById(id)
            .flatMap { a -> a.operation1() }
            .flatMap { a -> a.operation2() }
            .flatMap { b -> b.operation1() }
            .flatMap { b -> b.operation2() }
            .flatMap { c -> c.operation1() }
            .flatMap { c -> c.operation2() }
            .flatMap { d -> c.operation1() }

Imaginaros hacer ésto mismo sin Arrow, ¿posible?, sí pero el código se convierte en algo denso y complicado de entender. Además la gestión de estados en cada operación sería un sin fin de if else o cualquier otro tipo de control de flujo, sin mencionar la gestión de errores con nuestro try catch. Aquí es donde debemos hacer una pequeña «reflexión»: ¿conviene la implementación de Arrow?.

Típica respuesta: depende. No es una cuestión de gustos.

Pero esto no es todo, otro punto a pensar es, ¿cómo manejamos la transaccionalidad con un Either?. La gestión de operaciones atómicas es diferente, deberíamos crear una pieza intermedia, y cuando sea un left realizar un rollback. La teoría parece simple pero la práctica indica lo contrario. A día de hoy los grandes frameworks como Spring o Micronaut no dispone integraciones con Arrow, por lo tanto, si estas interesado en manejar transaccionabilidad, es un buen punto a tener en cuenta antes de implementarlo.

7. Conclusión

El objetivo del tutorial no es convencer el uso de Either con Arrow, más bien una comparación. ¿Cuándo usar Either? y ¿cuándo no usar Either?, bajo mi opinión, en arquitecturas basadas en eventos donde no debería existir transaccionalidad es donde mejor encaja Either. En el resto de arquitecturas lo que añade es complejidad en el código y en la gestión de operaciones atómicas.

Y por último, si quieres crear tu propio Either, puedes echar un vistazo al siguiente tutorial pinchando aquí

8. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad