Modelando los Clientes de Terceros: Black Book, Carfax y S&P VIS
Un contrato compartido, tres implementaciones específicas por proveedor, cada una lo bastante aislada como para que una rareza de Carfax nunca rompa una llamada a Black Book.
Con un OkHttpClient compartido y bien configurado ya en mano, la siguiente pregunta es de qué depende realmente VehicleValuationService cuando necesita una valuación. No debería depender de OkHttp directamente, ni depender de `BlackBookClient` o `CarfaxClient` por nombre — debería depender de una abstracción que oculte con qué proveedor está hablando. Esa abstracción es `ValuationProviderClient`, una interfaz pequeña con un solo método: `suspend fun fetchValuation(vin: String): ValuationResult`. Cada proveedor la implementa de la misma manera, lo que significa que el servicio puede tener una `List<ValuationProviderClient>`, llamar a los tres de forma concurrente y no importarle nunca cuál es Black Book y cuál es Carfax.
Fíjate en que la interfaz es `suspend`. Obtener una valuación no tiene nada de intensivo en CPU — consiste casi enteramente en esperar un viaje de ida y vuelta por la red — así que es exactamente el tipo de trabajo para el que existen las corrutinas. Una función suspend le permite a `VehicleValuationService` lanzar estas tres llamadas de forma concurrente con `async`, esperar (`await`) a las tres, y alimentar los resultados al motor de reglas, sin bloquear nunca un hilo durante los diez y tantos segundos que podrían tomar los tres proveedores juntos si se llamaran uno tras otro.
La implementación es una clase por proveedor: `BlackBookClient`, `CarfaxClient`, `VisClient`. Cada una es dueña de exactamente la URL base de un proveedor, exactamente el esquema de autenticación de un proveedor y exactamente la forma de respuesta de un proveedor — parseando ese JSON hacia el mismo tipo de dominio, `ValuationResult`, que el resto del servicio entiende. Desde afuera, las tres se ven idénticas; desde adentro, cada una es libre de ser tan rara como realmente lo es su proveedor.
Es tentador, sobre todo al principio, construir un único `GenericValuationClient` que reciba un `provider: String` y ramifique internamente con un `when` la lógica de parseo, las cabeceras y las URLs. Resiste la tentación. En el momento en que Carfax cambie su esquema de respuesta o empiece a aplicar límites de tasa más agresivos, estarás editando una clase que también maneja Black Book y VIS, y un error tipográfico en la rama de Carfax puede tumbar una ruta de código que no tiene nada que ver con Carfax. Modelar cada proveedor como su propia clase hace que un bug, una caída o un cambio de API que rompa algo en un proveedor tenga un radio de impacto de exactamente una clase — no puede filtrarse hacia cómo hablas con los otros dos, y puedes probar, redeployar o incluso deshabilitar temporalmente un cliente sin tocar los demás.
Los proveedores realmente difieren de las maneras exactas que hacen que esto valga la pena. Black Book devuelve un objeto JSON plano con el número que quieres a un solo nivel de profundidad; Carfax lo anida tres niveles adentro de un objeto `history` y reporta dólares enteros donde Black Book reporta un decimal; una integración real con S&P VIS podría reportar centavos directamente, o envolver errores en un envoltorio con estado 200 en lugar de usar códigos de estado HTTP correctos. Nada de esa idiosincrasia es un defecto de diseño en tu servicio — es simplemente lo que significa integrarse con tres empresas independientes — y mantener cada rareza contenida dentro de su propio cliente es lo que evita que se convierta en un problema en todas partes.
Dentro de cada cliente, la llamada real a OkHttp sigue siendo bloqueante — `execute()` detiene el hilo que la invoca hasta que llegan los bytes de vuelta. Envolverla en `withContext(Dispatchers.IO)` es lo que la vuelve amigable con las corrutinas: `Dispatchers.IO` es un pool de hilos grande y elástico, construido específicamente para absorber trabajo bloqueante como este, así que la llamada bloqueante ocurre ahí en lugar de en el dispatcher que haya llamado a `fetchValuation` originalmente. Saltarse ese `withContext` e invocar directamente el código bloqueante de OkHttp desde una función suspend bloquearía silenciosamente el hilo que esté corriendo esa corrutina — lo cual, en `Dispatchers.Default`, significa robar uno de un pool pequeño y fijo de hilos pensados para trabajo de CPU, justo cuando el motor de reglas los necesita.
Con tres clientes de proveedor construidos sobre la misma interfaz y cada uno responsable únicamente de sus propias rarezas, el servicio tiene una costura limpia sobre la cual construir resiliencia. Ni Black Book, ni Carfax, ni VIS van a ser confiables el cien por ciento del tiempo — esa es precisamente la premisa de este módulo — y las próximas dos lecciones agregan reintentos y circuit breakers como comportamiento que envuelve a cualquier `ValuationProviderClient`, sin que ninguna de las tres implementaciones necesite saber que está ahí.
import java.time.Instant// The one contract the rest of the service depends on. It knows nothing// about HTTP, JSON shapes, or provider-specific auth -- just "give me a// valuation for this VIN, suspended until it is ready or it fails."interface ValuationProviderClient {val providerName: Stringsuspend fun fetchValuation(vin: String): ValuationResult}data class ValuationResult(val provider: String,val vin: String,val estimatedValueCents: Long,val confidence: Double,val retrievedAt: Instant)class ValuationProviderException(provider: String, statusCode: Int) :RuntimeException("$provider returned HTTP $statusCode")
Defines the shared contract every provider client implements, plus the domain model they all produce; it can't run in-browser because it is meant to be compiled as part of the Spring Boot application alongside the real client implementations, not executed standalone.
import com.fasterxml.jackson.databind.JsonNodeimport com.fasterxml.jackson.databind.ObjectMapperimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.withContextimport okhttp3.OkHttpClientimport okhttp3.Requestimport java.time.Instantclass BlackBookClient(private val client: OkHttpClient,private val mapper: ObjectMapper,private val baseUrl: String) : ValuationProviderClient {override val providerName = "black-book"override suspend fun fetchValuation(vin: String): ValuationResult =// OkHttp execute() is a blocking call. Dispatchers.IO is a large,// elastic thread pool meant exactly for blocking I/O like this --// running it there instead of on the calling dispatcher keeps us// from starving the coroutines that do CPU-bound rules-engine work.withContext(Dispatchers.IO) {val request = Request.Builder().url("$baseUrl/v2/valuations/$vin").get().build()client.newCall(request).execute().use { response ->if (!response.isSuccessful) {throw ValuationProviderException(providerName, response.code)}val body: JsonNode = mapper.readTree(response.body?.string())ValuationResult(provider = providerName,vin = vin,estimatedValueCents = (body["valueUsd"].asDouble() * 100).toLong(),confidence = body["confidenceScore"].asDouble(),retrievedAt = Instant.now())}}}
Implements the interface for Black Book, wrapping a blocking OkHttp call in withContext(Dispatchers.IO); it can't run in-browser because it depends on a real OkHttpClient, a live network connection to Black Book, and a JSON parser, none of which the sandbox provides.
import com.fasterxml.jackson.databind.ObjectMapperimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.withContextimport okhttp3.OkHttpClientimport okhttp3.Requestimport java.time.Instantclass CarfaxClient(private val client: OkHttpClient,private val mapper: ObjectMapper,private val baseUrl: String) : ValuationProviderClient {override val providerName = "carfax"override suspend fun fetchValuation(vin: String): ValuationResult =withContext(Dispatchers.IO) {val request = Request.Builder().url("$baseUrl/history/valuation?vin=$vin").header("X-Carfax-Client", "vehicle-valuation-service").get().build()client.newCall(request).execute().use { response ->if (!response.isSuccessful) {throw ValuationProviderException(providerName, response.code)}// Carfax nests the number three levels deep and reports it in// whole dollars, not cents -- a quirk that lives and dies// inside this class instead of leaking into the domain model// or, worse, into how BlackBookClient parses its response.val history = mapper.readTree(response.body?.string())["history"]ValuationResult(provider = providerName,vin = vin,estimatedValueCents = history["estimatedValue"]["amountUsd"].asLong() * 100,confidence = history["estimatedValue"]["reliability"].asDouble(),retrievedAt = Instant.now())}}}
Implements the same interface for Carfax, whose response nests the price three levels deep in whole dollars instead of Black Book's flat, decimal shape; it can't run in-browser for the same reasons as the Black Book client: real HTTP, a real provider, and a real JSON parser.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. VehicleValuationService needs valuations from Black Book, Carfax, and S&P VIS, each with a different response shape and its own quirks. What is the main reason to give each provider its own ValuationProviderClient implementation instead of one class that branches on a provider name?