ADR-0006 — Los invariantes de negocio viven en la base
Estado: Aceptado Fecha: 2026-08-21 Deciden: Mao (product owner), asistente AI
Contexto
Sección titulada «Contexto»M1 trajo el primer invariante duro del sistema: RN-CAT-04 dice que el precio mínimo de un producto nunca puede superar sus precios de lista. Si se rompe, un código de descuento puede dejar una venta por debajo del piso y nadie se entera hasta el cierre.
Habrá muchos más. M2 necesita que el stock no quede negativo, M3 que un insumo no se consuma más de lo que hay, M4 que el balance de agua cierre. La decisión que se tome acá se repite en cada módulo.
Hay además una restricción concreta del proyecto: existen dos roles de Postgres (ADR-0004). El de la aplicación no es dueño de las tablas, y el dueño corre las migraciones. Una garantía puede aplicar a uno, al otro, o a los dos.
Alternativas evaluadas
Sección titulada «Alternativas evaluadas»Opción A — Validar solo en la capa de servicio
Sección titulada «Opción A — Validar solo en la capa de servicio»- ✅ Un solo lugar, en TypeScript, fácil de testear y de leer.
- ✅ El mensaje de error se escribe para una persona.
- ❌ Se saltea con un
UPDATEdirecto. Una migración, un script de corrección, unpsqla las once de la noche: ninguno pasa por el servicio. - ❌ Depende de que cada endpoint nuevo se acuerde de llamarla. El día que alguien agregue una ruta y se olvide, el invariante deja de existir sin que ningún test lo note.
Opción B — Validar solo con un CHECK en la base
Sección titulada «Opción B — Validar solo con un CHECK en la base»- ✅ Aplica a todos: la aplicación, el dueño, un script suelto.
- ✅ No depende de la disciplina de quien escribe la query.
- ❌ El error que llega es de Postgres:
violates check constraint "productos_precio_minimo_es_piso". Ilegible para quien está cargando un precio. - ❌ Llega tarde: el usuario ya mandó el formulario.
Opción C — Las dos, con roles distintos ← elegida
Sección titulada «Opción C — Las dos, con roles distintos ← elegida»- ✅ La base impide el dato malo; el servicio explica qué corregir.
- ✅ Cada capa hace lo que la otra no puede.
- ❌ La regla se escribe dos veces.
Decisión
Sección titulada «Decisión»Elegimos la Opción C, y la parte que importa es por qué no es duplicación:
La base impide el dato malo aunque un endpoint se olvide. El servicio existe para que el error sea legible.
No son dos validaciones de lo mismo: son dos responsabilidades distintas que casualmente comparten una condición. Quitar cualquiera de las dos pierde algo real.
Se aplica con tres mecanismos, según qué se quiera garantizar:
| Mecanismo | Garantiza | Ejemplo en M1 |
|---|---|---|
CHECK |
Una condición sobre la fila, para todo el mundo | precio_minimo <= los dos precios de lista |
GENERATED ALWAYS AS |
Que un derivado no pueda desincronizarse | litros = contenido × unidades |
REVOKE al rol de app |
Que una operación entera no exista | sin DELETE sobre productos |
Consecuencias
Sección titulada «Consecuencias»Se vuelve fácil:
- Confiar en los datos sin auditar todo el código que los escribe.
- Corregir datos a mano sin miedo: la base rechaza lo que no corresponde.
- Que un módulo nuevo herede la garantía sin leer el código del anterior.
Se vuelve difícil / costoso:
- Cambiar un invariante exige una migración, no un deploy.
- Un
CHECKmal escrito bloquea escrituras legítimas hasta que se migre. - Hay que acordarse de escribir las dos mitades.
Lo que aceptamos pagar: la condición escrita dos veces, y una migración cada vez que un invariante cambie.
Lo que exige el ADR: que cada invariante tenga un test de integración que
lo verifique contra la base, no solo contra el servicio. Sin ese test, el
CHECK podría faltar en la migración y nadie se enteraría — el servicio
seguiría validando y todo parecería bien.
Verificado
Sección titulada «Verificado»Un UPDATE que baja el precio residencial por debajo del piso falla incluso
ejecutado con el rol dueño, el mismo que corre las migraciones. El GRANT
solo limita al rol de la aplicación; el CHECK no perdona a nadie.
