El problema
Vienes de SQL y modelas como te enseñaron: una tabla clientes, una pedidos,
una direcciones, una lineas_pedido. En DynamoDB creas cuatro tablas y sigues
adelante.
Entonces llega la pantalla de “detalle del cliente”, que necesita el perfil, sus
últimos pedidos y sus direcciones. En SQL eso era un JOIN. Aquí son tres
consultas, y como DynamoDB no tiene joins, las haces desde la Lambda:
const perfil = await ddb.get({ TableName: 'clientes', Key: { id } });
const pedidos = await ddb.query({ TableName: 'pedidos', ... });
const direcciones = await ddb.query({ TableName: 'direcciones', ... });
Cada una es rápida —cinco milisegundos— pero has convertido una pantalla en tres viajes de red que se suman, y en tres cosas que pueden fallar por separado. Cuando la pantalla crezca a cinco entidades, serán cinco.
Y hay un problema peor que la latencia: no puedes leerlas de forma
consistente. Entre tu primera consulta y la tercera, otro proceso pudo cambiar
los datos. En SQL el JOIN te daba una foto coherente; aquí tienes tres fotos
tomadas en tres momentos.
La solución
Una sola tabla, con las claves primarias renombradas a algo deliberadamente
genérico —PK y SK— y los distintos tipos de entidad conviviendo dentro,
distinguidos por un prefijo en la clave.
| PK | SK | Tipo | Atributos |
|---|---|---|---|
| CLIENTE#42 | PERFIL | Perfil | nombre, email, alta |
| CLIENTE#42 | DIRECCION#casa | Dirección | calle, ciudad, cp |
| CLIENTE#42 | PEDIDO#2026-09-01#1001 | Pedido | total, estado |
| CLIENTE#42 | PEDIDO#2026-09-03#1002 | Pedido | total, estado |
| PEDIDO#1001 | LINEA#1 | Línea | sku, cantidad, precio |
| PEDIDO#1001 | LINEA#2 | Línea | sku, cantidad, precio |
PK = CLIENTE#42 devuelve las cuatro filas resaltadas: el perfil, los dos pedidos y la dirección. Un viaje de red, una foto coherente. Las líneas del pedido viven bajo otra clave porque nadie las pide a la vez que el perfil.Tres ideas hacen que esto funcione:
La colección de elementos. Todos los elementos que comparten PK se guardan
físicamente juntos y ordenados por SK. Una Query sobre PK = CLIENTE#42 los
devuelve todos de una vez, en un solo viaje. Eso es el sustituto del JOIN.
El prefijo en la clave de ordenación. Como el SK se ordena
lexicográficamente, PERFIL, DIRECCION#… y PEDIDO#… quedan agrupados por
tipo. Y como los pedidos llevan la fecha antes del id, salen ordenados por
fecha sin hacer nada: SK begins_with "PEDIDO#2026-09" te da los de septiembre.
El GSI sobrecargado. Un índice con claves igual de genéricas —GSI1PK,
GSI1SK— sirve para varios patrones de acceso a la vez, porque cada tipo de
entidad mete ahí lo que necesita. Un solo GSI puede resolver “pedidos por
estado” y “clientes por email” según qué haya escrito cada uno.
Cuándo usarlo
- La aplicación tiene patrones de acceso conocidos y estables, y una pantalla necesita varias entidades relacionadas a la vez.
- Las relaciones son jerárquicas: cliente y sus pedidos, pedido y sus líneas, tenant y sus recursos.
- Te importan la latencia y el número de viajes más que la comodidad de escribir consultas nuevas.
Cuándo NO usarlo
Cómo implementarlo
- Escribe primero la lista de patrones de acceso. Literalmente, en una tabla: “dado un cliente, traer su perfil y sus últimos 20 pedidos”. Si la lista está vacía, para aquí.
- Agrupa por lo que se lee junto. Lo que una pantalla pide a la vez quiere
compartir
PK. - Diseña el
SKpara que ordene. Prefijo de tipo, y dentro, lo que quieras que salga en orden: fecha antes que id, casi siempre. - Añade GSI sobrecargados solo para los patrones que la tabla base no cubre. Recuerda el techo: 20 GSI por tabla y 100 atributos proyectados sumando todos los índices.
- Proyecta lo justo. Cada GSI es una copia con su propio almacenamiento y su propia capacidad de escritura.
- Documenta el modelo. Una tabla de patrones de acceso a claves, en el repositorio. Sin ella, el siguiente que llegue hará un Scan.
El código
// Un unico Query devuelve el perfil, las direcciones y los pedidos.
const { Items } = await ddb.send(new QueryCommand({
TableName: 'app',
KeyConditionExpression: 'PK = :pk',
ExpressionAttributeValues: { ':pk': `CLIENTE#${id}` },
}));
// El tipo se deduce del prefijo del SK: la tabla es generica,
// el codigo que la lee no tiene por que serlo.
const perfil = Items.find((i) => i.SK === 'PERFIL');
const direcciones = Items.filter((i) => i.SK.startsWith('DIRECCION#'));
const pedidos = Items.filter((i) => i.SK.startsWith('PEDIDO#'));
// Solo los pedidos de septiembre, ya ordenados por fecha porque
// la fecha va delante del id en el SK.
const septiembre = await ddb.send(new QueryCommand({
TableName: 'app',
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :mes)',
ExpressionAttributeValues: {
':pk': `CLIENTE#${id}`,
':mes': 'PEDIDO#2026-09',
},
ScanIndexForward: false, // los mas recientes primero
}));
Te va a morder
Coste
Single-table no es más barato por ser una tabla: DynamoDB factura por petición y por almacenamiento, no por número de tablas. Lo que ahorra es otra cosa:
| Concepto | Efecto |
|---|---|
| Consultas por pantalla | de N a 1 — menos peticiones y menos latencia |
| Almacenamiento | igual: los mismos datos ocupan lo mismo |
| GSI | cada uno es una copia con su propia capacidad de escritura |
| Complejidad del equipo | sube, y no aparece en la factura |
El ahorro real está en el número de peticiones y en los viajes de red. Si tu pantalla hacía cinco consultas y ahora hace una, has dividido por cinco tanto el coste de lectura como la latencia acumulada.
Lo que sube es el coste humano. Es un intercambio legítimo cuando el volumen lo justifica, y un mal negocio cuando se hace por seguir una buena práctica que alguien leyó en un blog.
Fuentes
Tamaño máximo de elemento, límites de operaciones por lote y reparto de capacidad entre tabla e índices: Working with items and attributes in DynamoDB. Throughput por partición y el ejemplo del elemento de 20 KB: Best practices for designing and using partition keys. Cuotas de GSI, LSI y atributos proyectados, y la irreversibilidad de los LSI: Quotas in Amazon DynamoDB. Comportamiento del borrado por TTL: Using time to live (TTL) in DynamoDB.