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
- 2. Entorno
- 3. Arrow
- 4. Kotest
- 5. Ejemplo práctico
- 6. Pequeña reflexión
- 7. Conclusión
- 8. Referencias
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 unEither<L, R>
aEither<L, R2>
. - flatMap: es una función que recibe otra función de transformación
f: (R) -> Either<L2, R2>
para pasar de unEither<L, R>
aEither<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 unEither<L, R>
. Su uso es muy común cuando queremos convertir un estado a unEither<L, R>
. En resumidas cuentas no todo devuelve unEither<L, R>
, en este mismo ejemplo los servicios de dominioClassRoomFinder
oStudentFinder
no tienen implementado Arrow y por ello hacemos uso delcatch
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étodofun 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 unEither
. - 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 elEither
. - Hacemos uso de
map
para hacer transformaciones solo sobre el right. Por ejemplo, en la línea 23 buscamos un estudiante y hacemos unmap
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í