Saltar a contenido

Sesión 3 — Adaptadores REST, JPA y la pasarela por Feign

Rama: sesion-3 Lo que vas a lograr: payments corriendo de verdad. Persiste en PostgreSQL, expone una API REST y cobra a través de una pasarela por Feign. Para tener a quién llamar, armamos una pasarela de mentira (provider-sim).


Parte 1 — El adaptador de salida: persistencia con JPA (15 min)

Cuatro piezas en infrastructure/adapter/out/persistence.

PaymentJpaEntity.kt:

package com.baqjug.payments.infrastructure.adapter.out.persistence

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.math.BigDecimal

@Entity
@Table(name = "payments")
class PaymentJpaEntity(
    @Id val id: String,
    @Column(nullable = false) val amount: BigDecimal,
    @Column(nullable = false, length = 3) val currency: String,
    @Column(nullable = false) val status: String
)

SpringDataPaymentRepository.kt:

package com.baqjug.payments.infrastructure.adapter.out.persistence

import org.springframework.data.jpa.repository.JpaRepository

interface SpringDataPaymentRepository : JpaRepository<PaymentJpaEntity, String>

PaymentPersistenceMapper.kt:

package com.baqjug.payments.infrastructure.adapter.out.persistence

import com.baqjug.payments.domain.model.*
import org.springframework.stereotype.Component
import java.math.BigDecimal

@Component
class PaymentPersistenceMapper {
    fun toEntity(p: Payment) =
        PaymentJpaEntity(p.id.value, p.amount.value, p.amount.currency, p.status.name)

    fun toDomain(e: PaymentJpaEntity) = Payment(
        id = PaymentId(e.id),
        amount = Money(e.amount, e.currency),
        status = PaymentStatus.valueOf(e.status)
    )
}

PaymentRepositoryAdapter.kt:

package com.baqjug.payments.infrastructure.adapter.out.persistence

import com.baqjug.payments.application.port.out.PaymentRepository
import com.baqjug.payments.domain.model.Payment
import org.springframework.stereotype.Component

@Component
class PaymentRepositoryAdapter(
    private val jpa: SpringDataPaymentRepository,
    private val mapper: PaymentPersistenceMapper
) : PaymentRepository {
    override fun save(payment: Payment) { jpa.save(mapper.toEntity(payment)) }
    override fun findAll(): List<Payment> = jpa.findAll().map { mapper.toDomain(it) }
}

Kotlin al paso: map { it }

jpa.findAll().map { mapper.toDomain(it) } recorre la lista y transforma cada elemento. it es el nombre implícito del parámetro cuando la lambda recibe uno solo. Es el equivalente conciso del stream().map(...) de Java.

Spring al paso: las anotaciones de persistencia

  • @Entity marca la clase como una tabla de JPA. Hibernate la mapea a filas.
  • @Table(name = "payments") fija el nombre de la tabla.
  • @Id señala la llave primaria.
  • @Column(nullable = false, length = 3) describe la columna: si admite nulos, su largo, etc.
  • JpaRepository<PaymentJpaEntity, String> te regala save, findAll, findById y más, sin escribirlos. El segundo tipo, String, es el tipo del id.
  • @Component le dice a Spring que administre esa clase como un bean, para poder inyectarla. El mapper y el adaptador lo llevan porque viven en infraestructura.

El mapper vive acá, no en el dominio

El dominio no conoce PaymentJpaEntity. La traducción es trabajo de infraestructura.


Parte 2 — El adaptador de entrada: REST (15 min)

En infrastructure/adapter/in/web.

dto/PaymentDtos.kt:

package com.baqjug.payments.infrastructure.adapter.`in`.web.dto

import com.baqjug.payments.domain.model.Money
import com.baqjug.payments.domain.model.Payment
import jakarta.validation.constraints.Positive
import java.math.BigDecimal

data class PaymentRequest(@field:Positive val amount: BigDecimal) {
    fun toDomain() = Payment(amount = Money(amount))
}

data class PaymentResponse(val id: String, val amount: BigDecimal, val currency: String, val status: String) {
    companion object {
        fun from(p: Payment) =
            PaymentResponse(p.id.value, p.amount.value, p.amount.currency, p.status.name)
    }
}

Kotlin al paso: companion object

El companion object guarda miembros que se llaman sin instancia, como un static de Java. Acá PaymentResponse.from(p) arma la respuesta desde el dominio. Y @field:Positive aplica la validación al campo generado por Kotlin.

Spring al paso: las anotaciones REST

  • @RestController marca la clase como un controlador web que devuelve datos (JSON), no vistas.
  • @RequestMapping("/payments") fija la ruta base del controlador.
  • @PostMapping y @GetMapping atan un método a POST y GET sobre esa ruta.
  • @RequestBody convierte el JSON del cuerpo en un objeto Kotlin.
  • @Valid dispara las validaciones (como @field:Positive) antes de entrar al método.
  • El controlador recibe ProcessPaymentUseCase, el puerto de entrada. No conoce el servicio concreto, solo el contrato.

PaymentController.kt:

package com.baqjug.payments.infrastructure.adapter.`in`.web

import com.baqjug.payments.application.port.`in`.ProcessPaymentUseCase
import com.baqjug.payments.infrastructure.adapter.`in`.web.dto.PaymentRequest
import com.baqjug.payments.infrastructure.adapter.`in`.web.dto.PaymentResponse
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/payments")
class PaymentController(
    private val useCase: ProcessPaymentUseCase
) {
    @PostMapping
    fun create(@Valid @RequestBody request: PaymentRequest): PaymentResponse =
        PaymentResponse.from(useCase.process(request.toDomain()))

    @GetMapping
    fun list(): List<PaymentResponse> =
        useCase.findAll().map { PaymentResponse.from(it) }
}

