Aplicándolo: Escribiendo a Mongo Fuera del Hilo de la Petición
VehicleValuationService recibe su propio CoroutineScope de aplicación respaldado por SupervisorJob, inyectado como bean — nunca GlobalScope — para que la escritura a Mongo sobreviva la petición y los fallos queden contenidos.
Las lecciones 15 y 16 te dieron dos reglas: usa `launch`, no `async`, para la escritura de `valuation_event`, y constrúyela sobre un `SupervisorJob` con un `CoroutineExceptionHandler` para que una escritura fallida no arrastre a las demás. Ahora hay que conectar ambas en la forma real de `VehicleValuationService`. El requisito, dicho con precisión: `valuate(vin)` calcula un `ValuationResult` — llamando a Black Book, Carfax y S&P VIS, y luego ejecutando el motor de reglas — y por separado persiste un `ValuationEvent` en MongoDB para el rastro de auditoría, sin que la respuesta HTTP espere a esa segunda parte.
El scope tiene que ser un bean de Spring con el scope singleton por defecto: construido una sola vez, cuando arranca el contexto de la aplicación, y vivo exactamente durante todo el tiempo que la aplicación esté viva — nunca por petición, nunca por llamada. Esa única decisión es lo que arregla la trampa de la lección 15. Como el scope no se crea dentro de la propia corrutina de la petición, cancelar la petición (o que la respuesta se complete) no tiene ningún efecto sobre él; la escritura lanzada con `launch` es hija de un `Job` que pertenece a la aplicación, no a la petición.
Compilaría perfectamente saltarse el bean y llamar directamente a `GlobalScope.launch { ... }` dentro de `valuate()` — `GlobalScope` ya está ahí, en el propio paquete `kotlinx.coroutines`, sin necesidad de inyección. Resiste la tentación; es un anti-patrón por tres razones concretas. Primero, es imposible de testear: `GlobalScope` es un singleton fijo de nivel superior integrado en la propia librería de corrutinas, así que una prueba unitaria no tiene forma de sustituir otro dispatcher ni de interceptar lo que hace. Segundo, es imposible de detener: `GlobalScope` vive durante todo el proceso, sin ningún gancho para cancelar trabajo cuando el contexto de la aplicación de Spring se apaga, así que las escrituras en curso durante un despliegue rodante simplemente compiten contra el cierre de la JVM. Tercero, y más fundamental, no tiene un ciclo de vida estructurado — no pertenece a nada, así que nadie es dueño de la decisión de cuándo deben detenerse sus hijas, que es justo la idea hacia la que ha apuntado todo este módulo. Un bean inyectado por constructor te devuelve las tres cosas: la inyección de dependencias lo hace sustituible en pruebas, el `@PreDestroy` de Spring te da un lugar donde cancelarlo al apagar la aplicación, y queda visiblemente en manos del componente que lo usa.
En la práctica, el bean reúne las tres lecciones en un solo objeto: `CoroutineScope(SupervisorJob() + Dispatchers.IO + handler)`. `SupervisorJob()` le da a cada escritura lanzada con `launch` su propio dominio de fallo. `Dispatchers.IO` es el dispatcher pensado para trabajo de entrada/salida de tipo bloqueante — una elección razonable para una llamada al driver de MongoDB, distinta de `Dispatchers.Default`, que se usa para trabajo intensivo en CPU. Y `handler` es un `CoroutineExceptionHandler` que registra el fallo e incrementa un contador de Datadog, de modo que un documento rechazado se convierte en una línea en tus logs y un pico en un dashboard, en vez de un hueco silencioso en el rastro de auditoría.
`VehicleValuationService` recibe ese scope como un parámetro más del constructor, exactamente igual que recibe `VehicleRepository` o los tres clientes de proveedores — eso es lo que lo hace sustituible en una prueba. Dentro de `valuate()`, las tres llamadas a proveedores siguen siendo `async`, porque sus resultados alimentan al motor de reglas y la función realmente necesita esperar a las tres. La escritura de auditoría es la excepción: después de que el motor de reglas devuelve una decisión y la función ya tiene todo lo necesario para construir un `ValuationEvent`, llama a `valuationEventScope.launch { valuationEventRepository.save(event) }` y devuelve el `ValuationResult` de inmediato. Sin `.join()`, sin `.await()` — la función retorna en cuanto retorna la propia llamada a `launch`, que ocurre en cuanto la corrutina queda programada, no cuando termina.
Rastrea cada propiedad de seguridad hasta su origen. La escritura sobrevive a que la petición termine porque `valuationEventScope` es un bean, no un scope anidado dentro de la propia corrutina de la petición — eso es la lección 15. Una escritura fallida para un VIN no puede cancelar una escritura en curso para otro VIN que comparte el mismo scope, porque el scope está construido sobre un `SupervisorJob` — eso es la lección 16. Y una escritura fallida no se esfuma sin dejar rastro, porque el `CoroutineExceptionHandler` de ese mismo scope la registra y la contabiliza — también la lección 16. Ninguna de las tres es opcional; quita cualquiera de ellas y vuelves a una versión de esta funcionalidad que es rápida pero poco fiable, de una forma que solo se nota bajo condiciones reales de producción.
Este diseño se paga solo también en las pruebas: como el scope llega por el constructor, una prueba puede darle a `VehicleValuationService` un scope construido sobre `Dispatchers.Unconfined` (o un dispatcher de prueba que corre de forma inmediata), y comprobar directamente que se llamó a `valuationEventRepository.save(...)` — sin `Thread.sleep`, sin sondeos, sin inestabilidad. Y en producción, conectar `@PreDestroy` en la clase de configuración para llamar a `scope.cancel()` le da al contexto de la aplicación un lugar donde apagar esto limpiamente durante un despliegue rodante — algo que `GlobalScope` jamás podría ofrecer. Con el mecanismo ya en su sitio, la pregunta que queda es qué decisión toma realmente el motor de reglas con todos estos datos — que es hacia donde gira el curso a continuación.
@Configurationclass CoroutineScopeConfig(private val meterRegistry: MeterRegistry) {private val logger = LoggerFactory.getLogger(CoroutineScopeConfig::class.java)private val handler = CoroutineExceptionHandler { _, throwable ->logger.error("Failed to persist valuation_event", throwable)meterRegistry.counter("valuation.audit_write.failed").increment()}private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + handler)@Beanfun valuationEventScope(): CoroutineScope = scope@PreDestroyfun shutdown() {scope.cancel("Application context is shutting down")}}
The application-scoped CoroutineScope, wired as a Spring bean with a SupervisorJob and a handler that logs and counts failures instead of swallowing them.
@Serviceclass VehicleValuationService(private val blackBookClient: BlackBookClient,private val carfaxClient: CarfaxClient,private val spVisClient: SpVisClient,private val rulesEngine: ValuationRulesEngine,private val valuationEventRepository: ValuationEventRepository,private val valuationEventScope: CoroutineScope,) {suspend fun valuate(vin: String): ValuationResult = coroutineScope {val blackBook = async { blackBookClient.getValuation(vin) }val carfax = async { carfaxClient.getHistory(vin) }val spVis = async { spVisClient.getValuation(vin) }val decision = rulesEngine.evaluate(vin = vin,blackBook = blackBook.await(),carfax = carfax.await(),spVis = spVis.await(),)val event = ValuationEvent.from(vin, decision)valuationEventScope.launch {valuationEventRepository.save(event)}decision.toValuationResult()}}
VehicleValuationService takes the scope through its constructor like any other collaborator, awaits the providers it needs a result from, and launches the audit write it doesn't.
@Testfun `valuate saves a valuation event without the caller waiting`() = runTest {val eagerScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)val repository = mockk<ValuationEventRepository>(relaxed = true)val service = VehicleValuationService(blackBookClient, carfaxClient, spVisClient,rulesEngine, repository, eagerScope,)service.valuate(vin = "1HGCM82633A004352")coVerify(exactly = 1) { repository.save(any()) }}
Why constructor injection matters, in a test: swap in a scope with an eager dispatcher and the audit write becomes directly assertable, no sleeping the test thread.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. Why does VehicleValuationService inject its CoroutineScope through the constructor as a Spring bean instead of just calling GlobalScope.launch { ... } directly inside valuate()?