El modo de fallo más constante de los sistemas construidos con IA y vibecoding es el esquema. Parece razonable para tres casos de uso y se vuelve inservible en cuanto llega el cuarto. Todo lo demás — la autenticación, los despliegues, el front-end — se puede parchear en sitio. El esquema no. Es la parte del sistema a la que todo lo demás está anclado, y es la parte que el modelo tiene menos incentivo para hacer bien.
Reconstruimos el mismo puñado de patrones cada vez. Ninguno es ingenioso. Todos son aburridos, finitos, y el trabajo de mayor apalancamiento en cualquier compromiso previo al lanzamiento. Este post es el catálogo.
Claves foráneas, no "ya haremos el join en código"
El prototipo se libra de no tener claves foráneas porque tiene un solo desarrollador, una sola base de datos, y un número pequeño de filas insertadas en un orden conocido. La integridad referencial la garantiza el hecho de que el mismo humano escribió los inserts y los reads en la misma tarde. Nada ha tenido tiempo de derivar.
Producción no tiene ese lujo. Producción tiene un worker que reintenta, un background job que se ejecuta fuera de orden, un despliegue que hace rollback a medias, una herramienta de soporte que hace un UPDATE que nadie había pensado. Sin claves foráneas la base de datos terminará, antes o después, conteniendo una fila que referencia un padre que ya no existe, o que nunca existió. El bug aparece tres semanas después como un 500 en una página que nadie mantiene, y cuesta un día localizarlo porque los datos en disco te están mintiendo sobre lo que es posible.
Una clave foránea es una línea de DDL. También es un contrato que la base de datos impone a cada escritor, incluyendo los que aún no has construido. Las añadimos en todas partes, incluso donde el modelo nos había asegurado que no harían falta.
Restricciones reales, no vibes
NOT NULL, CHECK, UNIQUE. Estas tres restricciones cargan con la mayor parte del trabajo de mantener una base de datos de producción honesta, y son las que los esquemas generados por IA tratan como decoración opcional.
El patrón que vemos con más frecuencia: una columna que "siempre se rellena en la práctica" pero está como nullable en el esquema, porque el script de seed del prototipo daba la casualidad de rellenarla. Seis meses después hay filas donde está nula, porque un code path que nadie recordaba no la rellenó, y ahora cada query tiene que manejar defensivamente el caso null. Multiplica eso por todo el esquema y tienes una base de código donde cada lectura va envuelta en condicionales que existen porque a la base de datos no se le pidió hacer su trabajo.
Peor que no tener restricciones son las restricciones vibe-coded. Un CHECK que permite tres valores cuando la aplicación solo produce dos, "por si acaso lo necesitamos luego". Un UNIQUE sobre el par de columnas equivocado. Un NOT NULL en una columna que la aplicación rellena con string vacío cuando no tiene un valor real. Parecen disciplina y no lo son. Le dan al siguiente ingeniero falsa confianza sobre lo que hay en la tabla.
Escribimos restricciones que coinciden con lo que la aplicación realmente hace, y las escribimos apretadas. Si la aplicación produce dos valores, el CHECK permite dos valores. Si una columna es obligatoria, va NOT NULL y se cambia la aplicación para proveer un valor, no al revés.
Índices diseñados para las queries que realmente ejecutas
Los modelos adivinan los índices. No son malas adivinanzas — indexarán las claves foráneas, indexarán las columnas de búsqueda obvias, a veces añadirán un compuesto que parece plausible. También son, casi siempre, no los índices que la aplicación termina necesitando.
El coste de adivinar mal tiene dos mitades. La primera son índices que existen y no se usan: peso muerto en cada escritura, páginas muertas en caché, bytes muertos en los backups. La segunda son queries que deberían usar un índice y no lo hacen, porque el índice se construyó con el orden de columnas equivocado o el predicado equivocado, y el planner silenciosamente hace un sequential scan. La aplicación va lenta y nadie sabe por qué.
Diseñamos los índices contra los patrones de query reales una vez los tenemos. Eso significa mirar pg_stat_statements, leer EXPLAIN ANALYZE en las queries que importan, y borrar los índices que tienen cero scans después de una semana de tráfico real. Los índices parciales, cuando la carga está sesgada, valen a menudo diez veces lo que cuesta uno completo. Nada de esto es exótico; todo requiere sentarse con la base de datos una tarde.
Migraciones que corren hacia adelante y hacia atrás
La mayoría de los intentos de rollback fallan. La razón es casi siempre la misma: la migración hacia adelante cambió datos, y la migración hacia atrás solo sabe deshacer el cambio de esquema, no el cambio de datos.
Una migración que añade una columna con default, rellena las filas existentes, y luego borra la columna vieja son tres operaciones. El camino hacia adelante las ejecuta en orden. El camino hacia atrás tiene que recrear la columna vieja, copiar los datos de vuelta (posiblemente transformados), y borrar la nueva. Si los datos perdieron información en cualquier dirección — un string truncado, un enum colapsado, un timestamp forzado — el rollback no puede restaurarlos. El equipo se entera en el peor momento posible.
Escribimos migraciones down que funcionan de verdad, y las probamos contra una copia de los datos de producción antes de fiarnos de ellas. A menudo la respuesta correcta es hacer la migración hacia adelante no destructiva: añadir la forma nueva, correr las dos formas durante una release, conmutar las lecturas, y luego borrar la forma vieja en una migración posterior. Aburrido, lento, y recuperable. Lo contrario de ingenioso.
JSONB es una herramienta, no una victoria
Las columnas JSONB son el lugar donde los esquemas generados por IA esconden las decisiones que no querían tomar. "Ya definiremos la estructura luego" se convierte en una columna data jsonb que acumula campos que nadie documentó, tipos que nadie hizo cumplir, y patrones de acceso que nadie indexó.
JSONB es genuinamente útil. Es la respuesta correcta para formas dispersas, evolutivas, específicas de cada tenant, donde promover cada campo a una columna real sería ridículo. Es la respuesta equivocada para la entidad central del sistema, donde los campos se conocen, las queries se conocen, y la única razón por la que es JSONB es que el modelo no quiso comprometerse.
Nuestra regla: cada columna JSONB recibe un inventario honesto de lo que hay dentro, una forma esperada documentada, y un plan de qué campos se van a extraer a columnas reales una vez que sus patrones de acceso se estabilicen. Añadimos CHECK con jsonb_typeof donde la forma es estable. Añadimos índices de expresión donde una ruta específica se consulta con frecuencia. Y revisamos la columna cada trimestre para ver qué debería ser ya una columna real con una restricción real.
Lo que cuesta y lo que vale
Hacer bien el esquema antes del lanzamiento es aproximadamente una semana de ingeniería en un sistema de tamaño moderado. Es trabajo aburrido. No hay nada que enseñar en demo al final. Las capturas de pantalla se ven igual que antes.
Hacerlo mal son meses de dolor incremental después de que lleguen los primeros cien clientes. Es la página de on-call a las 2 de la madrugada porque un NULL se coló en una columna que "no podía" tenerlo. Es la migración que tira la base de datos cuarenta minutos porque nadie añadió un índice concurrente. Es el ticket de soporte que no se puede resolver porque dos filas referencian un tenant que se borró en marzo y la FK no estaba ahí para impedirlo.
El esquema es la parte del sistema que no puedes refactorizar sin afectar todas las demás. Añadir un índice es barato. Cambiar el tipo de una columna en una tabla viva con cien millones de filas es un proyecto. Partir un blob JSONB en columnas normalizadas una vez la mitad de la aplicación lee de él es un proyecto que dura un trimestre.
La disciplina de hacerlo bien antes del lanzamiento es aburrida, finita, y la semana de mayor apalancamiento en cualquier compromiso previo al lanzamiento. No disfrutamos este trabajo más que cualquier otro. Simplemente sabemos lo que cuesta no hacerlo.


