Vengo de PostgreSQL, así que la primera vez que vi un diseño single-table de DynamoDB —usuarios, pedidos y productos conviviendo en la misma tabla, con claves como USER#123 y ORDER#456— pensé que alguien había perdido la cabeza. Tardé en entenderlo porque estaba haciendo la pregunta equivocada: en un modelo relacional diseñas por entidades, y en DynamoDB diseñas por patrones de acceso. Cuando ese chip hace clic, el single-table deja de ser una aberración y se convierte en la forma natural de sacarle partido a la base. Este artículo es cómo hice ese cambio mental y dónde están las trampas.
Por qué DynamoDB no es Postgres con otra piel
En Postgres normalizo, creo tablas por entidad y dejo que el planificador resuelva los joins en tiempo de consulta. Es flexible: si mañana necesito una consulta nueva, la escribo y el motor se las arregla, quizá con un índice extra. Esa flexibilidad la pago en que el rendimiento depende del plan que elija el motor.
DynamoDB invierte el trato. No hay joins ni planificador: cada acceso es una búsqueda por clave, y si no diseñaste la clave para esa consulta, esa consulta o es imposible o te obliga a un Scan que recorre toda la tabla. A cambio, un acceso bien diseñado tiene latencia predecible sin importar cuántos datos haya. La consecuencia es brutal: en DynamoDB no puedes empezar por el modelo de datos, tienes que empezar por la lista de consultas que tu producto va a hacer.
Empieza por los access patterns, siempre
Antes de tocar una tabla, escribo la lista de accesos en lenguaje llano. Para un producto de pedidos sería algo así: dame un usuario por su id; dame todos los pedidos de un usuario; dame un pedido con sus líneas; dame los pedidos de un usuario ordenados por fecha. Esa lista no es documentación, es el diseño. Cada patrón tiene que corresponder a un Query de partition key, no a un Scan.
Este paso es el que la gente que viene de SQL se salta, y es exactamente el que no puedes saltarte. Si más adelante aparece un patrón que no previste, en Postgres es una consulta nueva; en DynamoDB puede ser un rediseño de claves o un índice secundario global. El coste de equivocarse aquí es alto, así que aquí es donde pongo el pensamiento.
Claves genéricas: PK y SK que no significan una sola cosa
El truco del single-table es que la partition key y la sort key no se llaman user_id ni order_id. Se llaman PK y SK a secas, y su contenido cambia según la entidad. Un usuario vive en PK = USER#123, SK = PROFILE. Sus pedidos viven en la misma partición, PK = USER#123, SK = ORDER#456. Así, pedir un usuario y todos sus pedidos es un único Query sobre PK = USER#123: la partición ya trae el perfil y los pedidos juntos, sin join.
// Todos los ítems de un usuario (perfil + pedidos) en una sola query
const res = await ddb.query({
TableName: 'app',
KeyConditionExpression: 'PK = :pk',
ExpressionAttributeValues: { ':pk': 'USER#123' },
});
// Solo los pedidos: acoto por prefijo de la sort key
const orders = await ddb.query({
TableName: 'app',
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
ExpressionAttributeValues: { ':pk': 'USER#123', ':prefix': 'ORDER#' },
});
El begins_with sobre la sort key es la palanca: al prefijar los tipos (ORDER#, PROFILE, ADDRESS#) puedo traer justo el subconjunto que quiero de una partición, ordenado, en una sola llamada.
💡 En DynamoDB la sort key no ordena datos, diseña consultas. El prefijo que le pones es lo que decide qué preguntas podrás hacer barato.
GSIs: cuando necesitas mirar los datos por otro eje
Los patrones que no encajan en la clave principal se resuelven con índices secundarios globales. Un GSI es, en esencia, una proyección de la tabla con otra PK y otra SK, mantenida por DynamoDB de forma asíncrona. Si necesito "todos los pedidos con estado PENDING ordenados por fecha", creo un GSI cuya PK sea el estado y cuya SK sea la fecha, y ese acceso vuelve a ser un Query limpio.
El patrón que uso es la sobrecarga de índice: los mismos atributos genéricos GSI1PK y GSI1SK significan cosas distintas según la entidad, igual que la clave principal. Con un par de GSIs bien pensados cubro casi cualquier producto. Lo que no hago es crear un GSI por capricho: cada índice cuesta escritura y almacenamiento, porque cada put en la tabla se replica en los índices que apliquen.
Los trade-offs que nadie te cuenta al principio
El single-table es potente, pero es honesto reconocer lo que cuesta. Lo primero es la curva mental: modelar así es incómodo hasta que interiorizas pensar en accesos, y un equipo nuevo tarda en leer una tabla donde todo se llama PK y SK. Lo segundo es la rigidez: si aparece un patrón de acceso realmente imprevisto, adaptarte cuesta más que en SQL, donde una consulta ad-hoc siempre es posible aunque sea lenta.
Por eso no uso single-table para todo. Cuando el producto tiene consultas exploratorias, informes cambiantes o relaciones que no sé anticipar, Postgres me da una flexibilidad que DynamoDB me cobraría carísima. Elijo DynamoDB cuando los patrones de acceso son conocidos, acotados y necesito latencia predecible a escala sin operar un motor relacional. Ahí el single-table brilla: menos piezas, latencia plana y una factura que escala con el uso real y no con el tamaño de la tabla. Como todo en AWS, no es la mejor herramienta, es la mejor herramienta para un problema con la forma correcta.