Í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)
}
}
Links y recursos
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/
Share this post
Twitter
Facebook
Reddit
LinkedIn
Email