erDiagram
USERS ||--o{ TASKS : "owns"
TASKS }o--o{ TAGS : "labeled with"
USERS {
bigint id PK
text email
text password_hash
timestamptz created_at
}
TASKS {
bigint id PK
bigint user_id FK
text title
int priority
timestamptz due_date
boolean done
timestamptz created_at
}
TAGS {
bigint id PK
text name
}
TASK_TAGS {
bigint task_id FK
bigint tag_id FK
}
17 8. Necesitamos que los datos sobrevivan al reinicio
La memoria no es un plan

El array en memoria de la Parte 1 muere cada vez que reinicias el servidor. Ahora Taskflow necesita una base de datos de verdad — y antes de escribir una línea de código, hay que diseñarla: qué tablas, qué columnas, qué tipos, cómo se relacionan. La mitad de este capítulo no es código: es pensar.
17.1 El problema
let tasks = [] funcionó perfecto… hasta que el proceso reinició y las 200 tareas del cliente desaparecieron. La memoria RAM es volátil por diseño — es el escritorio donde trabajas, no el archivero donde guardas. Necesitamos algo que sobreviva: a reinicios, a crashes, a deploys.
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Un proceso es un programa en ejecución: el sistema operativo le da memoria propia y un lugar en la fila del CPU. Tu API es un proceso; Postgres es otro; el navegador es varios. Que “se caiga el proceso” = el programa murió.
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
¿Por qué no un archivo JSON en disco? Porque la siguiente pregunta llega inmediata: ¿cómo busco “las tareas pendientes del usuario 42” sin leer todo el archivo? ¿Qué pasa si dos requests escriben a la vez? Eso es exactamente lo que una base de datos resuelve desde 1970: guardar datos estructurados, consultarlos rápido, y sobrevivir a la concurrencia.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
17.2 Cómo lo resuelve un equipo
Un equipo serio diseña antes de crear. La conversación real suena así: “¿qué cosas existen en Taskflow?” → usuarios, tareas, etiquetas. “¿Cómo se relacionan?” → un usuario tiene muchas tareas; una tarea puede tener varias etiquetas. “¿Qué no puede ser nulo?” → toda tarea tiene título. Esa conversación se dibuja en un diagrama ER (entidad- relación: cajas y líneas, nada más) y después se traduce a SQL.
Elegimos PostgreSQL por razones concretas, no por moda: es relacional (los datos de Taskflow son relaciones — tareas de usuarios), open source, el estándar de facto, y sus JSONB te dan lo flexible cuando lo necesites. “MongoDB porque mi dato ya es JSON” es el atajo que revisamos en Otras cimentaciones.
17.3 Conceptos nuevos
- D Base de datos relacional: tablas (entidades), filas (registros), columnas (atributos) — y relaciones entre ellas
- D Tipos de columna,
NOT NULL, valores por defecto — la BD como guardiana de tus invariantes - D Clave primaria:
SERIALvsUUID— y por qué exponer el id interno en la URL es una decisión, no un default - D Clave foránea +
ON DELETE CASCADEvsSET NULL— qué le pasa a los hijos cuando muere el padre - D Diagrama ER: dibujar el modelo antes de crearlo
- D Relacional vs las 4 familias NoSQL — y la regla “empieza relacional”
- D Local vs producción: mismo motor, distinta operación
- U Capítulo agnóstico: SQL es el mismo en los tres stacks
17.4 La explicación visual
El modelo de Taskflow dibujado — esto es el diagrama ER:
Cómo se lee: USERS ||--o{ TASKS = un usuario tiene cero o muchas tareas. TASKS }o--o{ TAGS = muchos a muchos — y como una tabla no puede tener “listas” dentro, esa relación vive en task_tags, la tabla puente. Toda decisión del dibujo se convierte en una línea de SQL.
Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.
17.5 Implementación
El diagrama traducido a CREATE TABLE — SQL puro, idéntico para los tres stacks (los drivers del cap. 10 ejecutarán esto mismo):
-- 001_initial.sql — the ER diagram as DDL
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY, -- auto-increment internal id
public_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
public_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
priority INT NOT NULL DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
due_date TIMESTAMPTZ,
done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
-- the many-to-many bridge: no data of its own, only pairs
CREATE TABLE task_tags (
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id) -- the pair IS the key
);Lee esto como invariantes escritos en piedra: la validación del cap. 4 protege la API; estos NOT NULL/UNIQUE/CHECK/REFERENCES protegen el dato — la última frontera, la que no puedes saltarte ni con un bug en el handler ni con un script manual.
Tres decisiones que merecen una pausa:
idinterno (BIGSERIAL) vspublic_id(UUID). El id interno es para los JOINs (rápido, ordenado); el UUID es el que viaja en la URL/tasks/7f3a.... Exponerid=42le dice al mundo “hay ~42 tareas” e invita a probarid=41— el UUID no revela ni permite adivinar.ON DELETE CASCADE: borrar un usuario borra sus tareas — elegido, no default. La alternativaSET NULLdejaría tareas huérfanas.task_tagscon PK compuesta: el par(task_id, tag_id)es la clave — no puede repetirse, y no necesita id propio.
Un JOIN combina tablas por su relación: “cada tarea con el nombre de su proyecto”. Es la superpotencia relacional — en una query traes lo que en NoSQL serían varios viajes.
Diseña el esquema PostgreSQL para mi app de tareas. Entidades: users,
tasks (title, priority 1-5, due_date, done), tags (many-to-many con
tasks). Requisitos: id interno BIGSERIAL + public_id UUID (el UUID es
el que se expone en la API), constraints NOT NULL/UNIQUE/CHECK donde
correspondan, FKs con ON DELETE pensado y justificado, y una tabla
puente para la relación N:M. Dame el SQL completo más un diagrama ER
en texto, y explícame cada decisión de diseño.
17.6 ¿Relacional o no relacional? — y local ≠ producción
Lo único que necesitas llevarte: existen otras familias de bases de datos — documento, clave-valor, columna ancha, grafo — y cada una existe porque alguna forma de dato le queda mejor que las tablas. Pero el 90% de los productos (Taskflow y Nexus incluidos) son relaciones disfrazadas: empieza relacional, agrega la especializada cuando una necesidad real lo pida. Y la BD de tu laptop y la de producción son el mismo motor en ligas distintas: mismo SQL, distinta operación.
| Familia | Ejemplo | El dato se guarda como | Brilla cuando | Qué te cuesta |
|---|---|---|---|---|
| Documento | MongoDB, Firestore | Un JSON por registro; cada uno puede tener campos distintos | Registros con forma variable de verdad: catálogos, contenido, perfiles raros | Las relaciones las enforzas tú en código; no hay REFERENCES que proteja |
| Clave-valor | Redis, DynamoDB | Una llave → un valor, sin queries ricas | Leer/escribir por llave a velocidad extrema: caché, sesiones, rate limits | No puedes preguntar “los pendientes del usuario 42” — solo GET por llave |
| Columna ancha | Cassandra, Bigtable | Filas con columnas que pueden variar por fila | Escritura masiva continua: métricas, logs, IoT, series de tiempo | Diseñas por query, no por entidad — curva alta, nicho real |
| Grafo | Neo4j | Nodos + aristas: la relación es el dato | “Amigos de amigos”, rutas, fraude, recomendación | Si tu dato no es una red, es overkill puro |
Las señales de decisión — la pregunta que responde “¿cuál uso?”:
- ¿Los datos se relacionan y esas relaciones deben cumplirse siempre? → relacional.
REFERENCESlo garantiza; en Mongo lo garantizas tú (o no, y ahí nacen los datos huérfanos). - ¿Cada registro puede tener campos distintos e impredecibles? → documento… o
JSONBdentro de Postgres, que es la respuesta que termina ganando el 80% de las veces. - ¿Solo lees/escribes por una llave, a escala brutal? → clave-valor, casi siempre como complemento (Redis delante de Postgres, no en vez).
- ¿Millones de escrituras continuas tipo log/métricas? → columna ancha.
- ¿La pregunta del negocio es sobre la red (quién conoce a quién)? → grafo.
La trampa clásica: “MongoDB porque mi dato ya es JSON”. Eso confunde el formato (JSON es el sobre) con la estructura (tus datos son relaciones: tareas de usuarios). Taskflow responde JSON en cada endpoint y aun así su base es relacional pura.
| Alternativa relacional | Qué es | Cuándo elegirla |
|---|---|---|
| PostgreSQL | El estándar open source + JSONB | El default para casi todo producto |
| SQLite | Relacional en un archivo, sin servidor | Apps locales, embebidas, prototipos, tests |
| MySQL | Relacional igual de capaz | Equipo que ya lo usa; hosting legacy |
17.6.1 Tu laptop vs producción: mismo motor, dos ligas
El Postgres que levantas con docker compose up y el RDS de AWS (o Cloud SQL de GCP, o Azure SQL) hablan exactamente el mismo SQL — tus queries, migraciones y drivers no cambian ni una línea. Lo que cambia es
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
Una migración es un cambio versionado del esquema: un archivo que dice “crea esta columna” (up) y “bórrala” (down). Son el historial Git de la estructura de la BD — se aplican en orden y no se editan una vez aplicadas.
todo lo que rodea al motor:
| Local (Docker) | Producción (administrada) | |
|---|---|---|
| Datos | Descartables — borrar y re-sembrar es lo normal | Irremplazables — backups automáticos, point-in-time recovery |
| Si muere | Reinicias el contenedor | Réplica en otra zona, failover automático |
| Credenciales | postgres:postgres en el compose |
Secret manager, usuarios con permisos mínimos, TLS obligatorio |
| Red | localhost:5432, todo abierto |
Puerto visible solo desde tu app (VPC / security group) |
| Conexiones | 10 bastan | Límite real → pooler (PgBouncer) cuando escala |
| El admin | Tú | El proveedor parchea y hace backup; tú configuras |
La regla de oro: mismo motor en ambos entornos. Usar SQLite en local “y ya Postgres en prod” es cómo se fabrican los bugs que solo existen en producción — tipos distintos, SQL distinto, NULL y constraints que se comportan distinto. Por eso el compose del cap. 31 levanta Postgres real aunque pese más: dev debe parecerse a prod en lo que importa (mismo motor, mismas migraciones); puede diferir en lo demás (sin réplicas, sin backups — esos son problemas de operación, no de desarrollo).
17.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| Diseñar “sobre la marcha” | Crear tablas cuando ya hace falta el endpoint | Dibujo ER primero; SQL después |
Exponer id secuencial en URLs |
Es lo que el ORM da por defecto | public_id UUID para lo externo |
ON DELETE por accidente |
El framework eligió CASCADE sin que lo decidieras | Decidirlo por relación, escribirlo explícito |
| Validar solo en la API | “Ya lo valida el schema de Zod/Pydantic” | Constraints en la BD — la API es una entrada de muchas |
Tablas sin created_at |
“No lo necesito ahora” | Barato de añadir hoy, imposible de reconstruir mañana |
17.8 Buenas prácticas
- Dibuja el ER antes del primer
CREATE TABLE— cambiar una línea en el dibujo cuesta segundos; cambiar una tabla con datos cuesta una migración. - La BD es la última guardiana: todo lo que siempre debe ser cierto (email único, prioridad 1–5, tarea con dueño) merece un constraint, no solo validación en el handler.
idinterno para JOINs,public_idpara el mundo — separa la identidad interna de la expuesta.- Nombres consistentes: tablas en plural y minúscula (
tasks), snake_case en columnas (due_date), PK llamadaid, FK llamadatabla_id(user_id). La convención evita mil decisiones pequeñas.
Una foreign key es un puntero verificado: tasks.project_id apunta a projects.id y la BD garantiza que el proyecto existe. Es la diferencia entre “el dato dice 5” y “el dato apunta a algo real”.
EXTRA Lo que los roadmaps listan como bases que debes reconocer —
Relacionales: MySQL (la más popular históricamente), PostgreSQL, MariaDB, MS SQL Server, Oracle.
NoSQL: MongoDB, CouchDB, DynamoDB (RethinkDB quedó en el camino). Muchas startups optan por NoSQL — pero la decisión es por forma del dato, no por moda (ver la sección anterior de este capítulo).
El libro elige PostgreSQL: relacional completo, JSONB cuando el dato varía, y el mismo motor en local y producción.
17.9 Ejercicio
- Dibuja (en papel o texto) el ER si Taskflow agrega comentarios en las tareas: un usuario comenta en una tarea.
- Escribe el
CREATE TABLE commentscorrespondiente con sus FKs y decide elON DELETE. - ¿Por qué
task_tagsno necesita una columnaidpropia? - Un compañero propone: “pasemos a MongoDB, total la API ya responde JSON”. ¿Qué le respondes?
- Tu app corre contra Postgres en Docker en tu laptop y contra RDS en producción. Nombra dos cosas que son idénticas entre ambos y dos que cambian por completo.
USERS ||--o{ COMMENTSyTASKS ||--o{ COMMENTS— un comentario pertenece a un usuario y a una tarea (dos FKs).- CASCADE en ambos: un comentario sin tarea o sin autor no tiene sentido. (Alternativa defendible:
CREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, public_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000), created_at TIMESTAMPTZ NOT NULL DEFAULT now() );ON DELETE SET NULLenuser_idsi quisieras conservar comentarios de usuarios borrados — entonces la columna sería nullable. Lo importante es que sea decisión.) - Porque su PK compuesta
(task_id, tag_id)ya es única y es toda la identidad que necesita: la fila es el vínculo. Unidextra sería una columna sin trabajo. - “JSON es el formato de la respuesta, no la estructura de los datos. Nuestras tareas son
deusuarios yconetiquetas — esas relaciones las enforzaREFERENCESgratis en Postgres; en Mongo las enforzaríamos nosotros en código (o no, y ahí nacen los huérfanos). Si algún día necesitamos forma variable,JSONBya está incluido.” — confundir formato con estructura es la trampa clásica. - Idénticas: el motor (mismo SQL, mismos tipos, mismas migraciones) y tu código (driver, pool, queries — ni una línea cambia). Cambian: la operación (tú admin vs backups/réplicas/failover del proveedor) y el entorno (credenciales
postgres:postgresvs secret manager + TLS + puerto cerrado salvo a tu app).
17.10 Mini reto
Decidir qué pasa con las tareas cuando se borra su usuario — y defenderlo.
Con ON DELETE CASCADE (lo elegido): el usuario se va con sus tareas — correcto para datos personales (y GDPR-friendly: “borrar mi cuenta” borra mis datos). Alternativas: SET NULL solo si las tareas tienen valor histórico sin dueño (requeriría user_id nullable), o soft-delete (deleted_at) si la regulación exige retener. Para Taskflow, CASCADE es la defensiva: dato personal huérfano es peor que dato borrado.
17.11 Vocabulario técnico del capítulo
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un booleano es un valor de dos estados: true o false. Es el resultado de toda comparación (age > 18) y lo que los if evalúan. Nombrado por George Boole, el matemático de la lógica.
Un pool es el staff de conexiones a la BD: abrir una conexión por request es caro, así que el pool mantiene ~10–20 abiertas y las presta. El request la usa, la devuelve, y la siguiente la reutiliza.
Un ORM (Object-Relational Mapper) traduce entre objetos del código y filas de la tabla: task.save() en vez de INSERT. Cómodo para el CRUD, peligroso si no sabes qué SQL genera (el N+1 nace ahí).
La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.
Una sesión es la conversación continuada entre tú y el servidor: HTTP no recuerda nada entre requests, así que la sesión (vía cookie o token) es el “pulso de mano” que te identifica en cada llamada.
Un entorno es una instancia completa donde corre tu app con su propia config y datos: local (tu máquina), staging (réplica de prueba), producción (la real). Cada entorno tiene sus propias llaves y su propia base de datos.
Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.
Un puerto es una puerta numerada dentro de una computadora: la IP llega al edificio, el puerto al departamento. :3000 = “la app que escucha en el departamento 3000”. Postgres suele usar 5432, HTTP el 80, HTTPS el 443.
localhost significa “esta misma máquina” — es la dirección que tu computadora usa para hablarse a sí misma. Cuando desarrollas, el “servidor” y el “cliente” viven en tu laptop: por eso todo es localhost:3000.
El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.