8 minute read

Índice de temario

Día Tema a tratar
1 Definiendo objetivos
2 Proyecto inicial
3 Endpoint básico
4 Entidades y persistencia
5 Transaccionalidad
6 Migraciones
7 Manejo de excepciones

Día 4. Entidades y persistencia

En el post anterior cubrimos la capa de UI de forma básica, con un test y una respuesta básica. Ahora toca hacer que el endpoint realmente haga lo que promete, que es guardar un usuario.

El tema de mapeo de entidades y persistencia es algo más peliagudo de configurar, aunque se parece bastante a Doctrine como concepto, esconde varias cosas algo oscuras a simple vista.

Vamos a partir de una entidad de usuario básica que tenga su Id, y su Username. Con esto podremos poner a prueba la persistencia. Para ello, nos serviremos de lo aprendido en nuestro mini manual para pasar de PHP a Kotlin , y concretamente el tema de los Value objects y las Entidades


¡¡¡Ojo al la hora de usar @JvmInline en los value objects!!! Aunque está definido como una optimización de Kotlin, resulta que me falla a la hora de usarlo con JPA. Desconozco la causa, pero en el momento de escribir el post, es lo que hay…


Con lo que la entidad quedaría así con los tests:

package com.example.users.user.domain

import com.example.users.user.valueobjects.UserId
import com.example.users.user.valueobjects.UserName
import org.assertj.core.api.Assertions.assertThat
import java.util.*
import kotlin.test.Test

private const val USER_NAME = "Test user"

internal class UserTest {

    @Test
    fun `Should be build`() {
        val userIdData = UUID.randomUUID().toString()

        val user = User.build(
            UserId.build(userIdData),
            UserName.build(USER_NAME)
        )

        assertThat(user.userId.value).isEqualTo(userIdData)
        assertThat(user.userName.value).isEqualTo(USER_NAME)
    }
}

Y el código (contando que ya tenemos los value objects creados, no voy a meterme en esa parte).

package com.example.users.user.domain

import com.example.users.user.valueobjects.UserId
import com.example.users.user.valueobjects.UserName
import javax.persistence.*

class User private constructor(
    val userId: UserId,
    val userName: UserName
) {
    @JvmStatic
    companion object {
        fun build(userId: UserId, userName: UserName): User {
            return User(userId, userName)
        }
    }
}

Persistencia.. allá vamos!!!

Preparar las entidades para trabajar con JPA

Primer escollo… nuestras entidades han de tener un constructor sin argumentos para que JPA pueda instanciarlo y luego rellenarlo por reflexión. Para eso, debemos agregar un plugin al fichero bulid.gradle.kts. Con este plugin, todas los objetos marcados con anotaciones @Entity, @MappedSuperClass y @Embeddable tendrán dicho constructor. Por otro lado, también hace las clases open para cumplir con otro requisito, como en Doctrine, que las clases de tipo entidad no pueden ser final.

plugins {
    ...
   	kotlin("plugin.jpa") version "1.4.32"
    ...
}

Crear tablas con herramienta de migraciones

Para persistir datos, primero hemos de poder crear tablas en nuestra base de datos. Y eso requiere una herramienta que pueda gestionar migraciones.

Lo primero, comentar que no hay una herramienta parecida a las migrations de Doctrine en Springboot. Con lo que hay que buscar una herramienta externa que nos lo gestione. Miré en su momento Flyway y Liquibase. Me quedé con la segunda por razones que explico en esta comparativa .

En definitiva, en nuestro build.gradle.kts, agregamos la dependencia:

dependencies {
    ....
	implementation("org.liquibase:liquibase-core")
    ....
}

Lo configuramos en application.properties para que encuentre nuestro fichero de migraciones.

spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml

Y aportamos contenido en db.changelog-master.xml

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xmlns:pro="http://www.liquibase.org/xml/ns/pro"
                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd
http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-4.1.xsd">
    <changeSet id="202112231812" author="Fernando Aparicio">
        <createTable tableName="users">
            <column name="id" type="uniqueidentifier">
                <constraints primaryKey="true" />
            </column>
            <column name="username" type="varchar(250)">
                <constraints unique="true"/>
            </column>
        </createTable>
    </changeSet>
</databaseChangeLog>

Al arrancar la aplicación, ejecutará las migraciones que tenga pendientes y tendremos la tabla creada.

