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
@Entitymarca la clase como una tabla de JPA. Hibernate la mapea a filas.@Table(name = "payments")fija el nombre de la tabla.@Idseñala la llave primaria.@Column(nullable = false, length = 3)describe la columna: si admite nulos, su largo, etc.JpaRepository<PaymentJpaEntity, String>te regalasave,findAll,findByIdy más, sin escribirlos. El segundo tipo,String, es el tipo del id.@Componentle 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
@RestControllermarca la clase como un controlador web que devuelve datos (JSON), no vistas.@RequestMapping("/payments")fija la ruta base del controlador.@PostMappingy@GetMappingatan un método aPOSTyGETsobre esa ruta.@RequestBodyconvierte el JSON del cuerpo en un objeto Kotlin.@Validdispara 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/ChargeResponseacá, enprovider-sim: el contrato de la pasarela. Es de ella.ChargeApiRequest/ChargeApiResponseen el adaptador Feign depayments(Parte 4): cómo nuestro cliente ve esa misma API externa.ChargeResulten 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:
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:
application.yml:
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
urlsale de una propiedad:"\${providers.a.url}"leeproviders.a.urldelapplication.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.ProviderAAdapterlleva@Componenty implementaPaymentGateway: 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:
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¶
En la Sesión 4 viene el momento que vale toda la charla: cambiar de proveedor sin tocar el negocio.