Saltar a contenido

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é:

./gradlew test

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

git add .
git commit -m "sesion-2: puertos, servicio y test sin Spring"
git branch sesion-2

En la Sesión 3 entran los adaptadores: REST, JPA y la pasarela por Feign.