Sesión 2 — Puertos, servicio y test sin Spring¶
Rama: sesion-2
Lo que vas a lograr: el puerto de entrada (procesar un pago), los puertos de salida (la pasarela y el repositorio), el servicio que los conecta, y un test que prueba la regla sin Spring, sin base de datos y sin red.
Parte 1 — El puerto de entrada (driving) (10 min)¶
Lo que la aplicación ofrece. Una interfaz.
application/port/in/ProcessPaymentUseCase.kt:
package com.baqjug.payments.application.port.`in`
import com.baqjug.payments.domain.model.Payment
interface ProcessPaymentUseCase {
fun process(payment: Payment): Payment
fun findAll(): List<Payment>
}
Kotlin al paso: interfaces
Igual que en Java, una interface define un contrato. Acá decimos qué ofrece la aplicación: procesar un pago y listar los pagos. Sin decir cómo.
Parte 2 — Los puertos de salida (driven) (10 min)¶
Lo que la aplicación necesita. Dos interfaces.
application/port/out/PaymentGateway.kt:
package com.baqjug.payments.application.port.out
import com.baqjug.payments.domain.model.Payment
data class ChargeResult(val approved: Boolean, val reference: String?)
interface PaymentGateway {
fun charge(payment: Payment): ChargeResult
}
application/port/out/PaymentRepository.kt:
package com.baqjug.payments.application.port.out
import com.baqjug.payments.domain.model.Payment
interface PaymentRepository {
fun save(payment: Payment)
fun findAll(): List<Payment>
}
Kotlin al paso: tipos que pueden ser nulos
En ChargeResult, reference: String? lleva un ?: significa que puede ser nulo (si el pago se rechaza, no hay referencia). Kotlin te obliga a manejar ese caso, y por eso evita muchos NullPointerException. Un String sin ? nunca puede ser nulo.
El puerto clave de la charla
PaymentGateway es solo una interfaz. El dominio no sabe que detrás va a haber un Feign llamando a una pasarela real. Solo sabe que necesita cobrar.
Parte 3 — El servicio que conecta los puertos (15 min)¶
application/service/ProcessPaymentService.kt:
package com.baqjug.payments.application.service
import com.baqjug.payments.application.port.`in`.ProcessPaymentUseCase
import com.baqjug.payments.application.port.out.PaymentGateway
import com.baqjug.payments.application.port.out.PaymentRepository
import com.baqjug.payments.domain.model.Payment
import com.baqjug.payments.domain.model.PaymentStatus
import org.springframework.stereotype.Service
@Service
class ProcessPaymentService(
private val gateway: PaymentGateway,
private val repository: PaymentRepository
) : ProcessPaymentUseCase {
override fun process(payment: Payment): Payment {
val result = gateway.charge(payment)
payment.status = if (result.approved) PaymentStatus.APPROVED else PaymentStatus.REJECTED
repository.save(payment)
return payment
}
override fun findAll(): List<Payment> = repository.findAll()
}
Kotlin al paso: constructor e if como expresión
Lo que está entre paréntesis después del nombre de la clase es el constructor primario. Spring inyecta ahí los dos puertos. private val los declara como propiedades de una vez. Y if (...) A else B en Kotlin devuelve un valor, no es solo un bloque: por eso podemos asignar el resultado directo a status.
Sobre el @Service
@Service es de Spring y el servicio vive en aplicación. ¿No contamina? Es una anotación de composición, no de comportamiento: no cambia la lógica. El costo de evitarla casi nunca compensa. Si querés el aislamiento total, existe el truco de una anotación propia @UseCase escaneada aparte, pero para esta guía dejamos @Service, que es lo más directo.
Parte 4 — El test sin Spring (15 min)¶
Primero, arreglá el test que generó el asistente
Spring Initializr creó un PaymentsApplicationTests.kt con @SpringBootTest. Esa anotación levanta todo el contexto de Spring, y ahora mismo falla: todavía no hay base de datos configurada ni implementaciones de PaymentRepository y PaymentGateway (son interfaces sin adaptador). Cambialo por un test simple por ahora:
package com.baqjug.payments
import kotlin.test.Test
import kotlin.test.assertTrue
class PaymentsApplicationTests {
@Test
fun contextLoads() { assertTrue(true) }
}
Restauramos @SpringBootTest en la Sesión 3, cuando ya existan los adaptadores de infraestructura. La idea de fondo: en dominio y aplicación los tests son unitarios puros, no necesitan Spring ni base de datos, solo fakes inyectados a mano.
Como el servicio recibe interfaces, lo probamos con dobles escritos a mano. Ni un @SpringBootTest.
src/test/kotlin/.../ProcessPaymentServiceTest.kt:
package com.baqjug.payments.application.service
import com.baqjug.payments.application.port.out.ChargeResult
import com.baqjug.payments.application.port.out.PaymentGateway
import com.baqjug.payments.application.port.out.PaymentRepository
import com.baqjug.payments.domain.model.Money
import com.baqjug.payments.domain.model.Payment
import com.baqjug.payments.domain.model.PaymentStatus
import java.math.BigDecimal
import kotlin.test.Test
import kotlin.test.assertEquals
class FakeGateway(private val approved: Boolean) : PaymentGateway {
override fun charge(payment: Payment) = ChargeResult(approved, "FAKE-REF")
}
class InMemoryPayments : PaymentRepository {
private val store = mutableListOf<Payment>()
override fun save(payment: Payment) { store.add(payment) }
override fun findAll() = store.toList()
}
class ProcessPaymentServiceTest {
@Test
fun `aprueba cuando la pasarela aprueba`() {
val service = ProcessPaymentService(FakeGateway(approved = true), InMemoryPayments())
val result = service.process(Payment(amount = Money(BigDecimal("5000"))))
assertEquals(PaymentStatus.APPROVED, result.status)
}
@Test
fun `rechaza cuando la pasarela rechaza`() {
val service = ProcessPaymentService(FakeGateway(approved = false), InMemoryPayments())
val result = service.process(Payment(amount = Money(BigDecimal("5000"))))
assertEquals(PaymentStatus.REJECTED, result.status)
}
}
Corré:
Esto es testeabilidad de verdad
Probamos la regla completa (aprueba o rechaza según la pasarela) sin levantar Spring, sin base de datos, sin red. Corre en milisegundos. La pasarela falsa la escribimos en cuatro líneas porque el servicio depende de una interfaz, no de un proveedor concreto.
Cierre de la sesión¶
En la Sesión 3 entran los adaptadores: REST, JPA y la pasarela por Feign.