Cómo diseñar sistemas cuando no controlas todas las dependencias
Cómo construir sistemas resilientes cuando las APIs de terceros, los servicios legacy y los equipos externos no son confiables. Circuit breakers, bulkheads, fallbacks y el concepto de trust boundary, a partir de experiencia real en e-commerce.
- #system-design
- #architecture
- #resilience
- #backend
- #microservices
Hay un momento en la carrera de todo ingeniero en el que te das cuenta de algo incómodo: el sistema del que eres responsable es tan confiable como su dependencia externa más débil. Y esa dependencia no la controlas tú.
Quizás sea una pasarela de pagos que agota el tiempo de espera en horas pico. Quizás sea un sistema de inventario legacy mantenido por un equipo en otra zona horaria que hace deploy los viernes. Quizás sea un proveedor de envíos cuya API devuelve 500 cada vez que publica una “actualización menor”.
He vivido todo esto. Y esto es lo que aprendí: los sistemas resilientes no se diseñan esperando que todo funcione. Se diseñan asumiendo que todo va a fallar.
El concepto de trust boundary
Antes de entrar en los patrones, establezcamos un modelo mental que uso constantemente: el trust boundary (límite de confianza).
Todo sistema tiene un límite entre lo que controlas y lo que no. Dentro del límite, eres dueño del código, los deploys, el monitoreo y la rotación de guardias. Fuera del límite, estás a merced de las prioridades, el calendario de releases y la definición de “confiable” de alguien más.
El error que cometen la mayoría de los equipos es tratar las dependencias externas como si estuvieran dentro del trust boundary. Llaman a una API de terceros y asumen que responderá en 200 ms. Se integran con un servicio legacy interno y asumen que el contrato no cambiará. Confían.
No confíes. Verifica, aísla y planifica para el fallo.
Así es como trazo los trust boundaries en la práctica:
- Nivel 1 (confianza total): Servicios que mi equipo posee y despliega. Nosotros controlamos los SLA.
- Nivel 2 (confianza condicional): Servicios internos de otros equipos. Tenemos algo de influencia, pero ningún control.
- Nivel 3 (confianza cero): APIs de terceros, sistemas legacy, cualquier cosa detrás de una VPN que no podemos monitorear.
Cada nivel recibe una estrategia de resiliencia distinta. Las dependencias de Nivel 3 reciben el tratamiento completo.
Patrón Circuit Breaker: deja de golpear servicios muertos
El circuit breaker es el primer patrón al que deberías recurrir cuando lidias con dependencias poco confiables. El concepto está tomado de la ingeniería eléctrica: cuando un servicio downstream está fallando, deja de enviarle solicitudes.
Aquí hay una implementación simplificada que he usado en servicios Node.js:
class CircuitBreaker {
private failures = 0;
private lastFailure: number = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private readonly threshold: number = 5,
private readonly resetTimeout: number = 30_000
) {}
async execute<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.state = 'half-open';
} else {
return fallback();
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
return fallback();
}
}
private onSuccess(): void {
this.failures = 0;
this.state = 'closed';
}
private onFailure(): void {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = 'open';
}
}
}
En un sistema de e-commerce en el que trabajé, envolvimos cada llamada a la pasarela de pagos con un circuit breaker. Cuando Stripe tenía problemas intermitentes (sí, hasta Stripe tiene malos días), el circuito se abría tras 5 fallos consecutivos. En lugar de encolar miles de intentos de pago fallidos y dejar a los usuarios mirando un spinner, redirigíamos de inmediato al flujo de fallback: mostrábamos un mensaje de “procesamiento de pago retrasado” y encolábamos el cobro para reintentarlo.
Idea clave: El circuit breaker no arregla el problema. Previene los fallos en cascada y le da al usuario una mejor experiencia durante las caídas.
Aislamiento con Bulkheads: no dejes que un fallo hunda el barco
Los bulkheads (mamparos) de un barco son compartimentos estancos. Si uno se inunda, el barco no se hunde. El mismo principio aplica al software.
En un escenario real: teníamos una plataforma de e-commerce en la que el calculador de tarifas de envío, el verificador de inventario y el procesador de pagos compartían el mismo pool de conexiones HTTP. Cuando el proveedor de envíos empezó a responder lento, consumió todas las conexiones disponibles. De pronto, los pagos tampoco se podían procesar. Una dependencia defectuosa tumbó una ruta crítica que no tenía relación con ella.
La solución fue el aislamiento con bulkheads:
- Pools de conexiones separados por cada dependencia externa
- Pools de hilos separados (o, en Node.js, workers de cola separados) para distintas categorías de trabajo
- Timeouts separados ajustados al comportamiento esperado de cada dependencia
const paymentClient = new HttpClient({
maxConnections: 20,
timeout: 5_000, // los pagos deberían ser rápidos
retries: 2,
});
const shippingClient = new HttpClient({
maxConnections: 10,
timeout: 15_000, // las APIs de envío son notoriamente lentas
retries: 3,
});
const inventoryClient = new HttpClient({
maxConnections: 15,
timeout: 3_000, // servicio interno, debería ser rápido
retries: 1,
});
Esto no es sobreingeniería. Es la diferencia entre “la API de envíos está lenta hoy” y “todo el checkout está caído”.
Estrategias de timeout: el asesino silencioso
La mayoría de los incidentes de producción que he depurado no empezaron con un crash. Empezaron con un timeout demasiado generoso o inexistente.
Esta es mi jerarquía de timeouts para llamadas externas:
- Connect timeout: Cuánto esperar una conexión TCP. Mantenlo agresivo (1-3 segundos). Si un servicio no acepta conexiones, esperar más no ayudará.
- Read timeout: Cuánto esperar una respuesta después de establecida la conexión. Depende de la operación, pero rara vez supero los 10 segundos en llamadas síncronas.
- Total timeout: El presupuesto global de toda la operación, incluidos los reintentos. Es el que la mayoría olvida.
La regla que sigo: toda llamada HTTP a un servicio externo DEBE tener un timeout explícito. Sin excepciones. Los timeouts por defecto de las librerías HTTP suelen ser de 30 a 120 segundos, lo cual es una eternidad en un pipeline de solicitudes.
Un ejemplo práctico: en un flujo de checkout, el presupuesto total es quizás de 8 segundos antes de que el usuario se impaciente. Si llamas en secuencia a una API de pagos, un validador de direcciones y un calculador de impuestos, no tienes 30 segundos por llamada. Tienes quizás 2-3 segundos cada una, y el pago recibe la asignación más generosa.
Mecanismos de fallback: degradación elegante en la práctica
Aquí es donde se pone interesante. Los fallbacks no son solo devolver datos en cache. Se trata de decidir qué puede seguir haciendo tu sistema sin una dependencia.
Ejemplos reales de sistemas que he construido:
- Pasarela de pagos caída: Encolar la orden, mostrar el estado “procesando” y cobrar de forma asíncrona cuando la pasarela se recupere. El usuario igual completa el checkout.
- Calculador de tarifas de envío caído: Mostrar tarifas planas estimadas a partir de datos históricos. No es perfecto, pero el usuario puede comprar.
- Servicio de inventario caído: Permitir la compra, pero marcarla para revisión manual. Aceptar el riesgo de vender de más unas pocas piezas frente a bloquear todas las ventas.
- Motor de recomendaciones caído: Mostrar productos populares en lugar de personalizados. Nadie lo nota.
El patrón:
async function getShippingRates(order: Order): Promise<ShippingRate[]> {
try {
return await shippingCircuitBreaker.execute(
() => shippingClient.calculateRates(order),
() => getCachedFlatRates(order.destination)
);
} catch {
return getDefaultFlatRates(); // último recurso
}
}
La conversación con el negocio importa aquí. Los fallbacks tienen trade-offs. Mostrar tarifas planas cuando las tarifas reales son distintas significa que podrías perder dinero en algunos envíos. Encolar y cobrar después significa que podrías cobrarle a una tarjeta que luego será rechazada. Son decisiones de negocio, no solo técnicas. Ten esta conversación con producto antes de la caída, no durante.
Contract Testing: detecta los quiebres antes de producción
Una de las estrategias más efectivas que he adoptado es el contract testing dirigido por el consumidor. En lugar de rezar para que la API de envíos no cambie su formato de respuesta, codificamos lo que esperamos y lo probamos con regularidad.
Herramientas como Pact te permiten definir contratos:
describe('Shipping API Contract', () => {
it('should return rates with expected structure', async () => {
const response = await provider.executeTest(async (mockServer) => {
const client = new ShippingClient(mockServer.url);
const rates = await client.getRates(testOrder);
expect(rates).toEqual(
expect.arrayContaining([
expect.objectContaining({
carrier: expect.any(String),
price: expect.any(Number),
estimatedDays: expect.any(Number),
}),
])
);
});
});
});
Esto no evitará que la API externa se rompa. Pero te avisará de inmediato cuando se viole el contrato, en lugar de descubrirlo a través de errores en producción a las 2 AM.
El trade-off: resiliencia vs. simplicidad
Hay algo sobre lo que quiero ser honesto: todo esto añade complejidad. Circuit breakers, bulkheads, fallbacks, contract tests: todos añaden código, configuración y carga cognitiva.
No todos los sistemas necesitan todos estos patrones. ¿Un proyecto personal que llama a una sola API externa? Simplemente añade un timeout y maneja los errores con elegancia. ¿Una plataforma de e-commerce que procesa miles de órdenes por hora a través de una docena de servicios externos? Necesitas el kit completo.
Mi regla general:
- Si la caída de la dependencia cuesta dinero: circuit breaker + fallback + monitoreo
- Si la caída provoca pérdida de datos: aislamiento con bulkheads + reintentos con idempotencia
- Si la dependencia pertenece a otro equipo: contract tests + SLA explícitos
- Si es un tercero sobre el que no puedes influir en absoluto: todo lo anterior
Reflexiones finales
La lección más difícil del diseño de sistemas no es aprender patrones. Es aceptar que no puedes controlarlo todo. Tu proveedor de pagos tendrá caídas. El sistema legacy cambiará sin avisar. El equipo del otro lado del edificio desplegará un cambio que rompe todo un viernes.
Tu trabajo no es prevenir estos fallos. Es diseñar sistemas que los manejen con elegancia. Cada patrón de resiliencia es una apuesta: “Cuando esta dependencia falle (y va a fallar), así es como seguiremos funcionando”.
Los ingenieros que construyen sistemas realmente confiables no son los que escriben el código más ingenioso. Son los que se han quemado las veces suficientes como para planificar el incendio.