Entrega apps generadas por IA más seguras apoyándote en las restricciones de PostgreSQL (NOT NULL, CHECK, UNIQUE, FOREIGN KEY) antes que en código y pruebas.

El código generado por IA suele parecer correcto porque maneja la ruta feliz. Las aplicaciones reales fallan en esa zona intermedia y caótica: un formulario envía una cadena vacía en lugar de null, un job en segundo plano reintenta y crea el mismo registro dos veces, o un borrado elimina una fila padre y deja hijos huérfanos. Estos no son errores exóticos. Aparecen como campos requeridos en blanco, valores “únicos” duplicados y filas huérfanas que apuntan a nada.
Además, se escapan de la revisión de código y de las pruebas básicas por una razón simple: los revisores leen la intención, no cada caso límite. Las pruebas suelen cubrir algunos ejemplos típicos, no semanas de comportamiento real de usuarios, importaciones desde CSV, reintentos por redes inestables o peticiones concurrentes. Si un asistente generó el código, puede omitir comprobaciones pequeñas pero críticas como recortar espacios, validar rangos o protegerse contra condiciones de carrera.
“Restricciones primero, código después” significa poner reglas innegociables en la base de datos para que los datos malos no puedan almacenarse, sin importar qué camino de código intente escribirlos. Tu app aún debería validar la entrada para dar mejores mensajes de error, pero la base de datos hace cumplir la verdad. Ahí es donde las restricciones de PostgreSQL brillan: te protegen de categorías enteras de errores.
Un ejemplo rápido: imagina un CRM pequeño. Un script de importación generado por IA crea contactos. Una fila tiene un email de "" (vacío), dos filas repiten el mismo email con distinto uso de mayúsculas, y un contacto referencia un account_id que no existe porque la cuenta se eliminó en otro proceso. Sin restricciones, todo eso puede llegar a producción y romper informes más tarde.
Con las reglas adecuadas en la base de datos, esas escrituras fallan de inmediato, cerca de la fuente. Los campos obligatorios no pueden faltar, los duplicados no se cuelan durante reintentos, las relaciones no pueden apuntar a registros eliminados o inexistentes, y los valores no pueden estar fuera de los rangos permitidos.
Las restricciones no previenen todos los errores. No arreglarán una interfaz confusa, un cálculo de descuento erróneo o una consulta lenta. Pero sí evitan que los datos malos se acumulen en silencio, que es a menudo donde los “bugs de casos borde generados por IA” se vuelven costosos.
Tu aplicación rara vez es un único código hablando con un único usuario. Un producto típico tiene una interfaz web, una app móvil, pantallas de administración, jobs en segundo plano, importaciones desde CSV y a veces integraciones de terceros. Cada camino puede crear o cambiar datos. Si cada ruta tiene que recordar las mismas reglas, una las olvidará.
La base de datos es el único lugar que comparten todas. Si la tratas como el guardián final, las reglas se aplican a todo automáticamente. Las restricciones de PostgreSQL convierten “suponemos que esto siempre es cierto” en “esto debe ser cierto, o la escritura falla”.
El código generado por IA hace que esto sea aún más importante. Un modelo puede añadir validación en un formulario React pero pasar por alto un caso en un job en segundo plano. O puede manejar bien los datos de la ruta feliz y fallar cuando un cliente real introduce algo inesperado. Las restricciones atrapan los problemas en el momento en que los datos malos intentan entrar, no semanas después cuando depuras informes extraños.
Si omites restricciones, los datos malos suelen ser silenciosos. El guardado tiene éxito, la app sigue y el problema aparece después como un ticket de soporte, un desajuste en facturación o un panel que nadie confía. Limpiar es caro porque arreglas historia, no una sola petición.
Los datos malos suelen colarse por situaciones cotidianas: una nueva versión de cliente manda un campo vacío en vez de omitido, un reintento crea duplicados, una edición de administrador evita comprobaciones de la UI, un archivo de importación tiene formato inconsistente o dos usuarios actualizan registros relacionados al mismo tiempo.
Un modelo mental útil: acepta datos sólo si son válidos en el límite. En la práctica, ese límite debería incluir la base de datos, porque la base de datos ve todas las escrituras.
NOT NULL es la restricción más simple de PostgreSQL, y previene una clase sorprendentemente grande de errores. Si un valor debe existir para que la fila tenga sentido, deja que la base de datos lo haga cumplir.
NOT NULL suele ser apropiado para identificadores, nombres obligatorios y marcas de tiempo. Si no puedes crear un registro válido sin ese valor, no permitas que esté vacío. En un CRM pequeño, un lead sin propietario o sin creado_en no es un “lead parcial”. Son datos rotos que causarán comportamientos extraños más adelante.
NULL se cuela más a menudo con código generado por IA porque es fácil crear caminos “opcionales” sin darse cuenta. Un campo del formulario puede ser opcional en la UI, una API puede aceptar una clave ausente y una rama de una función de creación puede omitir asignar un valor. Todo sigue compilando y la prueba de la ruta feliz pasa. Luego usuarios reales importan un CSV con celdas vacías o un cliente móvil envía una carga distinta, y NULL llega a la base de datos.
Un buen patrón es combinar NOT NULL con un valor por defecto sensato para campos que el sistema controla:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueLos valores por defecto no siempre son una victoria. No pongas por defecto campos proporcionados por el usuario como email o company_name sólo para satisfacer NOT NULL. Una cadena vacía no es “más válida” que NULL. Sólo oculta el problema.
Cuando no estés seguro, decide si el valor es realmente desconocido o si representa un estado diferente. Si “aún no proporcionado” tiene significado, considera una columna de estado separada en vez de permitir NULL en todas partes. Por ejemplo, deja phone nullable, pero añade phone_status como missing, requested o verified. Eso mantiene el significado consistente en tu código.
Un CHECK es una promesa de la tabla: cada fila debe cumplir una regla, siempre. Es una de las formas más sencillas de evitar que casos borde creen filas que parecen válidas en el código pero no tienen sentido en la vida real.
Las restricciones CHECK funcionan mejor para reglas que dependen solo de valores en la misma fila: rangos numéricos, valores permitidos y relaciones simples entre columnas.
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents \u003e= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date \u003e= start_date);
Un buen CHECK es legible de un vistazo. Trátalo como documentación para tus datos. Prefiere expresiones cortas, nombres de restricciones claros y patrones predecibles.
CHECK no es la herramienta adecuada para todo. Si una regla necesita buscar otras filas, agregar datos o comparar entre tablas (por ejemplo, “una cuenta no puede exceder su límite de plan”), mantén esa lógica en el código de la aplicación, triggers o un job controlado en segundo plano.
Una regla UNIQUE es simple: la base de datos se niega a almacenar dos filas que tengan el mismo valor en la columna restringida (o la misma combinación de valores en varias columnas). Esto elimina una clase entera de errores donde una ruta de “crear” se ejecuta dos veces, ocurre un reintento o dos usuarios envían lo mismo al mismo tiempo.
UNIQUE garantiza que no haya duplicados para los valores exactos que definas. No garantiza que el valor esté presente (NOT NULL), que siga un formato (CHECK) o que coincida con tu idea de igualdad (mayúsculas, espacios, puntuación) a menos que lo definas.
Lugares comunes donde suele querer unicidad: el email en la tabla de usuarios, external_id de otro sistema o un nombre que debe ser único dentro de una cuenta como (account_id, name).
Un detalle: NULL y UNIQUE. En PostgreSQL, NULL se trata como “desconocido”, por lo que múltiples NULL están permitidos bajo una restricción UNIQUE. Si quieres “el valor debe existir y ser único”, combina UNIQUE con NOT NULL.
Un patrón práctico para identificadores visibles por el usuario es la unicidad insensible a mayúsculas. La gente escribirá “[email protected]” y luego “[email protected]” y esperará que sean lo mismo.
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
Define qué significa “duplicado” para tus usuarios (mayúsculas, espacios en blanco, por cuenta vs global), y luego codifícalo una vez para que todos los caminos de código sigan la misma regla.
Una FOREIGN KEY dice: “esta fila debe apuntar a una fila real allí”. Sin ella, el código puede crear silenciosamente registros huérfanos que parecen válidos en aislamiento pero rompen la app más tarde. Por ejemplo: una nota que referencia un cliente eliminado o una factura que apunta a un user ID que nunca existió.
Las claves foráneas importan especialmente cuando dos acciones ocurren cerca en el tiempo: un borrado y una creación, un reintento después de un timeout o un job en segundo plano que corre con datos desactualizados. La base de datos es mejor imponiendo consistencia que cada ruta de la app recordando comprobarlo.
La opción ON DELETE debe corresponder con el significado real de la relación. Pregunta: “Si el padre desaparece, ¿el hijo debe seguir existiendo?”
RESTRICT (o NO ACTION): impedir borrar el padre si existen hijos.CASCADE: borrar el padre borra también a los hijos.SET NULL: conservar al hijo pero eliminar el vínculo.Ten cuidado con CASCADE. Puede ser correcto, pero también puede borrar más de lo esperado cuando un bug o una acción administrativa elimina un registro padre.
En apps multi-tenant, las foreign keys no son solo sobre corrección. También evitan fugas entre cuentas. Un patrón común es incluir account_id en cada tabla propiedad del tenant y enlazar relaciones a través de él.
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
Esto hace valer “quién posee qué” en el esquema: una nota no puede apuntar a un contacto de otra cuenta, incluso si el código de la app (o una consulta generada por un LLM) lo intenta.
Empieza escribiendo una lista corta de invariantes: hechos que deben ser siempre verdaderos. Mantenlos simples. “Cada contacto necesita un email.” “Un estado debe ser uno de unos pocos valores permitidos.” “Una factura debe pertenecer a un cliente real.” Esas son las reglas que quieres que la base de datos haga cumplir siempre.
Aplica los cambios en pequeñas migraciones para que producción no se sorprenda:
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).La parte complicada son los datos malos existentes. Planifícalo. Para duplicados, elige una fila ganadora, fusiona el resto y guarda una pequeña nota de auditoría. Para campos requeridos faltantes, elige un valor por defecto sólo si es realmente seguro; de lo contrario, aísla en cuarentena. Para relaciones rotas, reasigna las filas hijas al padre correcto o elimina las filas malas.
Después de cada migración, valida con algunas escrituras que deberían fallar: inserta una fila con un valor requerido faltante, inserta una clave duplicada, inserta un valor fuera de rango e intenta referenciar una fila padre inexistente. Las escrituras fallidas son señales útiles. Te muestran dónde la app estaba confiando silenciosamente en un comportamiento “mejor esfuerzo”.
Imagina un CRM pequeño: cuentas (cada cliente de tu SaaS), empresas con las que trabajan, contactos en esas empresas y deals ligados a una empresa.
Este es exactamente el tipo de app que la gente genera rápido con una herramienta de chat. Se ve bien en demos, pero los datos reales se ensucian rápido. Dos errores suelen aparecer pronto: contactos duplicados (el mismo email ingresado dos veces de forma ligeramente distinta) y deals creados sin company porque una ruta de código olvidó establecer company_id. Otro clásico es un valor de deal negativo tras un refactor o un error de parseo.
La solución no es añadir más ifs. Son unas pocas restricciones bien elegidas que hacen imposible almacenar datos malos.
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value \u003e= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
No se trata de ser estricto por el gusto de serlo. Estás convirtiendo expectativas vagas en reglas que la base de datos puede aplicar siempre, sin importar qué parte de la app escriba datos.
Una vez que estas restricciones están en su lugar, la app se simplifica. Puedes eliminar muchas comprobaciones defensivas que intentan detectar duplicados después del hecho. Los fallos se vuelven claros y accionables (por ejemplo, “el email ya existe para esta cuenta” en lugar de comportamiento extraño aguas abajo). Y cuando una ruta de API generada olvida un campo o maneja mal un valor, la escritura falla de inmediato en vez de corromper silenciosamente la base de datos.
Las restricciones funcionan mejor cuando coinciden con cómo funciona realmente el negocio. La mayor parte del dolor viene de añadir reglas que parecen “seguras” en el momento pero se convierten en sorpresas después.
Un error común es usar ON DELETE CASCADE por todas partes. Parece ordenado hasta que alguien borra un registro padre y la base de datos elimina la mitad del sistema. Los cascades pueden ser correctos para datos verdaderamente propiedad (como partidas de borrador que nunca deberían existir solas), pero son arriesgados para registros que la gente considera importantes (clientes, facturas, tickets). Si no estás seguro, prefiere RESTRICT y maneja los borrados intencionadamente.
Otro problema es escribir CHECK demasiado estrechos. “Status debe ser ‘new’, ‘won’ o ‘lost’” suena bien hasta que necesitas “paused” o “archived”. Un buen CHECK describe una verdad estable, no una elección temporal de la UI. “amount \u003e= 0” envejece bien. “country in (...)” a menudo no.
Algunos problemas que aparecen repetidamente cuando los equipos añaden restricciones después de que el código generado ya está en producción:
CASCADE como herramienta de limpieza y luego borrar más datos de los esperados.Sobre rendimiento: PostgreSQL crea un índice automáticamente para UNIQUE, pero las foreign keys no indexan automáticamente la columna que referencia. Sin ese índice, las actualizaciones y borrados en el padre pueden hacerse lentos porque Postgres tiene que escanear la tabla hija para comprobar referencias.
Antes de endurecer una regla, encuentra las filas existentes que fallarían, decide si arreglarlas o ponerlas en cuarentena y despliega el cambio en pasos.
Antes de lanzar, tómate cinco minutos por tabla y escribe qué debe ser siempre verdadero. Si puedes decirlo en inglés sencillo, normalmente puedes aplicarlo con una restricción.
Pregunta esto por cada tabla:
Si estás usando una herramienta de build basada en chat, trata esos invariantes como criterios de aceptación para los datos, no como notas opcionales. Por ejemplo: “El monto de un deal debe ser no negativo”, “El email de un contacto es único por workspace”, “Una tarea debe referenciar un contacto real”. Cuanto más explícitas sean las reglas, menos margen hay para casos límite accidentales.
Koder.ai (koder.ai) incluye funciones como modo de planificación, snapshots y rollback, y exportación de código fuente, que pueden facilitar iterar sobre cambios de esquema con seguridad mientras ajustas restricciones con el tiempo.
Un patrón de despliegue simple que funciona en equipos reales: elige una tabla de alto valor (usuarios, órdenes, facturas, contactos), añade 1-2 restricciones que eviten los peores fallos (a menudo NOT NULL y UNIQUE), arregla las escrituras que fallan y repite. Endurecer las reglas con el tiempo vence a una única migración grande y arriesgada.