Applying It: Writing to Mongo Off the Request Thread
VehicleValuationService gets its own application-scoped, SupervisorJob-backed CoroutineScope as a bean — never GlobalScope — so the Mongo write survives the request and failures stay contained.
Lessons 15 and 16 gave you the two rules: use `launch`, not `async`, for the `valuation_event` write, and build it on a `SupervisorJob` with a `CoroutineExceptionHandler` so one bad write can't take others down. Now wire both into the real shape of `VehicleValuationService`. The requirement, restated precisely: `valuate(vin)` computes a `ValuationResult` — calling Black Book, Carfax, and S&P VIS, then running the rules engine — and separately persists a `ValuationEvent` to MongoDB for the audit trail, without the HTTP response waiting on that second part.
The scope has to be a Spring bean with the default singleton scope: built once, when the application context starts, and alive for exactly as long as the application is — never per-request, never per-call. That single decision is what fixes lesson 15's trap. Because the scope isn't created inside the request's own coroutine, cancelling the request (or the response completing) has no effect on it; the `launch`ed write is a child of a `Job` that belongs to the application, not to the request.
It would compile just fine to skip the bean and call `GlobalScope.launch { ... }` directly inside `valuate()` — `GlobalScope` is already sitting right there in the `kotlinx.coroutines` package, no injection required. Resist it; it is an anti-pattern for three concrete reasons. First, it's untestable: `GlobalScope` is a fixed top-level singleton baked into the coroutines library itself, so a unit test has no way to substitute a different dispatcher or intercept what it does. Second, it's unstoppable: `GlobalScope` lives for the entire process, with no hook to cancel work when Spring's application context shuts down, so in-flight writes during a rolling deploy just race the JVM exiting. Third, and most fundamentally, it has no structured lifecycle — it doesn't belong to anything, so nothing owns the decision of when its children should stop, which is the entire idea this module has been building toward. A constructor-injected bean gets you all three back: dependency injection makes it swappable in tests, Spring's `@PreDestroy` gives you a place to cancel it on shutdown, and it's visibly owned by the component that uses it.
In practice, the bean bundles all three lessons into one object: `CoroutineScope(SupervisorJob() + Dispatchers.IO + handler)`. `SupervisorJob()` gives every `launch`ed write its own failure domain. `Dispatchers.IO` is the dispatcher meant for blocking-ish I/O work — a reasonable default for a MongoDB driver call, distinct from the `Dispatchers.Default` used for CPU-bound work. And `handler` is a `CoroutineExceptionHandler` that logs the failure and increments a Datadog counter, so a rejected document becomes a line in your logs and a blip on a dashboard instead of a silent gap in the audit trail.
`VehicleValuationService` takes that scope as just another constructor parameter, exactly like it takes `VehicleRepository` or the three provider clients — that's what makes it swappable in a test. Inside `valuate()`, the three provider calls are still `async`, because their results feed the rules engine and the function genuinely needs to wait for all three. The audit write is the odd one out: after the rules engine returns a decision and the function has everything it needs to build a `ValuationEvent`, it calls `valuationEventScope.launch { valuationEventRepository.save(event) }` and returns the `ValuationResult` immediately. No `.join()`, no `.await()` — the function returns as soon as the call to `launch` itself returns, which happens as soon as the coroutine is scheduled, not when it completes.
Trace each safety property back to where it came from. The write survives the request completing because `valuationEventScope` is a bean, not a scope nested inside the request's own coroutine — that's lesson 15. A failed write for one VIN can't cancel an in-flight write for another VIN sharing the same scope, because the scope is built on a `SupervisorJob` — that's lesson 16. And a failed write doesn't vanish without a trace, because the `CoroutineExceptionHandler` on that same scope logs it and counts it — also lesson 16. None of the three is optional; drop any one of them and you're back to a version of this feature that's fast but unreliable in a way that only shows up under real production conditions.
This design pays for itself again in tests: because the scope arrives through the constructor, a test can hand `VehicleValuationService` a scope built on `Dispatchers.Unconfined` (or a test dispatcher that runs eagerly), and assert directly that `valuationEventRepository.save(...)` was called — no `Thread.sleep`, no polling, no flakiness. And in production, wiring `@PreDestroy` on the configuration class to call `scope.cancel()` gives the application context a place to shut this down cleanly during a rolling deploy — something `GlobalScope` could never offer. With the mechanism in place, the remaining question is what decision the rules engine is actually making with all this data — which is where the course turns next.
@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.
🧠 Check your understanding
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()?