DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

DynamoDB Single-Table Design pensando desde el frontend

Single-Table Design es el patrón más controversial de DynamoDB. La mitad de la gente que lo conoce lo ama, la otra mitad lo odia. Yo estuve en el grupo que lo odiaba durante años hasta que lo apliqué en un SSR real con Next.js. Cambió cómo diseño la capa de datos. Este artículo explica por qué, con un ejemplo completo de un dashboard multi-tenant.

La tesis es corta. Si tu frontend SSR renderiza en milisegundos, tu base de datos no puede tomarse 300ms para juntar datos. Single-Table Design te da una sola lectura que trae todo lo que un Server Component necesita. Bien diseñado, tu TTFB baja 60%.

Por qué la mayoría falla con DynamoDB

Los equipos vienen de Postgres o MongoDB y traen el modelo mental de entidades separadas. Crean una tabla users, otra organizations, otra projects, otra tasks. Cuando renderizan un dashboard, hacen cuatro queries en paralelo o peor, secuenciales. DynamoDB es rápida por query individual, pero hacer cuatro round-trips desde Lambda pega duro.

flowchart LR
    subgraph "Multi-table tradicional"
      A1[Lambda SSR] --> B1[Query Users]
      A1 --> B2[Query Orgs]
      A1 --> B3[Query Projects]
      A1 --> B4[Query Tasks]
      B1 --> C1[Join en código]
      B2 --> C1
      B3 --> C1
      B4 --> C1
      C1 --> D1[Render 280ms]
    end

    subgraph "Single-table"
      A2[Lambda SSR] --> B5[Query PK=ORG#123]
      B5 --> D2[Render 90ms]
    end
Enter fullscreen mode Exit fullscreen mode

Con Single-Table, modelas las relaciones como jerarquías de claves y haces UNA query que trae todo. El trade-off es que el modelo parece raro al inicio.

El dashboard real del ejemplo

Voy a usar el diseño que hice para una SaaS B2B. Es un panel donde cada organización (tenant) tiene usuarios, proyectos y tareas. La ruta /org/[orgId]/dashboard necesita renderizar:

  • Info de la organización (nombre, plan, límites)
  • Lista de los 5 proyectos más recientes
  • Los 3 usuarios más activos del mes
  • Conteo de tareas abiertas por prioridad

Con modelo tradicional son 4 queries. Con Single-Table es una.

El diseño de la tabla

Una sola tabla con tres atributos clave: PK (partition key), SK (sort key), y GSI1PK/GSI1SK para acceso alternativo.

Entidad PK SK Ejemplo de datos
Organización ORG#o_123 METADATA name, plan, maxUsers
Usuario ORG#o_123 USER#u_456 name, email, role
Proyecto ORG#o_123 PROJECT#p_789 title, status, createdAt
Tarea ORG#o_123 PROJECT#p_789#TASK#t_001 title, priority, status
Stat usuario ORG#o_123 STATS#USER#u_456#2025-07 tasksCompleted

El patrón clave: todas las entidades de una organización comparten el mismo PK. Eso me permite hacer query(PK="ORG#o_123") y traer TODO lo relacionado con esa organización. Para el dashboard inicial, eso es exactamente lo que quiero.

El CDK para crear la tabla queda así:

// cdk/lib/data-stack.ts
import { Stack, StackProps, RemovalPolicy } from "aws-cdk-lib";
import { Table, AttributeType, BillingMode } from "aws-cdk-lib/aws-dynamodb";
import { Construct } from "constructs";

export class DataStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const table = new Table(this, "AppTable", {
      tableName: "app-main",
      partitionKey: { name: "PK", type: AttributeType.STRING },
      sortKey: { name: "SK", type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST,
      removalPolicy: RemovalPolicy.RETAIN,
    });

    // GSI1: para acceso inverso (por usuario global)
    table.addGlobalSecondaryIndex({
      indexName: "GSI1",
      partitionKey: { name: "GSI1PK", type: AttributeType.STRING },
      sortKey: { name: "GSI1SK", type: AttributeType.STRING },
    });

    // GSI2: para búsqueda por estado de tareas
    table.addGlobalSecondaryIndex({
      indexName: "GSI2",
      partitionKey: { name: "GSI2PK", type: AttributeType.STRING },
      sortKey: { name: "GSI2SK", type: AttributeType.STRING },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Dos GSIs para patrones de acceso alternativos. El primero permite buscar "todas las organizaciones donde participa este usuario" (para usuarios multi-tenant). El segundo permite filtrar tareas por estado sin scan.

Insertando datos con los patrones correctos

Crear una organización con su usuario inicial es una transacción:

// lib/org-service.ts
import {
  DynamoDBClient,
  TransactWriteItemsCommand,
} from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
import { randomUUID } from "crypto";

const client = new DynamoDBClient({ region: process.env.AWS_REGION });
const TABLE = "app-main";

export async function createOrganization(input: {
  name: string;
  plan: "free" | "pro" | "enterprise";
  ownerEmail: string;
  ownerName: string;
}) {
  const orgId = `o_${randomUUID().slice(0, 8)}`;
  const userId = `u_${randomUUID().slice(0, 8)}`;
  const now = new Date().toISOString();

  await client.send(
    new TransactWriteItemsCommand({
      TransactItems: [
        {
          Put: {
            TableName: TABLE,
            Item: marshall({
              PK: `ORG#${orgId}`,
              SK: "METADATA",
              entityType: "Organization",
              id: orgId,
              name: input.name,
              plan: input.plan,
              maxUsers: input.plan === "free" ? 5 : 100,
              createdAt: now,
            }),
            ConditionExpression: "attribute_not_exists(PK)",
          },
        },
        {
          Put: {
            TableName: TABLE,
            Item: marshall({
              PK: `ORG#${orgId}`,
              SK: `USER#${userId}`,
              GSI1PK: `USER#${userId}`,
              GSI1SK: `ORG#${orgId}`,
              entityType: "User",
              id: userId,
              email: input.ownerEmail,
              name: input.ownerName,
              role: "owner",
              createdAt: now,
            }),
          },
        },
      ],
    })
  );

  return { orgId, userId };
}
Enter fullscreen mode Exit fullscreen mode

Lo interesante del GSI1 en el User item: permite consulta inversa. Si un usuario pertenece a múltiples orgs, con GSI1PK=USER#u_456 traigo todas sus organizaciones con una query.

Crear un proyecto:

export async function createProject(
  orgId: string,
  input: { title: string; description: string }
) {
  const projectId = `p_${randomUUID().slice(0, 8)}`;
  const now = new Date().toISOString();

  await client.send(
    new PutItemCommand({
      TableName: TABLE,
      Item: marshall({
        PK: `ORG#${orgId}`,
        SK: `PROJECT#${projectId}`,
        GSI2PK: `ORG#${orgId}#PROJECTS`,
        GSI2SK: now,
        entityType: "Project",
        id: projectId,
        title: input.title,
        description: input.description,
        status: "active",
        createdAt: now,
      }),
    })
  );

  return { projectId };
}
Enter fullscreen mode Exit fullscreen mode

El GSI2 con GSI2SK: createdAt me da proyectos ordenados cronológicamente. Para traer "últimos 5 proyectos", la query en GSI2 con ScanIndexForward: false, Limit: 5 lo hace.

La query que alimenta el dashboard

Acá está la magia. Una sola query trae todo lo que Server Component necesita:

// lib/dashboard-queries.ts
import { QueryCommand } from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { cache } from "react";

export const getDashboardData = cache(async (orgId: string) => {
  const currentMonth = new Date().toISOString().slice(0, 7); // "2025-07"

  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "PK = :pk",
      ExpressionAttributeValues: marshall({
        ":pk": `ORG#${orgId}`,
      }),
    })
  );

  const items = (result.Items ?? []).map(unmarshall);

  const org = items.find((i) => i.entityType === "Organization");
  const users = items.filter((i) => i.entityType === "User");
  const projects = items
    .filter((i) => i.entityType === "Project")
    .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
    .slice(0, 5);
  const tasks = items.filter((i) => i.entityType === "Task");
  const monthStats = items.filter(
    (i) =>
      i.entityType === "UserMonthStat" &&
      i.SK.includes(currentMonth)
  );

  const topUsers = monthStats
    .sort((a, b) => b.tasksCompleted - a.tasksCompleted)
    .slice(0, 3)
    .map((stat) => {
      const userId = stat.SK.split("#")[2];
      const user = users.find((u) => u.id === userId);
      return { ...user, stats: stat };
    });

  const tasksByPriority = tasks.reduce(
    (acc, t) => {
      if (t.status === "open") acc[t.priority] = (acc[t.priority] ?? 0) + 1;
      return acc;
    },
    { low: 0, medium: 0, high: 0, urgent: 0 }
  );

  return { org, projects, topUsers, tasksByPriority };
});
Enter fullscreen mode Exit fullscreen mode

Una query. Una. Si la organización tiene 50 proyectos, 20 usuarios, 200 tareas, toda esa data viene en una sola request de DynamoDB. Filtrar y agrupar en memoria es trivial en Node.

En CloudWatch veo esto tomando 45-80ms consistentemente. El modelo multi-tabla que reemplazó tomaba 250-320ms con paralelismo.

Usando la data en Next.js App Router

// app/org/[orgId]/dashboard/page.tsx
import { getDashboardData } from "@/lib/dashboard-queries";
import { notFound } from "next/navigation";
import { ProjectList } from "./project-list";
import { TopUsers } from "./top-users";
import { TaskPrioritySummary } from "./task-priority";

export default async function DashboardPage({
  params,
}: {
  params: Promise<{ orgId: string }>;
}) {
  const { orgId } = await params;
  const data = await getDashboardData(orgId);

  if (!data.org) notFound();

  return (
    <div className="container mx-auto p-6 grid grid-cols-12 gap-6">
      <header className="col-span-12">
        <h1 className="text-3xl font-bold">{data.org.name}</h1>
        <p className="text-sm text-gray-500">
          Plan {data.org.plan} · {data.org.maxUsers} usuarios máximo
        </p>
      </header>

      <section className="col-span-8">
        <h2 className="text-xl font-semibold mb-4">Proyectos recientes</h2>
        <ProjectList projects={data.projects} orgId={orgId} />
      </section>

      <aside className="col-span-4 space-y-6">
        <TopUsers users={data.topUsers} />
        <TaskPrioritySummary tasks={data.tasksByPriority} />
      </aside>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

El cache() de React deduplica dentro del mismo render. Si varios componentes piden getDashboardData(orgId), solo una query real sale. Esto es clave con Server Components anidados.

Cuando necesitas queries más específicas

A veces no quieres toda la org, solo un slice. Query con filtro en el SK:

// traer solo los proyectos de una org
export async function getProjects(orgId: string, limit = 20) {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "PK = :pk AND begins_with(SK, :sk)",
      ExpressionAttributeValues: marshall({
        ":pk": `ORG#${orgId}`,
        ":sk": "PROJECT#",
      }),
      Limit: limit,
    })
  );

  return (result.Items ?? []).map(unmarshall);
}