Parte 3 — Una pasarela de mentira: provider-sim (10 min)

Antes de seguir, ubiquemos quién llama a quién, porque hay dos endpoints y es fácil confundirlos:

cliente  →  POST /payments   (NUESTRA API, puerto 8080, servicio payments)
        payments cobra por Feign
            POST /charge      (LA PASARELA, puerto 8081, provider-sim)

No confundas los dos endpoints

POST /payments es nuestra API: la que usa el cliente para pedir un pago. POST /charge es la API de la pasarela: la que nuestro servicio llama por detrás para cobrar. Son servicios distintos, en puertos distintos. El provider-sim simula a la pasarela externa para que el Feign tenga a quién llamar sin depender de un proveedor real.

Generá otro proyecto en start.spring.io: artifact provider-sim, package com.baqjug.providersim, solo Spring Web. Descomprimilo al lado de payments.

ChargeController.kt:

package com.baqjug.providersim

import org.springframework.web.bind.annotation.*
import java.math.BigDecimal
import java.util.UUID

data class ChargeRequest(val amount: BigDecimal)
data class ChargeResponse(val approved: Boolean, val reference: String)

@RestController
class ChargeController {
    @PostMapping("/charge")
    fun charge(@RequestBody req: ChargeRequest): ChargeResponse =
        ChargeResponse(approved = req.amount <= BigDecimal("1000000"), reference = UUID.randomUUID().toString())
}

Por qué hay varios ChargeRequest parecidos

Vas a ver tres tipos que se parecen, y es a propósito, no un error:

  • ChargeRequest / ChargeResponse acá, en provider-sim: el contrato de la pasarela. Es de ella.
  • ChargeApiRequest / ChargeApiResponse en el adaptador Feign de payments (Parte 4): cómo nuestro cliente ve esa misma API externa.
  • ChargeResult en el puerto del dominio (Sesión 2): lo que el negocio necesita saber, sin detalles de HTTP.

Cada lado tiene su propio tipo. El adaptador traduce entre el del proveedor y el del dominio. Así, si la pasarela cambia su JSON, solo se toca el adaptador.

En su application.yml, ponelo en el puerto 8081:

server:
  port: 8081

Corré provider-sim y probalo:

cd provider-sim && ./gradlew bootRun
curl -X POST localhost:8081/charge -H "Content-Type: application/json" -d '{"amount":5000}'

Parte 4 — El adaptador de la pasarela, por Feign (10 min)

Volvé a payments. En infrastructure/adapter/out/gateway.

ProviderAClient.kt:

package com.baqjug.payments.infrastructure.adapter.out.gateway

import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import java.math.BigDecimal

data class ChargeApiRequest(val amount: BigDecimal)
data class ChargeApiResponse(val approved: Boolean, val reference: String)

@FeignClient(name = "provider-a", url = "\${providers.a.url}")
interface ProviderAClient {
    @PostMapping("/charge")
    fun charge(@RequestBody request: ChargeApiRequest): ChargeApiResponse
}

ProviderAAdapter.kt:

package com.baqjug.payments.infrastructure.adapter.out.gateway

import com.baqjug.payments.application.port.out.ChargeResult
import com.baqjug.payments.application.port.out.PaymentGateway
import com.baqjug.payments.domain.model.Payment
import org.springframework.stereotype.Component

@Component
class ProviderAAdapter(
    private val client: ProviderAClient
) : PaymentGateway {
    override fun charge(payment: Payment): ChargeResult {
        val response = client.charge(ChargeApiRequest(payment.amount.value))
        return ChargeResult(response.approved, response.reference)
    }
}

Habilitá Feign en la clase principal y configurá la URL.

PaymentsApplication.kt:

@SpringBootApplication
@EnableFeignClients
class PaymentsApplication

application.yml:

spring:
  jpa:
    hibernate:
      ddl-auto: update
providers:
  a:
    url: http://localhost:8081

Spring al paso: las anotaciones de Feign

  • @FeignClient(name = "provider-a", url = "...") convierte una interfaz en un cliente HTTP. Vos declarás los métodos; Spring escribe las llamadas.
  • El url sale de una propiedad: "\${providers.a.url}" lee providers.a.url del application.yml.
  • @PostMapping("/charge") dentro del cliente Feign dice a qué ruta del proveedor pega ese método.
  • @EnableFeignClients, en la clase principal, prende el escaneo de estos clientes.
  • ProviderAAdapter lleva @Component y implementa PaymentGateway: es el puente entre el puerto del dominio y el cliente Feign.

Acá está la idea

El puerto PaymentGateway lo definió la aplicación. Este adaptador lo cumple con Feign, hablando con la pasarela por HTTP. Pero el servicio del negocio sigue igual que en la sesión 2: pidió "cobrá este pago" y alguien lo cumplió.


Parte 5 — Correr y probar (5 min)

Con Docker abierto, y provider-sim corriendo en otra terminal:

cd payments && ./gradlew bootRun

Spring levanta PostgreSQL con Docker (gracias a Docker Compose Support) y arranca en el 8080.

curl -X POST localhost:8080/payments -H "Content-Type: application/json" -d '{"amount":5000}'
curl localhost:8080/payments

El pago entra por REST, el servicio cobra por Feign contra provider-sim, y se guarda en Postgres con estado APPROVED.


Cierre de la sesión

git add .
git commit -m "sesion-3: adaptadores REST, JPA y pasarela por Feign"
git branch sesion-3

En la Sesión 4 viene el momento que vale toda la charla: cambiar de proveedor sin tocar el negocio.