Garantizar la atomicidad en tu código: transacciones, Sagas y patrón Outbox
09-10-2026
Cuando empiezas en el mundo de la programación, uno de los peores bugs a los que te enfrentas es el que aparentemente funciona. Es ese bug donde miras el código y dices “esto está bien”, levantas los tests y pasan, ejecutas la app en local y funciona.
Imaginemos esta aplicación:
Un cliente que ya tiene cuenta contrata el plan premium. Para dárselo hay que hacer dos cosas: apuntarle el rol en la base de datos y guardar en S3 el acuerdo de confidencialidad (NDA) que acaba de firmar.
Cuando ese cliente intenta abrir el contenido premium, el access-service no decide por su cuenta: le pregunta al subscription-service si su suscripción está activa.
Y activa significa las dos cosas. Sin rol no entras, y sin NDA firmado tampoco.
Un día un cliente entra en la plataforma y contrata el plan premium. Justo en ese momento S3 está caído, así que la petición falla y recibe un 500.
Lo intenta otra vez. Otro 500, pero por un motivo distinto:
ERROR: duplicate key value violates unique constraint "user_roles_pkey"
Y a partir de aquí, todos los intentos van a fallar igual.
El código era este:
public class SubscriptionService { private final UserRepository userRepository; // SQL por debajo private final NdaStorage ndaStorage; // S3 por debajo public void subscribeToPremium(String username, InputStream signedNda) { userRepository.addPremiumRole(username); ndaStorage.store(username, signedNda); } }
Nada exótico y nadie se traga ninguna excepción por el camino: cuando S3 falló, el error subió hasta arriba y el cliente vio su 500. El fallo se vio, que es justo lo que queremos.
Pero para entonces la primera línea ya había hecho su trabajo. El rol premium está insertado.
Por eso el segundo intento muere antes de llegar a S3: el INSERT choca con la clave primaria de user_roles. Le hemos dicho al cliente que la operación falló, y la hemos dejado a medias de una forma que le impide volver a intentarla.
Y si se cansa de reintentar y entra a la aplicación, tampoco tiene el contenido premium:
User has not signed NDA yet, systems can't allow users without a previously signed NDA.
Tiene el rol y no tiene el NDA. El mismo servicio que le apuntó el rol es el que ahora dice que su suscripción no está activa.
El arreglo
Este caso concreto se resuelve con una línea:
@Transactional public void subscribeToPremium(String username, InputStream signedNda) { userRepository.addPremiumRole(username); ndaStorage.store(username, signedNda); }
Si S3 falla, la excepción propaga, la transacción hace rollback y el rol desaparece de la base de datos. El cliente recibe su 500, y esta vez el 500 es verdad: no ha pasado nada. Puede reintentar y funcionará.
En Spring se llama @Transactional; sin Spring, un Unit of Work.
Caso cerrado, y en muchísimos sistemas esto es todo lo que hace falta.
Lo que hemos arreglado en realidad
No hemos hecho que las dos operaciones sean atómicas. Hemos puesto primero la única que sabe deshacerse.
Dale la vuelta al orden y se ve:
@Transactional public void subscribeToPremium(String username, InputStream signedNda) { ndaStorage.store(username, signedNda); userRepository.addPremiumRole(username); // si esto falla... }
El rollback borra el rol, pero no el documento de S3: te queda un documento firmado de alguien que no es premium. Y ni siquiera basta con dejar el putObject al final, porque el commit ocurre después y también puede fallar. El orden reduce la ventana; no la cierra.
¿Es asumible ese objeto huérfano? Si la respuesta es sí —cuatro céntimos al mes, un job lo barre los domingos— ya has terminado, y no es peor respuesta que las otras.
Pero fíjate en qué estás firmando. Lo primero, que tu atomicidad vive en el orden de dos líneas: no lo dice el código, no lo protege ningún test, y dentro de seis meses alguien sube el store() porque se lee mejor y todo sigue en verde.
Y lo segundo, que mientras subes el documento a S3 tienes una transacción abierta: el INSERT coge una conexión del pool y no la suelta hasta el commit. El mismo orden que hace que el rollback funcione es el que te deja reteniendo una conexión mientras esperas a un tercero.
Y la pregunta que acabas de responder tiene nombre. A los átomos se les llamó así porque se creían indivisibles, y resultó que no lo eran. A subscribeToPremium() le pasó lo mismo: parecía una operación y eran dos.
La atomicidad no es algo que te dé el framework. Es una decisión: X operaciones ocurren todas o no ocurre ninguna, y a alguien le toca decir cuánto vale X.
Cuando el orden no es suficiente
No hay ninguna combinación de try, catch y @Transactional que te lleve más lejos, porque el problema ya no es de manejo de errores: estás escribiendo en dos sistemas que no comparten transacción.
Hay dos formas de salir de aquí, y las dos caben en el mismo subscribeToPremium().
Saga: si no puedes deshacerlo automáticamente, deshazlo tú
public void subscribeToPremium(String username, InputStream signedNda) { ndaStorage.store(username, signedNda); try { userRepository.addPremiumRole(username); } catch (Exception exception) { ndaStorage.delete(username); // compensación throw exception; } }
Vuelve a haber un try/catch, pero este no se traga nada: limpia y relanza. Sigues escribiendo en dos sistemas, solo que ahora cada paso sabe cómo deshacer lo anterior.
Su letra pequeña es que el delete() también puede fallar, y entonces estás igual que al principio.
Outbox: deja de escribir en dos sistemas
@Transactional public void subscribeToPremium(String username, InputStream signedNda) { userRepository.addPremiumRole(username); pendingNdaRepository.save(username, signedNda); } @Scheduled(fixedDelay = 5_000) public void uploadPendingNdas() { for (PendingNda pending : pendingNdaRepository.findNotUploaded()) { ndaStorage.store(pending.username(), pending.content()); pendingNdaRepository.markUploaded(pending.id()); } }
Durante la petición solo tocas la base de datos, así que la transacción vuelve a ser de verdad: o está el rol con su NDA pendiente, o no está ninguno de los dos. Lo de S3 pasa después y se reintenta las veces que haga falta.
Ganas un átomo real. Pagas que el documento no está en S3 cuando respondes, sino unos segundos más tarde y tienes una tabla nueva que limpiar.
Dónde vive el átomo de tus operaciones
Volvamos a la del artículo: ¿el átomo de “contratar el plan premium” es el rol, o el rol más el NDA firmado?
El código que escribe asumió lo primero: puesto el rol, operación hecha. El código que lee asume lo segundo: sin NDA no hay suscripción activa. Las dos ideas son razonables, viven en el mismo servicio, y ninguna estaba escrita en ningún sitio.
Y lo interesante es que esta pregunta no tiene una respuesta universal. Tiene una por operación, y buscarla es parte de nuestro criterio como ingenieros.
Un endpoint que elimina 100 usuarios, si borra 99 y falla en el último, ¿debe revertir los otros 99? ¿El átomo es la petición o cada usuario? Las dos respuestas son buenas, para productos distintos. Un borrado masivo desde una consola de administración probablemente quiera ir usuario a usuario y contarte cuáles fallaron. Una operación que tiene que cuadrar con contabilidad, seguramente no.
Y da lo mismo si lo que escribes es un endpoint, un batch nocturno o un consumidor de eventos: cada uno tiene su miga.
Cada operación que escribes tiene un átomo. Lo importante es que sea el que necesita el negocio, no el que te haya dejado por defecto la implementación.
¿Te ha gustado el artículo? Si te ha parecido útil, ¡compártelo en redes con la comunidad!😉

