Aprende la idea de Pat Helland sobre datos fuera vs dentro para trazar límites claros, diseñar llamadas idempotentes y reconciliar el estado cuando fallan las redes.

Cuando construyes una app, es fácil imaginar que las solicitudes llegan ordenadas, una por una, en el orden correcto. Las redes reales no funcionan así. Un usuario pulsa “Pagar” dos veces porque la pantalla se congeló. Una conexión móvil se corta justo después de pulsar un botón. Un webhook llega tarde, o llega dos veces. A veces nunca llega.
La idea de Pat Helland de datos fuera vs dentro es una forma clara de pensar sobre ese lío.
“Fuera” es todo lo que tu sistema no controla. Es donde hablas con otras personas y sistemas, y donde la entrega es incierta: peticiones HTTP desde navegadores y apps móviles, mensajes de colas, webhooks de terceros (pagos, email, envíos) y reintentos desencadenados por clientes, proxies o jobs en segundo plano.
En lo fuera, asume que los mensajes pueden retrasarse, duplicarse o llegar fuera de orden. Incluso si algo es “usualmente fiable”, diseña para el día en que no lo sea.
“Dentro” es lo que tu sistema puede hacer confiable. Es el estado durable que almacenas, las reglas que aplicas y los hechos que puedes probar después:
Dentro es donde proteges invariantes. Si prometes “un pago por orden”, esa promesa debe hacerse valer dentro, porque lo fuera no se puede confiar en que se comporte.
El cambio de mentalidad es simple: no asumas entrega perfecta ni sincronía perfecta. Trata cada interacción externa como una sugerencia poco fiable que puede repetirse, y haz que lo interno reaccione de forma segura.
Esto importa incluso para equipos pequeños y apps simples. La primera vez que un fallo de red crea un cargo duplicado o una orden atascada, deja de ser teoría y se convierte en reembolso, ticket de soporte y pérdida de confianza.
Un ejemplo concreto: un usuario pulsa “Realizar pedido”, la app envía una petición y la conexión se cae. El usuario lo intenta de nuevo. Si tu parte interna no tiene forma de reconocer “es el mismo intento”, puedes crear dos pedidos, reservar inventario dos veces o enviar dos correos de confirmación.
El punto de Helland es directo: el mundo exterior es incierto, pero el interior de tu sistema debe permanecer consistente. Las redes pierden paquetes, los teléfonos pierden señal, los relojes derivan y los usuarios refrescan. Tu app no puede controlar nada de eso. Lo que sí puede controlar es qué acepta como “verdad” una vez que los datos cruzan un límite claro.
Imagina a alguien pidiendo café en el móvil mientras camina por un edificio con Wi‑Fi malo. Pulsa “Pagar”. El spinner gira. La red se corta. Pulsa otra vez.
Quizá la primera petición llegó a tu servidor, pero la respuesta no volvió. O quizá ninguna de las peticiones llegó. Desde la vista del usuario, ambas posibilidades parecen iguales.
Eso es tiempo e incertidumbre: no sabes qué pasó todavía, y puede que lo averigües más tarde. Tu sistema debe comportarse con sensatez mientras espera.
Una vez aceptas que lo fuera es poco fiable, algunos comportamientos “raros” se vuelven normales:
Los datos externos son una afirmación, no un hecho. “Pagué” es solo una declaración enviada por un canal poco fiable. Se convierte en hecho solo después de que lo registres dentro de tu sistema de forma durable y consistente.
Esto te empuja hacia tres hábitos prácticos: define límites claros, haz que los reintentos sean seguros con idempotencia y planifica la reconciliación cuando la realidad no coincida.
La idea de “fuera vs dentro” empieza con una pregunta práctica: ¿dónde comienza y termina la verdad de tu sistema?
Dentro del límite, puedes hacer garantías sólidas porque controlas los datos y las reglas. Fuera del límite, haces intentos de la mejor voluntad y asumes que los mensajes pueden perderse, duplicarse, retrasarse o llegar fuera de orden.
En apps reales, ese límite suele aparecer en lugares como:
Una vez trazas esa línea, decide qué invariantes son innegociables dentro de ella. Ejemplos:
El límite también necesita un lenguaje claro para “dónde estamos”. Muchos fallos viven en la brecha entre “te escuchamos” y “lo terminamos”. Un patrón útil es separar tres significados:
Received: el mensaje llegó a tu borde (no necesariamente guardado aún)Accepted: lo guardaste y puedes reintentar el trabajo de forma segura más tardeProcessed: el trabajo previsto se completó y registraste el resultadoCuando los equipos saltan esto, acaban con bugs que solo aparecen bajo carga o durante fallos parciales. Un sistema usa “pagado” para significar dinero capturado; otro lo usa para significar intento de pago iniciado. Ese desajuste crea duplicados, órdenes atascadas y tickets de soporte que nadie puede reproducir.
Idempotencia significa: si la misma petición se envía dos veces, el sistema la trata como una sola y devuelve el mismo resultado.
Los reintentos son normales. Ocurren timeouts. Los clientes se repiten. Si lo fuera puede repetirse, tu dentro tiene que convertir eso en cambios de estado estables.
Un ejemplo sencillo: una app móvil envía “pagar $20” y la conexión se corta. La app reintenta. Sin idempotencia, el cliente podría cobrarse dos veces. Con idempotencia, la segunda petición devuelve el resultado del primer cobro.
La mayoría de equipos usan uno de estos patrones (a veces una mezcla):
Idempotency-Key: ...). El servidor registra la clave y la respuesta final.Cuando llega un duplicado, el mejor comportamiento normalmente no es un “409 conflict” o un error genérico. Es devolver el mismo resultado que devolviste la primera vez, incluyendo el mismo ID de recurso y estado. Eso es lo que hace que los reintentos sean seguros para clientes y jobs en segundo plano.
El registro de idempotencia debe vivir dentro de tu límite en almacenamiento durable, no en memoria. Si tu API se reinicia y lo olvida, la garantía de seguridad desaparece.
Mantén registros el tiempo suficiente para cubrir reintentos realistas y entregas retrasadas. La ventana depende del riesgo del negocio: minutos u horas para creaciones de bajo riesgo, días para pagos/correos/envíos donde los duplicados son costosos, y más tiempo si los socios pueden reintentar durante periodos extendidos.
Las transacciones distribuidas suenan reconfortantes: un gran commit a través de servicios, colas y bases de datos. En la práctica suelen ser indisponibles, lentas o demasiado frágiles para depender de ellas. Una vez hay un salto de red, no puedes asumir que todo committeará junto.
Una trampa común es construir un flujo de trabajo que solo funciona si cada paso tiene éxito ahora mismo: guardar orden, cobrar tarjeta, reservar inventario, enviar confirmación. Si el paso 3 hace timeout, ¿falló o tuvo éxito? Si reintentas, ¿duplicarás cobros o reservas?
Dos enfoques prácticos evitan esto:
Elige un estilo por flujo y mantente con él. Mezclar “a veces usamos outbox” con “a veces asumimos éxito sincrónico” crea casos límite difíciles de probar.
Una regla simple ayuda: si no puedes commit en forma atómica a través de límites, diseña para reintentos, duplicados y retrasos.
La reconciliación admite una verdad básica: cuando tu app habla con otros sistemas por la red, a veces discreparán sobre lo que ocurrió. Las peticiones hacen timeout, los callbacks llegan tarde y la gente reintenta acciones. La reconciliación es cómo detectas desajustes y los arreglas con el tiempo.
Trata a los sistemas externos como fuentes de verdad independientes. Tu app mantiene su propio registro interno, pero necesita comparar ese registro con lo que los socios, proveedores y usuarios hicieron realmente.
La mayoría de equipos usan un pequeño conjunto de herramientas aburridas (lo aburrido es bueno): un worker que reintenta acciones pendientes y vuelve a comprobar el estado externo, un escaneo programado para inconsistencias y una pequeña acción de reparación para soporte que permita reintentar, cancelar o marcar como revisado.
La reconciliación solo funciona si sabes qué comparar: libro mayor interno vs libro mayor del proveedor (pagos), estado de la orden vs estado del envío (fulfillment), estado de suscripción vs estado de facturación.
Haz que los estados sean reparables. En lugar de saltar directamente de “creado” a “completado”, usa estados intermedios como pending, on hold o needs review. Eso permite decir con seguridad “no estamos seguros todavía” y da a la reconciliación un lugar claro donde aterrizar.
Captura una pequeña bitácora de auditoría en cambios importantes:
Ejemplo: si tu app solicita una etiqueta de envío y la red se corta, podrías quedarte con “sin etiqueta” internamente mientras el transportista creó una. Un worker de reconciliación puede buscar por ID de correlación, descubrir que la etiqueta existe y avanzar la orden (o marcarla para revisión si los detalles no coinciden).
Una vez asumes que la red fallará, el objetivo cambia. No buscas que cada paso tenga éxito en un solo intento. Buscas que cada paso sea seguro de repetir y fácil de reparar.
Escribe una frase que describa el límite. Sé explícito sobre lo que tu sistema posee (la fuente de verdad), lo que refleja y lo que solo solicita a otros.
Lista los modos de fallo antes del camino feliz. Como mínimo: timeouts (no sabes si funcionó), peticiones duplicadas, éxito parcial (un paso ocurrió, el siguiente no) y eventos fuera de orden.
Elige una estrategia de idempotencia para cada entrada. Para APIs síncronas, suele ser una clave de idempotencia más un resultado almacenado. Para mensajes/eventos, suele ser un ID de mensaje único y un registro de “¿he procesado esto?”
Persiste la intención, luego actúa. Primero guarda algo durable como PaymentAttempt: pending o ShipmentRequest: queued, luego realiza la llamada externa y después almacena el resultado. Devuelve un ID de referencia estable para que los reintentos apunten a la misma intención en lugar de crear una nueva.
Construye reconciliación y una ruta de reparación, y hazlas visibles. La reconciliación puede ser un job que escanee registros “pendientes por demasiado tiempo” y vuelva a comprobar el estado. La ruta de reparación puede ser una acción administrativa segura como “reintentar”, “cancelar” o “marcar resuelto”, con una nota de auditoría. Añade observabilidad básica: correlation IDs, campos de estado claros y algunos contadores (pendientes, reintentos, fallos).
Ejemplo: si el checkout hace timeout justo después de llamar a un proveedor de pagos, no adivines. Guarda el intento, devuelve el ID del intento y permite que el usuario reintente con la misma clave de idempotencia. Más tarde, la reconciliación puede confirmar si el proveedor cobró o no y actualizar el intento sin duplicar cobros.
Un cliente pulsa “Realizar pedido”. Tu servicio envía una petición de pago a un proveedor, pero la red es inestable. El proveedor tiene su propia verdad, y tu base de datos tiene la tuya. Derivarán a menos que lo diseñes para evitarlo.
Desde tu punto de vista, lo fuera es una secuencia de mensajes que puede estar atrasada, repetirse o faltar:
Ninguno de esos pasos garantiza “exactamente una vez”. Solo garantizan “quizá”.
Dentro de tu límite, almacena hechos durables y lo mínimo necesario para conectar eventos externos a esos hechos.
Cuando el cliente hace el pedido por primera vez, crea un registro order en un estado claro como pending_payment. También crea un registro payment_attempt con una referencia única del proveedor más una idempotency_key vinculada a la acción del cliente.
Si el cliente hace timeout y reintenta, tu API no debería crear una segunda orden. Debe buscar la idempotency_key y devolver el mismo order_id y estado actual. Esa elección evita duplicados cuando fallan las redes.
Ahora el webhook llega dos veces. El primer callback actualiza payment_attempt a authorized y mueve la orden a paid. El segundo callback llega al mismo handler, pero detectas que ya procesaste ese evento del proveedor (almacenando el ID del evento del proveedor o comprobando el estado actual) y no haces nada. Aún puedes responder 200 OK, porque el resultado ya es verdadero.
Finalmente, la reconciliación maneja los casos desordenados. Si la orden sigue en pending_payment después de un retraso, un job en segundo plano consulta al proveedor usando la referencia almacenada. Si el proveedor dice “autorizado” pero te perdiste el webhook, actualizas tus registros. Si el proveedor dice “fallido” pero tú la marcaste como pagada, la marcas para revisión o activas una compensación como un reembolso.
La mayoría de registros duplicados y flujos “atascados” vienen de mezclar lo que pasó fuera de tu sistema (llegó una petición, se recibió un mensaje) con lo que comprometiste de forma segura dentro de tu sistema.
Un fallo clásico: un cliente envía “realizar pedido”, tu servidor empieza a trabajar, la red se cae y el cliente reintenta. Si tratas cada reintento como una verdad nueva, obtendrás cargos dobles, pedidos duplicados o correos múltiples.
Las causas habituales son:
Un problema que empeora todo: sin rastro de auditoría. Si sobrescribes campos y mantienes solo el estado más reciente, pierdes la evidencia necesaria para reconciliar después.
Una buena comprobación de cordura es: “Si ejecuto este handler dos veces, ¿obtengo el mismo resultado?” Si la respuesta es no, los duplicados no son un raro caso límite. Son garantizados.
Si recuerdas una cosa: tu app debe seguir correcta incluso cuando los mensajes lleguen tarde, lleguen dos veces o nunca lleguen.
Usa esta lista para detectar puntos débiles antes de que se conviertan en registros duplicados, actualizaciones perdidas o flujos atascados:
Si no puedes responder una de estas rápido, eso ya es útil. Normalmente significa que un límite es difuso o falta una transición de estado.
Pasos prácticos siguientes:
Dibuja primero límites y estados. Define un pequeño conjunto de estados por flujo (por ejemplo: Created, PaymentPending, Paid, FulfillmentPending, Completed, Failed).
Añade idempotencia donde más importa. Empieza por las escrituras de mayor riesgo: crear orden, capturar pago, emitir reembolso. Almacena claves de idempotencia en PostgreSQL con una restricción única para que los duplicados se rechacen de forma segura.
Trata la reconciliación como una característica normal. Programa un job que busque registros “pendientes por demasiado tiempo”, consulte sistemas externos y repare el estado local.
Itera con seguridad. Ajusta transiciones y reglas de reintento y prueba reenviando deliberadamente la misma petición y reprocesando el mismo evento.
Si construyes rápido en una plataforma guiada por chat como Koder.ai (koder.ai), vale la pena incorporar estas reglas en tus servicios generados desde el principio: la velocidad viene de la automatización, pero la fiabilidad viene de límites claros, handlers idempotentes y reconciliación.
"Outside" es cualquier cosa que no controlas: navegadores, redes móviles, colas, webhooks de terceros, reintentos y timeouts. Asume que los mensajes pueden demorarse, duplicarse, perderse o llegar fuera de orden.
"Inside" es lo que tú sí controlas: tu estado almacenado, tus reglas y los hechos que puedes demostrar después (por lo general en tu base de datos).
Porque la red te miente.
Un cliente que agota el tiempo no significa que tu servidor no procesó la petición. Un webhook que llega dos veces no implica que el proveedor hizo la acción dos veces. Si tratas cada mensaje como una "nueva verdad", acabarás con órdenes duplicadas, cargos dobles y flujos bloqueados.
Un límite claro es el punto donde un mensaje poco fiable se convierte en un hecho durable.
Límites comunes:
Una vez que los datos cruzan ese límite, aplicas invariantes dentro (por ejemplo: "una orden solo puede pagarse una vez").
Usa idempotencia. La regla es: la misma intención debe producir el mismo resultado incluso si se envía varias veces.
Patrones prácticos:
No lo guardes solo en memoria. Almacénalo dentro de tu límite (por ejemplo, PostgreSQL) para que reinicios no borren la protección.
Regla de retención orientativa:
Guárdalo el tiempo suficiente para cubrir reintentos realistas y callbacks demorados.
Usa estados que admitan incertidumbre.
Un conjunto simple y práctico:
pending_* (aceptamos la intención pero no sabemos aún el resultado)succeeded / failed (registramos un resultado final)needs_review (detectamos un desajuste que requiere intervención humana o un job especial)Porque no puedes commitear atómicamente a través de múltiples sistemas por la red.
Si haces "guardar orden → cobrar tarjeta → reservar inventario" sincrónicamente y el paso 2 hace timeout, no sabrás si reintentar. Reintentar puede causar duplicados; no reintentar puede dejar trabajo incompleto.
Diseña para éxito parcial: persiste la intención primero, luego realiza acciones externas y finalmente registra los resultados.
El patrón outbox/inbox hace el mensajería entre sistemas fiable sin fingir que la red es perfecta.
La reconciliación es cómo recuperas cuando tus registros y un sistema externo no coinciden.
Buenos valores por defecto:
needs_reviewNo es opcional para pagos, fulfillment, suscripciones o cualquier cosa con webhooks.
Sí. Construir rápido no elimina las fallas de red; solo te hace encontrarlas antes.
Si generas servicios con Koder.ai, aplica estas reglas desde el inicio:
Así, reintentos y callbacks duplicados se vuelven aburridos en lugar de costosos.
Esto evita adivinar durante timeouts y facilita la reconciliación.