Vamos al código por fin

Ya tenemos la entidad, los value objects y la tabla donde guardar nuestro usuario. Ahora queda hacer que nuestro caso de uso persista el usuario.

Para ello, el endpoint debe crear un command, un handler y un repository para poder procesar la petición. De momento lo haremos simple, sin Bus ni nada.

Command

package com.example.users.user.application.commands

data class SignUpUserCommand(val userName: String)

Handler

package com.example.users.user.application.commands

import com.example.users.user.domain.UniqueIdentifierProvider
import com.example.users.user.domain.User
import com.example.users.user.domain.UserRepository
import com.example.users.user.valueobjects.UserId
import com.example.users.user.valueobjects.UserName
import org.springframework.beans.factory.annotation.Autowired

@Service
@Transactional(rollbackFor = [Exception::class])
class SignUpUserHandler @Autowired constructor(
    private val userRepository: UserRepository,
    private val uniqueIdentifierProvider: UniqueIdentifierProvider
) {
    fun execute(signUpUserCommand: SignUpUserCommand) {
        userRepository.save(
            User.build(
                UserId.build(uniqueIdentifierProvider.generate()),
                UserName.build(signUpUserCommand.userName)
            )
        )
    }
}

Si os fijáis, hay la anotación @Service, que lo necesitamos para hacer usar el @Autowired en la capa de UI que veremos al final de todo. Y también @Transactional, que nos hace todo el caso de uso transaccional en base de datos. Los parámetros que le ponemos al transactional o comentaremos en el siguiente post para no desviarnos del tema.

Aquí ya podéis observar que se le ha agregado interfaz a un repositorio de usuario. En este caso vamos a optar por usar lo más canónico según DDD, que es no acoplar infraestructura a dominio.

Pasamos de esta configuración, que nos daría un abanico de funcionalidad extra que no usaremos:

package com.example.users.user.domain

import com.example.users.user.valueobjects.UserId
import org.springframework.data.repository.CrudRepository

interface UserRepository: CrudRepository<User, UserId>{

}

Y nosotros haremos que la interfaz sea pura.

Interfaz de repository

package com.example.users.user.domain

import com.example.users.user.valueobjects.UserId

interface UserRepository{
    fun find(userId: UserId): User?
    fun save(user: User)
}

Con su implementación correspondiente. (Los detalles los copié de la implementación SimpleJpaRepository)

package com.example.users.user.infrastructure.persistence

import com.example.users.user.domain.User
import com.example.users.user.domain.UserRepository
import com.example.users.user.valueobjects.UserId
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager

@Repository
class DatabaseUserRepository @Autowired constructor(val entityManager: EntityManager): UserRepository{

    override fun find(userId: UserId): User? {
        return entityManager.find(User::class.java, userId)
    }

    override fun save(user: User) {
        if(user.isNew) {
            entityManager.persist(user)
        } else {
            entityManager.merge(user)
        }
    }
}

Pues bien… Para que la cosa funcione, el entity manager debe saber las características de la entidad a nivel de mapeo. Además necesita saber si es una inserción o una modificación… Trabajando con identificadores creados de forma programática, debemos hacer algunas cosillas, como definir el comportamiento de isNew()

Este es el detalle de todo lo que necesitamos en la entidad a nivel de anotaciones.

Annotation Uso
@Entity Nos indica que es una entidad
@Table La tabla que usaremos para persistirlo
@EmbeddedId Esta es la manera que un identificador primario se debe declarar cuando trabajamos con value objects.
@Column Nos ayuda a definir la columna donde va a ir si no seguimos con el estándar camelCase -> snake_case
@Transient Indica que es una propiedad que no se va a persistir
@PostLoad Función que se ejecutará después de construir el objeto
@Prepersist Función que se ejecutará antes de persistir el objeto

User

package com.example.users.user.domain

import com.example.users.user.valueobjects.UserId
import com.example.users.user.valueobjects.UserName
import org.springframework.data.domain.Persistable
import javax.persistence.*

@Entity
@Table(name = "users")
class User private constructor(
    @EmbeddedId
    val userId: UserId,
    @Column(name = "username")
    val userName: UserName
): Persistable<UserId> {
    @Transient
    private var isNew: Boolean = true
    
    @JvmStatic
    companion object {
        fun build(userId: UserId, userName: UserName): User {
            return User(userId, userName)
        }
    }

    override fun isNew(): Boolean {
        return isNew
    }

    @PostLoad
    @PrePersist
    fun trackNotNew() {
        isNew = false
    }

    override fun getId(): UserId {
        return userId
    }
}

