Ensamblando Todo: El Servicio de Valuación de Vehículos
Sigue un VIN desde la solicitud HTTP hasta la fila en la base de datos y de vuelta, y observa la inyección por constructor y las funciones suspend trabajar en cada tramo.
Las tres lecciones anteriores miraron cada una una sola preocupación de forma aislada — capas, DI, corrutinas. Esta lección las junta y recorre una sola solicitud a través de toda la pila, porque los conceptos realmente encajan solo cuando se ven cooperando en lugar de descritos por separado. La solicitud: GET /vehicles/1HGCM82633A004352/valuation. Para cuando regresa una respuesta JSON, se ha ejecutado código en el controller, el service, tres clientes de proveedores, una revisión de caché en Redis y un repository de Postgres — y cada una de esas clases solo conoce a sus vecinos inmediatos.
Comienza en VehicleController, que Spring construyó una vez al arrancar con una única instancia de VehicleValuationService inyectada a través de su constructor — sin búsquedas, sin llamadas a una fábrica dentro del handler, solo un campo que ya estaba poblado antes de que llegara la primera solicitud. El método del handler extrae vin de la ruta, llama a valuationService.valuate(vin), y no hace nada más: sin lógica de negocio, sin acceso directo a base de datos o caché, solo una llamada suspend y un mapeo del resultado a un DTO ValuationResponse. Si este método empieza a acumular sentencias if sobre reglas de precios, esa es la señal de que la lógica se filtró hacia una capa que no debería contenerla.
Dentro de VehicleValuationService.valuate, cuatro colaboradores hacen su parte, y el propio service se construyó con los cuatro inyectados a través de su propio constructor: el repository, y los tres clientes de proveedores. Primero revisa Redis en busca de una valuación en caché indexada por VIN — un acierto de caché corta todo lo que sigue y retorna de inmediato, lo cual importa porque la alternativa son tres llamadas HTTP salientes. Ante un fallo de caché, llama a BlackBookClient.quote(vin), CarfaxClient.quote(vin) y VisClient.quote(vin) — cada uno un suspend fun, cada uno genuinamente no bloqueante, y cada uno implementando la misma interfaz ValuationProviderClient para que el service pueda tratarlos de manera uniforme en lugar de tratar cada SDK de proveedor como un caso especial.
Con las tres cotizaciones en mano, el service le pide a VehicleRepository las filas activas de pricing_rule relevantes para el rango de marca/modelo de ese VIN, y luego alimenta tanto las cotizaciones como las reglas al motor de reglas, que es lo que realmente produce una decisión — aprobado con cierto monto de oferta, o bloqueado por una salvaguarda, o marcado como no-compra. El service entonces escribe un registro de esa decisión en la colección valuation_event de MongoDB (el registro de auditoría que el escenario de este curso sigue mencionando) y refresca la entrada de caché en Redis con un TTL, de modo que la siguiente solicitud para el mismo VIN dentro de esa ventana se salta por completo las llamadas a los proveedores. Nota cuántos almacenes de datos distintos toca un solo método — Redis, tres APIs HTTP externas, Postgres, MongoDB — y aun así VehicleController por encima no sabe nada de eso; solo sabe que llamó a algo suspend y recibió un Valuation de vuelta.
La frontera entre solicitud y respuesta merece su propia mirada, porque es fácil confundirla accidentalmente con el modelo de dominio que fluye por el service. VehicleController no recibe cuerpo de solicitud para este GET en particular (el VIN viene de la ruta), pero sí produce un DTO ValuationResponse a la salida — una data class plana, con forma de cable, con exactamente los campos que un cliente necesita (vin, offerCents, decision) y nada sobre cómo se llegó a esa decisión. Internamente, el service trabaja con un tipo de dominio Valuation más rico que podría cargar las cotizaciones individuales de cada proveedor, qué regla se disparó, y metadatos de tiempo para observabilidad — información genuinamente útil para depurar y para las métricas de Datadog que emite el escenario de este curso, pero no algo que quieras comprometer como contrato de API pública. El mapeo de Valuation a ValuationResponse, típicamente una pequeña función de extensión toResponse(), es el único lugar donde esa frontera se cruza, de forma deliberada y explícita.
Cada una de estas clases fue independientemente comprobable gracias exactamente a las dos propiedades de lecciones anteriores: la inyección por constructor hace que las dependencias de cada clase sean explícitas e intercambiables por un doble de prueba, y las cadenas suspend consistentes permiten que las pruebas usen runTest de kotlinx-coroutines-test sin configurar hilos o streams reactivos a mano. Una prueba para VehicleValuationService.valuate puede construir el service directamente con clientes de proveedores simulados y un repository simulado, alimentar cotizaciones prefabricadas, y verificar la decisión resultante — sin contexto de Spring, sin Redis real, sin Postgres real, sin llamadas HTTP reales, y corre en milisegundos. La pila completa, cableada de verdad con Testcontainers haciendo de Postgres/Redis/MongoDB, se ejercita por separado en pruebas de integración — una distinción que un módulo posterior cubre a fondo.
Da un paso atrás y la forma de todo esto es: un punto de entrada HTTP, un service orquestador que compone varios colaboradores basados en suspend (una caché, tres clientes externos, un repository de base de datos, un destino de auditoría), y un DTO de frontera a la salida. Nada de esto es exótico — es el mismo patrón de tres capas, inyectado por constructor, suspend de principio a fin de las últimas tres lecciones, solo que visto operando sobre una solicitud real en lugar de en aislamiento. Cada módulo después de este va a acercarse a una pieza exacta de este mismo diagrama.
@RestControllerclass VehicleController(private val valuationService: VehicleValuationService,) {@GetMapping("/vehicles/{vin}/valuation")suspend fun getValuation(@PathVariable vin: String): ValuationResponse {return valuationService.valuate(vin).toResponse()}}@Serviceclass VehicleValuationService(private val cache: ValuationCache, // wraps Redisprivate val repository: VehicleRepository, // Postgres, pricing_ruleprivate val auditLog: ValuationEventLog, // MongoDB, valuation_eventprivate val blackBook: BlackBookClient,private val carfax: CarfaxClient,private val vis: VisClient,) {suspend fun valuate(vin: String): Valuation {cache.get(vin)?.let { return it }val quotes = listOf(blackBook.quote(vin), carfax.quote(vin), vis.quote(vin))val rules = repository.findActiveRulesFor(vin)val decision = RulesEngine.evaluate(quotes, rules)auditLog.record(vin, decision)cache.put(vin, decision, ttl = Duration.ofMinutes(15))return decision}}
The full wiring: three constructor-injected classes cooperating on one request. Requires a live Spring context with real Redis/Postgres/HTTP clients, so it does not run in-browser.
data class ValuationResponse(val vin: String,val offerCents: Long,val decision: String,)data class Valuation(val vin: String,val offerCents: Long,val decision: Decision,val providerQuotes: List<Quote>, // internal only -- never serialized to clientsval firedRuleId: Long?, // internal only -- useful for debugging, not for the API)fun Valuation.toResponse(): ValuationResponse = ValuationResponse(vin = vin,offerCents = offerCents,decision = decision.name,)
The boundary DTO and its mapping from the richer internal domain type -- the one deliberate place the two shapes meet. Requires the surrounding Spring types (ResponseEntity, etc.) to compile in context, so it does not run in-browser.
class VehicleValuationServiceTest {private val cache = mockk<ValuationCache>()private val repository = mockk<VehicleRepository>()private val auditLog = mockk<ValuationEventLog>(relaxed = true)private val blackBook = mockk<BlackBookClient>()private val carfax = mockk<CarfaxClient>()private val vis = mockk<VisClient>()private val service = VehicleValuationService(cache, repository, auditLog, blackBook, carfax, vis,)@Testfun `a cache hit skips every provider call`() = runTest {val cached = Valuation("VIN123", 1500_00, Decision.APPROVED, emptyList(), null)coEvery { cache.get("VIN123") } returns cachedval result = service.valuate("VIN123")assertEquals(cached, result)coVerify(exactly = 0) { blackBook.quote(any()) }}}
A unit test exercising the service in isolation, made possible entirely by constructor injection and suspend functions -- no Spring context is started. Uses MockK and kotlinx-coroutines-test, so it does not run in-browser.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. In VehicleValuationService.valuate, why does the method write to auditLog (MongoDB) and refresh cache (Redis) itself, rather than having VehicleController do those two things after receiving the decision back?