// traer las tareas de un proyecto específico
export async function getProjectTasks(orgId: string, projectId: string) {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "PK = :pk AND begins_with(SK, :sk)",
      ExpressionAttributeValues: marshall({
        ":pk": `ORG#${orgId}`,
        ":sk": `PROJECT#${projectId}#TASK#`,
      }),
    })
  );

  return (result.Items ?? []).map(unmarshall);
}
Enter fullscreen mode Exit fullscreen mode

El begins_with en el SK es la herramienta más poderosa del modelo. Te deja navegar la jerarquía sin escanear.

Comparación de performance

Esto es medido en un proyecto real con tráfico moderado (20K dashboard renders/día).

Enfoque TTFB promedio Costo mensual DynamoDB Queries/render
Multi-table RDS PostgreSQL 210ms $180 (db.t3.medium) 4
Multi-table DynamoDB 280ms $95 4
Single-table DynamoDB 85ms $48 1

El gran ganador no es solo el costo, es la latencia. El TTFB baja 60% y el usuario percibe la mejora.

Lo que aprendí haciendo esto mal primero

1. El naming de las claves importa más de lo que crees.
Empecé con PKs como org-123 y SKs como user-456. Funcionaba, pero no era legible al debuggear. Moví a ORG#o_123 y USER#u_456. El # como separador visual salva horas en consultas a consola.

