Wiring It Together: The Vehicle Valuation Service
Follow one VIN from HTTP request to database row and back, and watch constructor injection and suspend functions do their jobs at every hop.
The previous three lessons each looked at one concern in isolation — layering, DI, coroutines. This lesson puts them together and walks a single request through the full stack, because the concepts only really click once you see them cooperating rather than described separately. The request: GET /vehicles/1HGCM82633A004352/valuation. By the time a JSON response comes back, code has run in the controller, the service, three provider clients, a Redis cache check, and a Postgres repository — and every one of those classes only knows about its immediate neighbors.
It starts at VehicleController, which Spring constructed once at startup with a single VehicleValuationService instance injected through its constructor — no lookup, no factory call inside the handler, just a field that was already populated before the first request ever arrived. The handler method extracts vin from the path, calls valuationService.valuate(vin), and does nothing else: no business logic, no direct database or cache access, just a suspend call and a mapping of the result to a ValuationResponse DTO. If this method starts accumulating if-statements about pricing rules, that's the signal that logic has leaked into a layer that shouldn't hold it.
Inside VehicleValuationService.valuate, four collaborators do their part, and the service itself was built with all four injected through its own constructor: the repository, and the three provider clients. First it checks Redis for a cached valuation keyed by VIN — a cache hit short-circuits everything below it and returns immediately, which matters because the alternative is three outbound HTTP calls. On a cache miss, it calls out to BlackBookClient.quote(vin), CarfaxClient.quote(vin), and VisClient.quote(vin) — each a suspend fun, each genuinely non-blocking, and each implementing the same ValuationProviderClient interface so the service can treat them uniformly rather than special-casing each vendor's SDK.
With three quotes in hand, the service asks VehicleRepository for the active pricing_rule rows relevant to this VIN's make/model bracket, then feeds both the quotes and the rules into the rules engine, which is what actually produces a decision — approved at some offer amount, or blocked by a guardrail, or flagged no-buy. The service then writes a record of that decision to the valuation_event collection in MongoDB (the audit log this course's scenario keeps mentioning) and refreshes the Redis cache entry with a TTL, so the next request for the same VIN within that window skips the provider calls entirely. Notice how many different data stores one method touches — Redis, three external HTTP APIs, Postgres, MongoDB — and yet VehicleController above it knows about none of that; it only knows it called something suspend and got a Valuation back.
The request/response boundary deserves its own look, because it's easy to accidentally blur it with the domain model flowing through the service. VehicleController receives no request body for this particular GET (the VIN comes from the path), but it does produce a ValuationResponse DTO on the way out — a flat, wire-shaped data class with exactly the fields a client needs (vin, offerCents, decision) and nothing about how that decision was reached. Internally, the service works with a richer Valuation domain type that might carry the individual provider quotes, which rule fired, and timing metadata for observability — information genuinely useful for debugging and for the Datadog metrics this course's scenario emits, but not something you want to commit to as a public API contract. The mapping from Valuation to ValuationResponse, typically a small toResponse() extension function, is the one place that boundary is crossed, deliberately and explicitly.
Every one of these classes was independently testable because of exactly the two properties from earlier lessons: constructor injection means each class's dependencies are explicit and swappable with a fake, and consistent suspend chains mean tests can use kotlinx-coroutines-test's runTest without setting up threads or reactive streams by hand. A test for VehicleValuationService.valuate can construct the service directly with mocked provider clients and a mocked repository, feed in canned quotes, and assert on the resulting decision — no Spring context, no real Redis, no real Postgres, no real HTTP calls, and it runs in milliseconds. The full stack, wired for real with Testcontainers standing in for Postgres/Redis/MongoDB, gets exercised separately in integration tests — a distinction a later module covers in depth.
Step back and the shape of the whole thing is: one HTTP entry point, one orchestrating service that composes several suspend-based collaborators (a cache, three external clients, a database repository, an audit sink), and a boundary DTO on the way out. Nothing here is exotic — it's the same three-layer, constructor-injected, suspend-all-the-way-down pattern from the last three lessons, just seen operating on a real request instead of in isolation. Every module after this one is going to zoom into one piece of this exact diagram.
@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.
🧠 Check your understanding
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?