Hemos implementado Persistable para que Spring sepa manejarlo, pero nuestra entidad empieza a estar saturada de cosas que no nos interesan, con lo que creamos un MappedSuperClass AggregateRoot en el shared y extenderemos nuestras entidades de allí.

AggregateRoot

package com.example.shared.domain

import org.springframework.data.domain.Persistable
import javax.persistence.MappedSuperclass
import javax.persistence.PostLoad
import javax.persistence.PrePersist
import javax.persistence.Transient

@MappedSuperclass
abstract class AggregateRoot<ID>: Persistable<ID> {
    @Transient
    private var isNew: Boolean = true

    override fun isNew(): Boolean {
        return isNew
    }

    @PostLoad
    @PrePersist
    fun trackNotNew() {
        isNew = false
    }
}

User (limpio… o casi…)

package com.example.users.user.domain

import com.example.shared.domain.AggregateRoot
import com.example.users.user.valueobjects.UserId
import com.example.users.user.valueobjects.UserName
import javax.persistence.*

@Entity
@Table(name = "users")
class User private constructor(
    @EmbeddedId
    val userId: UserId,
    @Column(name = "username")
    val userName: UserName
): AggregateRoot<UserId>() {
    
    @JvmStatic
    companion object {
        fun build(userId: UserId, userName: UserName): User {
            return User(userId, userName)
        }
    }

    override fun getId(): UserId {
        return userId
    }
}

Ojo!, que nos dejamos cómo tratar con los value objects!!!

Pues para poder mapear los value objects contra la base de datos y vicevesa hay que hacer lo mismo que hacemos con Doctrine. Usar un conversor.

Conversor de Value object para propiedades normales

Para poder convertir el Username de objeto a primitivo de BBDD y viceversa, sólo debemos declarar un Converter, y Spring hará e resto.

package com.example.users.user.infrastructure.persistence.customtypes

import com.example.users.user.valueobjects.UserName
import javax.persistence.AttributeConverter
import javax.persistence.Converter

@Converter(autoApply = true)
class UserNameConverter: AttributeConverter<UserName, String?> {
    override fun convertToDatabaseColumn(attribute: UserName?): String {
        if (null == attribute) {
            return ""
        }

        return attribute.value
    }

    override fun convertToEntityAttribute(dbData: String?): UserName {
        if (null == dbData) {
            return UserName.build("")
        }

        return UserName.build(dbData)
    }
}

¿Y para los EmbeddedId, qué hacemos?

Declararlo @Emdeddable e implementar Serializable en el Value Object será suficiente. A tener en cuenta. En este caso, el campo que manda el nombre de la columna está en el value object, no en la propiedad de la entidad.

package com.example.users.user.valueobjects

import java.io.Serializable
import java.util.*
import javax.persistence.Embeddable

@Embeddable
class UserId private constructor(
    val id: UUID
): Serializable {

    @JvmStatic
    companion object {
        fun build(value: String): UserId = UserId(UUID.fromString(value))
    }
}

Una vez lo tenemos todo… con sus tests, por supuesto… vamos a hacer que el caso de uso sea llamado por el endpoint.

package com.example.users.user.ui.signupuser

import com.example.users.user.application.commands.SignUpUserCommand
import com.example.users.user.application.commands.SignUpUserHandler
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType

@RestController
class SignUpUser @Autowired constructor(private val signUpUser: SignUpUserHandler){
    @PostMapping("/user/signup")
    fun execute(@RequestParam("username") username: String): ResponseEntity<JvmType.Object>
    {
        signUpUser.execute(SignUpUserCommand(username))
        return ResponseEntity(HttpStatus.CREATED)
    }
}


Detectar new en entidad

https://thorben-janssen.com/spring-data-jpa-state-detection/

https://docs.oracle.com/javaee/7/api/javax/persistence/MappedSuperclass.html

Noarg plugin para Kotlin

https://programmerclick.com/article/9576253214/

https://kotlinlang.org/docs/no-arg-plugin.html

Jpa y JQL

https://thorben-janssen.com/jpql/


comments powered by Disqus