2. El overloading de GSIs se pone denso.
En mi caso uso GSI1 para "user a orgs" y GSI2 para "proyectos ordenados por fecha". Al principio traté de meter todo en GSI1 con múltiples prefijos. Se volvió incomprensible. Hoy, un GSI por patrón de acceso, máximo 3 GSIs por tabla. Más que eso y algo está mal modelado.

3. Los items con muchos atributos terminan en RCU altos.
Una organización con 500 tareas hace que mi dashboard query consuma 10-15 RCUs. Si tienes orgs muy grandes, considera dividir: una tabla "hot" para lo que el dashboard muestra (últimos 30 días) y una "archive" para histórico. DynamoDB TTL borra automáticamente items viejos.

4. Atomic counters son tu amigo para stats.
En lugar de calcular tasksCompleted sumando tareas, mantengo un item STATS#USER#u_456#2025-07 que incrementa con UpdateExpression: "ADD tasksCompleted :inc" cada vez que alguien completa una tarea. Leer stats es 1 request. Mucho mejor que sumar 200 items.

5. Local development con DynamoDB Local es lento.
En Docker funciona, pero el startup de la imagen pesa. Para dev, DynamoDB Local con flag -sharedDb -inMemory es suficiente. Para tests, uso @shelf/jest-dynamodb que levanta instancia fresca por test file.

6. No migres una app existente de golpe.
Intenté migrar un proyecto de 3 años y fue un desastre. Hoy recomiendo Single-Table solo para servicios nuevos o refactors grandes. Los proyectos híbridos con PostgreSQL para lo transaccional y DynamoDB para lo de acceso rápido funcionan bien.

Cuándo NO usar Single-Table Design

Si tu equipo no conoce DynamoDB profundo, el código queda incomprensible. Lo que ves es PK y SK, no organizations.id y users.email. Los juniors se pierden. Si no tienes quien mantenga esto, quédate con modelo tradicional hasta que el equipo madure.

Si haces queries ad-hoc sobre cualquier campo, DynamoDB no es tu base de datos. Single-Table brilla con patrones de acceso conocidos de antemano. Para analytics exploratorio, exporta a Athena o usa RDS.

Si tus relaciones son muchas-a-muchas complejas, el modelo se vuelve enredado. Un usuario en 20 organizaciones distintas con permisos granulares por proyecto es mejor en Postgres. DynamoDB puede, pero la complejidad del modelo no compensa.

Si transacciones cross-item son frecuentes (más de 20% de las escrituras), DynamoDB transacciones son caras (2x el costo normal). Postgres con ACID nativo sale mejor.


En el próximo artículo vamos a CI/CD completo para frontend con CodePipeline, CodeBuild y Playwright. Visual regression, E2E contra ambiente real, y rollback automático cuando las métricas post-deploy empeoran.

Top comments (0)