Uniendo todo: una solicitud, trazada
Veinticinco lecciones construyeron cada pieza por separado; esta última sigue un solo VIN a través de todas ellas, de principio a fin, en una sola solicitud.
No hay ninguna API nueva en esta lección. Todo lo que necesitas ya está construido: un controlador, un servicio, tres clientes de proveedores, un motor de reglas, un almacén de reglas respaldado por Postgres, una caché en Redis, un log de auditoría en Mongo, políticas de reintento y disyuntor, un limitador de tasa, y un conjunto de métricas de Datadog. Lo que hace este capítulo final en su lugar es trazar una sola solicitud HTTP — un `POST` para valuar un único VIN — a través de cada una de esas piezas en orden, para que veas cómo cooperan como un sistema y no como veintiséis lecciones aisladas.
Empieza donde empieza cada solicitud: `VehicleController` recibe el VIN, hace el trabajo mínimo de deserialización y validación, y delega en `VehicleValuationService.valuate(vin)` — una `suspend fun`, lo cual importa de inmediato, porque todo lo que sigue en esta llamada implica esperar E/S, y una función suspendida libera el hilo que atiende la solicitud para que haga otro trabajo en lugar de bloquearse esperando una llamada de red o un viaje de ida y vuelta a la base de datos.
Lo primero que hace el servicio es consultar Redis, porque el patrón cache-aside significa que la aplicación, no la caché, es dueña de la lógica de lectura: pedirle a Redis una valuación en caché indexada por VIN, y si hay acierto, devolverla de inmediato sin tocar jamás a un proveedor. En caso de fallo — el camino normal para un VIN por primera vez o uno cuya entrada en caché ya venció — la ejecución sigue hacia los proveedores, y aquí es también donde se consulta el limitador de tasa de la lección anterior antes de intentar cualquier llamada saliente.
Suponiendo que el cubo de tokens tenga capacidad, el servicio llama a las tres implementaciones de `ValuationProviderClient` de forma concurrente sobre OkHttp, cada una envuelta en la política de reintento y disyuntor de Failsafe construida antes en el curso. Una respuesta de Carfax perdida se reintenta en silencio; un Carfax que ha estado fallando de forma consistente tiene su disyuntor abierto, así que el servicio no desperdicia un timeout esperando a un proveedor que ya demostró estar caído, falla rápido y sigue adelante con las cotizaciones que sí llegaron. Si el limitador sí rechazó una llamada, ese evento se registra (muestreado, no una línea por rechazo) y se cuenta.
Las cotizaciones que sí regresan se combinan en una valuación, y el resultado se escribe en Redis — la mitad de escritura del cache-aside — de modo que la siguiente solicitud para el mismo VIN, dentro de la ventana de vigencia, jamás necesite llamar a un proveedor. Las cotizaciones combinadas se entregan entonces al motor de reglas dirigido por configuración, que aplica guardrails, boosts y lógica de no-compra/no-oferta sin un solo `if` codificado a mano regado por el servicio — un cambio de política de precios es un cambio de configuración, no un despliegue.
La decisión del motor de reglas es lo que se devuelve al llamador, pero la solicitud en realidad no ha terminado: se lanza una corrutina, deliberadamente sin esperarla, para escribir un documento `valuation_event` en el log de auditoría de Mongo. Corre de forma fire-and-forget en su propio scope respaldado por un `SupervisorJob`, específicamente para que un fallo al escribir un registro de auditoría — que el llamador no necesita que tenga éxito para recibir una decisión válida ya calculada — no pueda fallar ni siquiera enlentecer la respuesta que el llamador está esperando. Ese scope captura y registra sus propios errores, porque nadie más los está observando.
Envolviendo toda la llamada, desde el momento en que entra a `valuate()` hasta el momento en que retorna, está el timer de Micrometer registrando la latencia de punta a punta, etiquetada por resultado; cada llamada a un proveedor registró su propio contador de éxito o fallo; los gauges del disyuntor reflejan qué proveedor, si acaso, tiene actualmente el disyuntor abierto; y si el limitador rechazó algo en el camino, ese contador también se movió. Nada de esto requirió leer una sola línea de log para saber que la solicitud ocurrió, más o menos cuánto tardó, y si algo río abajo estaba en mal estado mientras corría — y la confianza en que cada pieza de este camino realmente funciona como se describe aquí vino de Testcontainers demostrando que el comportamiento de Postgres y Mongo era real, y de MockK demostrando que el manejo de fallos de los clientes de proveedores se comportaba correctamente sin hacer jamás una llamada real y facturable.
Nada de esto es en realidad sobre vehículos. Cambia Black Book, Carfax y S&P VIS por tres transportistas cotizando un estimado de entrega, o tres burós de suscripción calificando a un solicitante de crédito, o tres fuentes de precios para un motor de comparación, y la forma de la solución apenas cambia: llama a terceros poco confiables de forma segura, cachea lo que es costoso volver a pedir, mantén la lógica de decisión externa y auditable, no hagas esperar al llamador por trabajo que no necesita esperar, e instrumenta cada costura para que el sistema te diga cuándo está enfermo antes de que lo haga un cliente.
Eso es lo que este curso realmente estaba enseñando. El servicio de valuación de vehículos nunca fue el punto — fue el vehículo, sin intención de juego de palabras, para un conjunto de patrones que aparecen en casi cualquier servicio cuyo trabajo es agregar respuestas de fuentes que no controla y devolver una sola decisión que pueda respaldar. Ahora tienes todo eso: la arquitectura, los mecanismos de seguridad, las decisiones de persistencia, el modelo de concurrencia, y la observabilidad para demostrar que funciona. Eso es el curso completo.
@RestController@RequestMapping("/api/vehicles")class VehicleController(private val valuationService: VehicleValuationService) {@PostMapping("/{vin}/valuation")suspend fun getValuation(@PathVariable vin: String): ResponseEntity<ValuationResponse> {val decision = valuationService.valuate(vin)return ResponseEntity.ok(ValuationResponse.from(decision))}}
The controller entry point: thin, suspending, and delegating everything to the service.
class VehicleValuationService(private val cache: ValuationCacheRepository,private val providers: List<ValuationProviderClient>,private val rulesEngine: RulesEngine,private val auditRepository: ValuationEventRepository,private val metrics: ProviderMetrics,private val auditScope: CoroutineScope,) {suspend fun valuate(vin: String): ValuationDecision = metrics.timeValuationRequest {cache.get(vin)?.let { cached -> return@timeValuationRequest cached }val quotes: List<ProviderQuote> = coroutineScope {providers.map { provider ->async {runCatching { provider.fetchValuation(vin) }.onSuccess { metrics.recordProviderCall(provider.name, "success") }.onFailure { metrics.recordProviderCall(provider.name, "failure") }.getOrNull()}}.awaitAll().filterNotNull()}val decision = rulesEngine.evaluate(vin, quotes)cache.put(vin, decision)auditScope.launch {runCatching {auditRepository.save(ValuationEvent.from(vin, quotes, decision))}.onFailure { ex ->logger.warn(ex) { "Failed to write valuation_event audit record for vin=$vin" }}}decision}}
The full traced path in one method: cache-aside, concurrent provider calls with per-call metrics, the rules engine, the cache write-back, and the fire-and-forget audit write — all wrapped in a request-latency timer.
@Configurationclass AuditCoroutineConfig {@Beanfun auditScope(): CoroutineScope =CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("valuation-audit-writer"))}
Why the audit write runs on its own SupervisorJob-backed scope: one failed write must never cancel sibling work or the request that triggered it.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. In the traced request, the write to the Mongo valuation_event audit log happens on a separate coroutine launched with a SupervisorJob, wrapped in its own runCatching. Why not simply let an exception there propagate up and fail the whole request?