Skip to content
Ayhan Sipahi Ayhan Sipahi

DynamoDB Toolbox Guide: Single-Table Design in TypeScript

Move from raw AWS SDK complexity to production-ready single-table design with practical DynamoDB Toolbox patterns, common pitfalls, and scaling decisions.

Building serverless APIs with raw DynamoDB SDK calls creates significant maintenance overhead. Thousands of lines of AttributeValue mappings, dozens of scattered UpdateExpression strings, and zero type safety lead to brittle systems. When schema changes accidentally corrupt user records, it becomes clear that a better approach is essential.

DynamoDB Toolbox is the default worth reaching for. It wraps a table in typed entity definitions, so key construction, defaults, and marshalling stop being hand-written at every call site. Entity design, a service layer that owns the business rules, and a staged migration off raw SDK calls are where most of the work sits.

One caveat up front: the entity definition API has changed shape twice, and search results mix all three generations. Every snippet below targets v2, the line npm currently serves as latest.

The Challenges That Drive Tool Adoption

The AttributeValue Complexity

Working with raw DynamoDB SDK meant writing code like this every day:

// Raw SDK: every key, name, and value spelled out by hand
const params = {
  TableName: 'Users',
  Key: {
    'PK': { S: `USER#${userId}` },
    'SK': { S: `PROFILE#${userId}` }
  },
  UpdateExpression: 'SET #email = :email, #updatedAt = :updatedAt, #version = #version + :inc',
  ExpressionAttributeNames: {
    '#email': 'email',
    '#updatedAt': 'updatedAt',
    '#version': 'version'
  },
  ExpressionAttributeValues: {
    ':email': { S: newEmail },
    ':updatedAt': { S: new Date().toISOString() },
    ':inc': { N: '1' }
  },
  ConditionExpression: 'attribute_exists(PK) AND #version = :currentVersion',
  ReturnValues: 'ALL_NEW'
};

const result = await dynamodb.updateItem(params).promise();

Now multiply that across every operation in a service. There is no type safety, no validation, and no shared definition of what a user record is supposed to look like.

The Schema Validation Problem

A common scenario: adding a preferences field to user records. Without proper validation, it’s easy to overwrite the entire record structure instead of adding the field. Here’s what can go wrong:

// What was intended
const updateParams = {
  UpdateExpression: 'SET preferences = :prefs',
  ExpressionAttributeValues: {
    ':prefs': { M: { theme: { S: 'dark' } } }
  }
};

// What actually happened (copy-paste error)
const updateParams = {
  UpdateExpression: 'SET preferences = :prefs',
  ExpressionAttributeValues: {
    ':prefs': { S: JSON.stringify({ theme: 'dark' }) } // Wrong type!
  }
};

Result: corrupted user records and emergency data recovery. This illustrates why type safety and validation are critical for production systems.

The UpdateExpression Consistency Challenge

Large codebases often accumulate dozens of different UpdateExpression strings scattered across services. Each variation introduces potential bugs:

// In user-service.ts
'SET #email = :email, #updatedAt = :updatedAt'

// In profile-service.ts
'SET email = :email, updatedAt = :updatedAt' // Missing #

// In preferences-service.ts
'SET #email = :e, #updated = :u' // Different attribute names

// In admin-service.ts
'SET email = :email, #updatedAt = :updatedAt' // Mixed style

Nothing here is consistent or reusable, so every change carries the risk of silently writing the wrong attribute.

Discovering DynamoDB Toolbox

When evaluating solutions for DynamoDB complexity, DynamoDB Toolbox stands out for several key capabilities:

  • Type safety - No more AttributeValue hell
  • Schema validation - Catch errors before they hit production
  • Single-table design support - Several entities share one table without extra plumbing
  • TypeScript-first - Types are derived from the entity definition, not maintained beside it

These features address the core challenges that make raw DynamoDB operations difficult to maintain.

Entity and Service Architecture

A layout that keeps entity definitions, business logic, and handlers in separate files:

Foundation: Type-Safe Entity Definitions

The npm registry lists 221 published versions of dynamodb-toolbox, starting at 0.1.0 on 2019-12-06, and the entity API is not the same across them. The registry timeline explains why copied snippets so often refuse to compile: 0.9.5 closed the 0.x line on 2024-05-05, 1.0.0 arrived on 2024-07-19, and 2.0.0 landed on 2025-03-06, one day after 1.16.3 closed the 1.x line. That leaves 1.x as a window of roughly seven and a half months whose articles are still indexed. The v2.0.0 release note states the break plainly: “The v2 introduces some big changes in the schema syntax, and enables opting out of the internal entity attribute”. The current latest on npm is 2.10.4, published 2026-08-07, and that is what the code below targets.

Two attribute keys that circulate widely never existed. The published PureAttributeDefinition type in 0.9.5 is a closed list of exactly 18 keys: partitionKey, sortKey, type, default, dependsOn, transform, format, coerce, save, onUpdate, hidden, required, alias, map, setType, delimiter, prefix and suffix. Neither validate nor properties is among them, so an attribute-level validate: callback and a nested properties: block under a map were never valid 0.x, however often they show up in examples.

v2 gives both a home. Its schema builder exposes 13 types (any, null, boolean, number, string, binary, set, list, tuple, item, map, record, anyOf), roots every entity at item({...}), and nests through map(attributes, props?), where the attributes dictionary is the first positional argument rather than a key. Validation became a chainable method: the custom-validation page defines a validator as “a function that takes an input (validated by the schema) and returns a boolean”, and offers .validate(), .putValidate(), .updateValidate() and .keyValidate(), with .validate() acting as keyValidate on key schemas and putValidate otherwise. A validator answers true or an error string; it cannot rewrite the value, so lowercasing an email belongs in a transformer or in the service layer.

// lib/database/entities.ts - table and entity definitions, dynamodb-toolbox 2.10.4
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { Table } from 'dynamodb-toolbox/table';
import { Entity } from 'dynamodb-toolbox/entity';
import type { FormattedItem } from 'dynamodb-toolbox/entity';
import { item } from 'dynamodb-toolbox/schema/item';
import { string } from 'dynamodb-toolbox/schema/string';
import { number } from 'dynamodb-toolbox/schema/number';
import { boolean } from 'dynamodb-toolbox/schema/boolean';
import { map } from 'dynamodb-toolbox/schema/map';
import { set } from 'dynamodb-toolbox/schema/set';

// DYNAMODB_ENDPOINT is unset in AWS and points at DynamoDB Local in tests.
const dynamoClient = new DynamoDBClient({
  region: process.env.AWS_REGION,
  endpoint: process.env.DYNAMODB_ENDPOINT,
});

export const documentClient = DynamoDBDocumentClient.from(dynamoClient, {
  marshallOptions: { removeUndefinedValues: true, convertEmptyValues: false },
  unmarshallOptions: { wrapNumbers: false },
});

export const MainTable = new Table({
  name: process.env.MAIN_TABLE_NAME!,
  partitionKey: { name: 'PK', type: 'string' },
  sortKey: { name: 'SK', type: 'string' },
  indexes: {
    GSI1: {
      type: 'global',
      partitionKey: { name: 'GSI1PK', type: 'string' },
      sortKey: { name: 'GSI1SK', type: 'string' },
    },
    GSI2: {
      type: 'global',
      partitionKey: { name: 'GSI2PK', type: 'string' },
      sortKey: { name: 'GSI2SK', type: 'string' },
    },
  },
  documentClient,
});

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const userSchema = item({
  userId: string().key(),
  // A validator returns true or an error string. It never rewrites the value.
  // On a non-key attribute `.validate()` is a put validator, so neither rule
  // below runs on an update.
  email: string().validate(
    value => EMAIL.test(value) || 'Provide a valid email address'
  ),
  username: string().validate(
    value =>
      (value.length >= 3 && value.length <= 30) ||
      'Username must be 3 to 30 characters'
  ),
  firstName: string().optional(),
  lastName: string().optional(),
  avatar: string().optional(),
  bio: string().optional(),
  // Nesting is a positional attributes dictionary, not a `properties` key.
  preferences: map({
    theme: string().default('light'),
    notifications: boolean().default(true),
    language: string().default('en'),
  }).default({ theme: 'light', notifications: true, language: 'en' }),
  version: number().default(1),
}).and(prev => ({
  // Index attributes are derived from the item, so no caller can forget one.
  GSI1PK: string().link<typeof prev>(({ email }) => `EMAIL#${email}`),
  GSI1SK: string().link<typeof prev>(({ userId }) => `USER#${userId}`),
  GSI2PK: string().link<typeof prev>(({ username }) => `USERNAME#${username}`),
  GSI2SK: string().link<typeof prev>(({ userId }) => `USER#${userId}`),
}));

export const UserEntity = new Entity({
  name: 'User',
  table: MainTable,
  schema: userSchema,
  timestamps: true, // adds the internal `created` and `modified` attributes
  computeKey: ({ userId }) => ({ PK: `USER#${userId}`, SK: `USER#${userId}` }),
});

// One sentinel item per claimed value. The service layer writes these in the
// same transaction as the user, so a duplicate loses at the database.
export const UniqueValueEntity = new Entity({
  name: 'UniqueValue',
  table: MainTable,
  schema: item({
    scope: string().key().enum('EMAIL', 'USERNAME'),
    claim: string().key(),
    userId: string(),
  }),
  computeKey: ({ scope, claim }) => ({
    PK: `${scope}#${claim}`,
    SK: `${scope}#${claim}`,
  }),
});

export const OrganizationEntity = new Entity({
  name: 'Organization',
  table: MainTable,
  schema: item({
    orgId: string().key(),
    name: string(),
    domain: string().optional(),
    plan: string().default('free'),
    settings: map({
      maxUsers: number().default(10),
      features: set(string()).default(new Set(['basic'])),
      billing: map({
        customerId: string().optional(),
        subscriptionId: string().optional(),
      }).optional(),
    }).default({ maxUsers: 10, features: new Set(['basic']) }),
  }).and(prev => ({
    GSI1PK: string()
      .optional()
      .link<typeof prev>(({ domain }) => (domain ? `DOMAIN#${domain}` : undefined)),
    GSI1SK: string()
      .optional()
      .link<typeof prev>(({ orgId }) => `ORG#${orgId}`),
  })),
  computeKey: ({ orgId }) => ({ PK: `ORG#${orgId}`, SK: `ORG#${orgId}` }),
});

export const MembershipEntity = new Entity({
  name: 'Membership',
  table: MainTable,
  schema: item({
    orgId: string().key(),
    userId: string().key(),
    role: string().enum('owner', 'admin', 'member').default('member'),
    permissions: set(string()).default(new Set<string>()),
    invitedBy: string().optional(),
    status: string().enum('active', 'invited', 'removed').default('active'),
  }).and(prev => ({
    GSI1PK: string().link<typeof prev>(({ userId }) => `USER#${userId}`),
    GSI1SK: string().link<typeof prev>(({ orgId }) => `ORG#${orgId}`),
  })),
  computeKey: ({ orgId, userId }) => ({
    PK: `ORG#${orgId}`,
    SK: `USER#${userId}`,
  }),
});

// Types come from the entity, so they cannot drift away from the schema.
export type User = FormattedItem<typeof UserEntity>;
export type Organization = FormattedItem<typeof OrganizationEntity>;
export type Membership = FormattedItem<typeof MembershipEntity>;

Three things moved out of the definition and into the type system here. Key construction lives in computeKey, so the PK and SK strings are built in exactly one place. Index attributes are linked to the item they describe, so a caller cannot write a user without a GSI1PK. And FormattedItem<typeof UserEntity> derives the TypeScript type from the schema rather than restating it underneath, which is where a hand-written type falls out of sync first.

Service Layer

Three traps live in this layer, and all three survive code review because they look right.

The first is the query signature. In 0.x the published Entity type declares query(pk: any, options?: EntityQueryOptions, params?: Partial<QueryInput>), so the first positional argument is the partition-key value. The comparison keys belong to the options object, where $QueryOptions declares exactly ten of them (reverse, select, eq, lt, lte, gt, gte, between, beginsWith, startKey) and they apply to the sort key. A call written as query('GSI1PK', { eq: 'EMAIL#...' }, { index: 'GSI1' }) therefore asks for the partition whose key is the literal string "GSI1PK", then filters GSI1SK. The word eq appears in both generations, which is exactly why they blend so easily. v2 removes the ambiguity by making the whole thing one object: .query({ partition, index, range }).

The second is expecting a write to hand back what it wrote. The AWS PutItem reference is explicit: “The ReturnValues parameter is used by several DynamoDB operations; however, PutItem does not recognize any values other than NONE or ALL_OLD”, and Attributes carries the values “as they appeared BEFORE the PutItem operation”. The 0.9.5 types agree, typing put’s return values as 'NONE' | 'ALL_OLD' and omitting Attributes entirely at the default. So result.Item after a put was wrong twice over: wrong key name, and no such data on the wire. v2 answers with ToolboxItem on the put response, the complete item the library sent including computed keys and defaults. Update is a different operation: UpdateItem does accept ALL_NEW, so an updated record really does come back in Attributes.

The third is a design one. Reading three times and then writing is not a uniqueness check, because another invocation can claim the same email between the read and the write. AWS answers the single-item case with a condition rather than a read: “To prevent a new item from replacing an existing item, use a conditional expression that contains the attribute_not_exists function with the name of the attribute being used as the partition key for the table.” For a claim spanning several items, TransactWriteItems “groups up to 100 action requests”, caps the aggregate at 4 MB, forbids two actions targeting the same item, and completes “atomically so that either all of them succeed, or all of them fail”. A rejection arrives as TransactionCanceledException carrying a positional CancellationReasons array, so the handler can tell a duplicate email (ConditionalCheckFailed) from a throttle (ProvisionedThroughputExceeded). If you retry, reuse the ClientRequestToken: it “is valid for 10 minutes after the first request that uses it is completed”, and it holds 36 characters at most, which a UUID fits exactly. The token has to arrive with the request. One minted inside the handler is new on every attempt, so a retry after a lost response opens a second transaction and loses to the claims the first one already wrote. The commit leaves one detail behind. A read-back afterwards has to ask for a strongly consistent read, because the eventually consistent default can answer with nothing for an item the transaction just wrote.

// services/user-service.ts - business rules on top of the entities
import { TransactionCanceledException } from '@aws-sdk/client-dynamodb';
import { GetItemCommand } from 'dynamodb-toolbox/entity/actions/get';
import { UpdateItemCommand, $add } from 'dynamodb-toolbox/entity/actions/update';
import { PutTransaction } from 'dynamodb-toolbox/entity/actions/transactPut';
import { execute } from 'dynamodb-toolbox/entity/actions/transactWrite';
import { QueryCommand } from 'dynamodb-toolbox/table/actions/query';
import {
  MainTable,
  UserEntity,
  UniqueValueEntity,
  type User,
} from '../database/entities';

export class UserService {
  // Three items, one commit. A duplicate loses at the database rather than in
  // a read that happened a few milliseconds earlier.
  async createUser(input: {
    userId: string;
    email: string;
    username: string;
    firstName?: string;
    lastName?: string;
    idempotencyKey: string;
  }): Promise<User> {
    const { idempotencyKey, ...attributes } = input;
    const email = attributes.email.toLowerCase();

    try {
      await execute(
        // The caller's key, not one minted here. A retry after a lost response
        // resolves to the first transaction instead of failing its own claims.
        { clientRequestToken: idempotencyKey },
        UserEntity.build(PutTransaction)
          .item({ ...attributes, email })
          .options({ condition: { attr: 'userId', exists: false } }),
        UniqueValueEntity.build(PutTransaction)
          .item({ scope: 'EMAIL', claim: email, userId: input.userId })
          .options({ condition: { attr: 'claim', exists: false } }),
        UniqueValueEntity.build(PutTransaction)
          .item({ scope: 'USERNAME', claim: input.username, userId: input.userId })
          .options({ condition: { attr: 'claim', exists: false } }),
      );
    } catch (error) {
      throw explainCancellation(error, ['user id', 'email', 'username']);
    }

    // TransactWriteItems returns no item attributes, so the stored record comes
    // from a read, and that read is strongly consistent. The default would let
    // a just-committed user read back as missing, and this method would then
    // report a failure for a write that succeeded. On the single-item path,
    // PutItemCommand hands back the item it wrote in `ToolboxItem` and this
    // call is redundant.
    const { Item } = await UserEntity.build(GetItemCommand)
      .key({ userId: input.userId })
      .options({ consistent: true })
      .send();

    if (!Item) {
      throw new Error('User was committed but could not be read back');
    }

    return Item;
  }

  // The partition VALUE goes in `partition`. Sort-key conditions go in `range`.
  // An index read is eventually consistent and cannot ask otherwise, so a
  // lookup that follows a write closely can still come back empty.
  async getUserByEmail(email: string): Promise<User | null> {
    const { Items = [] } = await MainTable.build(QueryCommand)
      .query({ index: 'GSI1', partition: `EMAIL#${email.toLowerCase()}` })
      .entities(UserEntity)
      .options({ limit: 1 })
      .send();

    return Items[0] ?? null;
  }

  async getUserByUsername(username: string): Promise<User | null> {
    const { Items = [] } = await MainTable.build(QueryCommand)
      .query({ index: 'GSI2', partition: `USERNAME#${username}` })
      .entities(UserEntity)
      .options({ limit: 1 })
      .send();

    return Items[0] ?? null;
  }

  // UpdateItem accepts ALL_NEW, so the new record does arrive in Attributes.
  // `email` and `username` are excluded on purpose: their validators only run
  // on a put, and changing either would leave its uniqueness claim pointing at
  // the old value. Both belong in a transaction that moves the claim along.
  async updateUser(
    userId: string,
    updates: Partial<Omit<User, 'userId' | 'version' | 'email' | 'username'>>,
    expectedVersion?: number,
  ): Promise<User> {
    const condition =
      expectedVersion === undefined
        ? { attr: 'userId', exists: true }
        : {
            and: [
              { attr: 'userId', exists: true },
              { attr: 'version', eq: expectedVersion },
            ],
          };

    const { Attributes } = await UserEntity.build(UpdateItemCommand)
      .item({ userId, ...updates, version: $add(1) })
      .options({ condition, returnValues: 'ALL_NEW' })
      .send();

    if (!Attributes) {
      throw new Error('Update returned no attributes');
    }

    return Attributes;
  }
}

// CancellationReasons is positional: entry i explains transaction item i.
function explainCancellation(error: unknown, labels: string[]): Error {
  if (!(error instanceof TransactionCanceledException)) {
    return error instanceof Error ? error : new Error(String(error));
  }

  const taken = (error.CancellationReasons ?? [])
    .map((reason, index) =>
      reason.Code === 'ConditionalCheckFailed' ? labels[index] : null,
    )
    .filter((label): label is string => label !== null);

  return taken.length > 0
    ? new Error(`Already taken: ${taken.join(', ')}`)
    : error;
}

export const userService = new UserService();

Lambda Handler: Production-Ready API Endpoints

// handlers/users/create.ts - Create-user endpoint
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { userService } from '../../services/user-service';
import { z } from 'zod';

// Input validation schema
const CreateUserSchema = z.object({
  userId: z.string().min(1).max(50),
  email: z.string().email(),
  username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/),
  firstName: z.string().optional(),
  lastName: z.string().optional(),
});

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('Create user request:', {
    requestId: event.requestContext.requestId,
    sourceIp: event.requestContext.identity.sourceIp,
  });

  try {
    // Parse and validate input
    if (!event.body) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Request body is required',
          code: 'MISSING_BODY',
        }),
      };
    }

    let requestData;
    try {
      requestData = JSON.parse(event.body);
    } catch (error) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Invalid JSON in request body',
          code: 'INVALID_JSON',
        }),
      };
    }

    // Validate with Zod
    const validationResult = CreateUserSchema.safeParse(requestData);
    if (!validationResult.success) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Validation failed',
          code: 'VALIDATION_ERROR',
          details: validationResult.error.errors,
        }),
      };
    }

    // API Gateway mints a new requestId per attempt, so the idempotency key
    // comes from the client. Without it a retry becomes a second transaction.
    const idempotencyKey = Object.entries(event.headers ?? {}).find(
      ([name]) => name.toLowerCase() === 'idempotency-key',
    )?.[1];

    if (!idempotencyKey || idempotencyKey.length > 36) {
      return {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: 'Idempotency-Key header is required, 36 characters at most',
          code: 'MISSING_IDEMPOTENCY_KEY',
        }),
      };
    }

    // Create user
    const user = await userService.createUser({
      ...validationResult.data,
      idempotencyKey,
    });

    // Keep the response lean: preferences are fetched on their own endpoint
    const { preferences, ...safeUser } = user;

    return {
      statusCode: 201,
      headers: {
        'Content-Type': 'application/json',
        'X-Request-ID': event.requestContext.requestId,
      },
      body: JSON.stringify({
        message: 'User created successfully',
        user: safeUser,
      }),
    };

  } catch (error) {
    console.error('Error creating user:', {
      error: error.message,
      stack: error.stack,
      requestId: event.requestContext.requestId,
    });

    // The service turns a cancelled transaction into this message
    if (error.message.startsWith('Already taken:')) {
      return {
        statusCode: 409,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: error.message,
          code: 'CONFLICT',
        }),
      };
    }

    // Generic error response
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        error: 'Internal server error',
        code: 'INTERNAL_ERROR',
        requestId: event.requestContext.requestId,
      }),
    };
  }
};

Advanced Patterns

Optimistic Locking Pattern

// patterns/optimistic-locking.ts - re-read and retry on a version conflict
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { GetItemCommand } from 'dynamodb-toolbox/entity/actions/get';
import { UpdateItemCommand, $add } from 'dynamodb-toolbox/entity/actions/update';
import { UserEntity, type User } from '../database/entities';

// Same exclusion as the service layer: email and username move with their
// uniqueness claims, so they never ride a generic partial update.
export async function updateUserWithRetry(
  userId: string,
  updates: Partial<Omit<User, 'userId' | 'version' | 'email' | 'username'>>,
  maxAttempts = 3,
): Promise<User> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    // Strongly consistent on purpose. The default read can hand back the
    // version that just lost the race, and then every attempt fails the same
    // condition until the loop runs out.
    const { Item } = await UserEntity.build(GetItemCommand)
      .key({ userId })
      .options({ consistent: true })
      .send();

    if (!Item) {
      throw new Error('User not found');
    }

    try {
      const { Attributes } = await UserEntity.build(UpdateItemCommand)
        .item({ userId, ...updates, version: $add(1) })
        .options({
          condition: { attr: 'version', eq: Item.version },
          returnValues: 'ALL_NEW',
        })
        .send();

      if (!Attributes) {
        throw new Error('Update returned no attributes');
      }

      return Attributes;
    } catch (error) {
      if (!(error instanceof ConditionalCheckFailedException)) {
        throw error;
      }

      // Someone else won this round. Back off, re-read, try again.
      await new Promise(resolve =>
        setTimeout(resolve, 2 ** attempt * 50 + Math.floor(Math.random() * 50)),
      );
    }
  }

  throw new Error(`Version conflict persisted after ${maxAttempts} attempts`);
}

Batch Operations Pattern

The constants in a batch helper belong to AWS, and in v2 they are still the caller’s responsibility. The BatchWriteItem reference states that “A single call to BatchWriteItem can transmit up to 16MB of data over the network, consisting of up to 25 item put or delete operations”, and rejects the whole call when there are more than 25 requests, when two requests carry identical partition and sort keys, when any individual item exceeds 400 KB, when the total exceeds 16 MB, or when a partition key exceeds 2048 bytes or a sort key exceeds 1024 bytes. The same page notes that “BatchWriteItem cannot update items”, which is why a batch helper takes puts and deletes and nothing else. It also keeps the batch out of the uniqueness story: “you cannot specify conditions on individual put and delete requests”, and a put against an existing item overwrites it. A batch write can neither reserve an email or username claim nor guard the record it replaces, so the helper below takes an input that cannot carry either field and reads both back from the stored record. Registration, and any change of email or username, stays on the transactional path. BatchGetItem carries its own pair: “A single operation can retrieve up to 16 MB of data, which can contain as many as 100 items”, and asking for more returns a ValidationException reading “Too many items requested for the BatchGetItem call.” A repeated key is fatal on the read side too: “BatchGetItem will result in a ValidationException if the same key is specified multiple times.” Chunking never removes a repeat, so a helper whose input is a plain list of ids deduplicates before it chunks.

Chunking to 25 and 100 is necessary and not sufficient, and AWS’s own worked example shows why: “if you ask to retrieve 100 items, but each individual item is 300 KB in size, the system returns 52 items (so as not to exceed the 16 MB limit)”. The remainder comes back in UnprocessedKeys, and the write side behaves the same way through UnprocessedItems. A helper that ignores those two fields loses data quietly.

v2 leaves the caps exactly where they were. BatchWriteCommand collects BatchPutRequest and BatchDeleteRequest objects and runs through a standalone execute(), the docs note that “Only one BatchWriteCommand per Table is supported”, and neither execute helper chunks anything. Its maxAttempts option is documented as “A ‘meta’ option provided by DynamoDB-Toolbox to retry failed requests in a single promise” with a default of 1, so by default unprocessed items are returned rather than retried. Raising it retries in a tight loop with no pause, which is the one thing the AWS page warns against: “we strongly recommend that you use an exponential backoff algorithm. If you retry the batch operation immediately, the underlying read or write requests can still fail due to throttling on the individual tables.” The delay is yours to add.

// patterns/batch-operations.ts - chunking and backoff around the v2 batch actions
import {
  BatchWriteCommand as SdkBatchWriteCommand,
  type BatchWriteCommandInput,
} from '@aws-sdk/lib-dynamodb';
import {
  BatchWriteCommand,
  execute as executeWrite,
} from 'dynamodb-toolbox/table/actions/batchWrite';
import { BatchPutRequest } from 'dynamodb-toolbox/entity/actions/batchPut';
import {
  BatchGetCommand,
  execute as executeGet,
} from 'dynamodb-toolbox/table/actions/batchGet';
import { BatchGetRequest } from 'dynamodb-toolbox/entity/actions/batchGet';
import {
  MainTable,
  UserEntity,
  documentClient,
  type User,
} from '../database/entities';

// AWS caps, not library caps. Neither execute() helper chunks for you.
const WRITE_CHUNK = 25; // BatchWriteItem: at most 25 put or delete requests
const READ_CHUNK = 100; // BatchGetItem: at most 100 keys
const MAX_ROUNDS = 6;

type RequestItems = NonNullable<BatchWriteCommandInput['RequestItems']>;

function chunk<T>(values: T[], size: number): T[][] {
  const out: T[][] = [];
  for (let index = 0; index < values.length; index += size) {
    out.push(values.slice(index, index + size));
  }
  return out;
}

const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

// Refreshes users that already hold their email and username claims. A batch
// put carries no condition and writes no UniqueValueEntity, so it can neither
// move a claim nor check a version. The input leaves out all three, and any
// change of email or username belongs to the transaction in
// UserService.createUser.
type UserRefresh = { userId: string } & Partial<
  Omit<User, 'userId' | 'version' | 'email' | 'username'>
>;

export async function putExistingUsers(updates: UserRefresh[]): Promise<void> {
  // Two puts for one key reject the entire batch, so the last update for a key
  // is the one that goes out.
  const deduped = [
    ...new Map(updates.map(update => [update.userId, update])).values(),
  ];

  // A put replaces the whole item, so every field the caller cannot send comes
  // from the stored record.
  const stored = new Map(
    (await getUsers(deduped.map(update => update.userId))).map(user => [
      user.userId,
      user,
    ]),
  );

  const items = deduped.map(update => {
    const current = stored.get(update.userId);
    if (!current) {
      throw new Error(`No stored user for ${update.userId}`);
    }

    // A wider object still assigns to UserRefresh, so the claimed fields are
    // restated from the stored record after the spread.
    const { email, username, version } = current;
    return { ...current, ...update, email, username, version };
  });

  for (const batch of chunk(items, WRITE_CHUNK)) {
    const command = MainTable.build(BatchWriteCommand).requests(
      ...batch.map(item => UserEntity.build(BatchPutRequest).item(item)),
    );

    const { UnprocessedItems } = await executeWrite(command);
    await drainWrites(UnprocessedItems as RequestItems | undefined);
  }
}

// A chunk of 25 can still come back short, because the 16 MB envelope or a
// 400 KB item bites before the count does. Unprocessed items arrive already
// transformed, so they go straight back to the DocumentClient rather than
// through the entity a second time.
async function drainWrites(unprocessed?: RequestItems): Promise<void> {
  let pending = unprocessed ?? {};

  for (let round = 0; round < MAX_ROUNDS; round++) {
    if (Object.values(pending).flat().length === 0) {
      return;
    }

    await wait(2 ** round * 50 + Math.floor(Math.random() * 50));

    const response = await documentClient.send(
      new SdkBatchWriteCommand({ RequestItems: pending }),
    );
    pending = (response.UnprocessedItems ?? {}) as RequestItems;
  }

  const stuck = Object.values(pending).flat().length;
  if (stuck > 0) {
    throw new Error(`Batch write gave up with ${stuck} unprocessed requests`);
  }
}

export async function getUsers(userIds: string[]): Promise<User[]> {
  const found: User[] = [];

  // The same key twice fails the request with a ValidationException, and ids
  // collected from joined records repeat often, so the input is deduplicated.
  const uniqueIds = [...new Set(userIds)];

  for (const batch of chunk(uniqueIds, READ_CHUNK)) {
    let outstanding = batch;
    let settled = false;

    for (let round = 0; round < MAX_ROUNDS && !settled; round++) {
      const command = MainTable.build(BatchGetCommand).requests(
        ...outstanding.map(userId =>
          UserEntity.build(BatchGetRequest).key({ userId }),
        ),
      );

      const { Responses = [], UnprocessedKeys } = await executeGet(command);
      const page = (Responses[0] ?? []) as User[];
      found.push(...page);

      // 100 keys at 300 KB each returns 52 items. The rest surface here as
      // unprocessed keys, not as an error.
      settled = Object.keys(UnprocessedKeys ?? {}).length === 0;
      if (settled) {
        break;
      }

      const returned = new Set(page.map(user => user.userId));
      outstanding = outstanding.filter(userId => !returned.has(userId));
      await wait(2 ** round * 50 + Math.floor(Math.random() * 50));
    }

    if (!settled) {
      throw new Error('Batch get gave up with unprocessed keys');
    }
  }

  return found;
}

Transaction Pattern for ACID Operations

The 100-action and 4 MB caps apply here too, and so does the rule that no two actions may target the same item. Both operations below stay well inside all three. Creating a user here writes the same uniqueness claims as the service layer does. A second creation path that skips them reopens the duplicate the first one prevents. Both also take the caller’s idempotency key, because a retry that mints a fresh token is a new transaction and fails against whatever the first one already committed.

// patterns/transactions.ts - entity-level write transactions
import { execute } from 'dynamodb-toolbox/entity/actions/transactWrite';
import { PutTransaction } from 'dynamodb-toolbox/entity/actions/transactPut';
import { UpdateTransaction } from 'dynamodb-toolbox/entity/actions/transactUpdate';
import {
  UserEntity,
  UniqueValueEntity,
  OrganizationEntity,
  MembershipEntity,
} from '../database/entities';

export class TransactionService {
  // Either the organization exists with an owner, or nothing was written. The
  // uniqueness claims ride along, so this path cannot hand out an email or a
  // username that the service layer would have rejected.
  async createUserWithOrganization(
    user: { userId: string; email: string; username: string },
    org: { orgId: string; name: string },
    idempotencyKey: string,
  ): Promise<void> {
    const email = user.email.toLowerCase();

    await execute(
      { clientRequestToken: idempotencyKey },
      UserEntity.build(PutTransaction)
        .item({ ...user, email })
        .options({ condition: { attr: 'userId', exists: false } }),
      UniqueValueEntity.build(PutTransaction)
        .item({ scope: 'EMAIL', claim: email, userId: user.userId })
        .options({ condition: { attr: 'claim', exists: false } }),
      UniqueValueEntity.build(PutTransaction)
        .item({ scope: 'USERNAME', claim: user.username, userId: user.userId })
        .options({ condition: { attr: 'claim', exists: false } }),
      OrganizationEntity.build(PutTransaction)
        .item(org)
        .options({ condition: { attr: 'orgId', exists: false } }),
      MembershipEntity.build(PutTransaction).item({
        orgId: org.orgId,
        userId: user.userId,
        role: 'owner',
      }),
    );
  }

  // Two different items, so the same-item rule is satisfied. Each side states
  // what it expected to find, so a stale caller cannot create two owners.
  async transferOwnership(
    orgId: string,
    fromUserId: string,
    toUserId: string,
    idempotencyKey: string,
  ): Promise<void> {
    await execute(
      { clientRequestToken: idempotencyKey },
      MembershipEntity.build(UpdateTransaction)
        .item({ orgId, userId: fromUserId, role: 'member' })
        .options({ condition: { attr: 'role', eq: 'owner' } }),
      MembershipEntity.build(UpdateTransaction)
        .item({ orgId, userId: toUserId, role: 'owner' })
        .options({ condition: { attr: 'role', eq: 'member' } }),
    );
  }
}

Performance Optimization Patterns

Connection Reuse and Warm Starts

The entity file above builds its client inline to keep the example short. In a service, the client is built once here and imported by entities.ts, so a warm Lambda reuses the same sockets rather than negotiating new ones per invocation.

// config/dynamodb-config.ts - Shared client configuration
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Singleton pattern for connection reuse
class DynamoDBManager {
  private static instance: DynamoDBManager;
  private client: DynamoDBClient;
  private docClient: DynamoDBDocumentClient;

  private constructor() {
    this.client = new DynamoDBClient({
      region: process.env.AWS_REGION,
      // Connection settings for Lambda. requestHandler must be a single object;
      // writing it twice silently discards the first set of options.
      maxAttempts: 3,
      requestHandler: {
        connectionTimeout: 1000,
        socketTimeout: 1000,
        keepAlive: true,
        keepAliveMsecs: 1000,
        maxSockets: 50,
      },
    });

    this.docClient = DynamoDBDocumentClient.from(this.client, {
      marshallOptions: {
        removeUndefinedValues: true,
        convertEmptyValues: false,
        convertClassInstanceToMap: true,
      },
      unmarshallOptions: {
        wrapNumbers: false,
      },
    });
  }

  static getInstance(): DynamoDBManager {
    if (!DynamoDBManager.instance) {
      DynamoDBManager.instance = new DynamoDBManager();
    }
    return DynamoDBManager.instance;
  }

  getClient(): DynamoDBClient {
    return this.client;
  }

  getDocClient(): DynamoDBDocumentClient {
    return this.docClient;
  }
}

export const dynamoManager = DynamoDBManager.getInstance();
export const docClient = dynamoManager.getDocClient();

Query Optimization Patterns

Take pagination first. The AWS pagination page says Query results are “divided into ‘pages’ of data that are 1 MB in size (or less)” and that “A single Query only returns a result set that fits within the 1 MB size limit”. It then warns about the exact flag most cursor helpers export: “If LastEvaluatedKey is not empty, it does not necessarily mean that there is more data in the result set. The only way to know when you have reached the end of the result set is when LastEvaluatedKey is empty.” A page “can return zero matching items and still include a LastEvaluatedKey”, because the API reference defines Limit as “The maximum number of items to evaluate (not necessarily the number of matching items)” and a FilterExpression “is applied after a Query finishes, but before the results are returned”. So hasMore = !!LastEvaluatedKey is not a hasMore: it reports that the read stopped at a page boundary. Hand the key to the client as an opaque cursor and let the client stop when the cursor stops coming back.

Counting is the second correction. One Select: COUNT query is bounded by the same 1 MB page, so above that it answers about a page rather than about the partition. It is not a cheap read either: the Query API reference says COUNT “uses the same quantity of read capacity units as getting the items, and is subject to the same item size calculations”. What it saves is bytes on the wire. A correct count walks the pages and keeps a tally, which is what v2’s .paginate() is for, and the same docs page offers maxPages as “A ‘meta’ option provided by DynamoDB-Toolbox to send multiple requests in a single promise” when the walking should happen inside one call.

The fan-out helper is gone and stays gone. Nothing in the DynamoDB documentation presents N parallel queries as an optimisation, and each query still stops at its own 1 MB page, so there is no figure to attach to the pattern. Promise.all over several queries remains a reasonable shape when one request genuinely needs several partitions; it just is not a documented performance technique, and calling it one was the original mistake.

// patterns/query-optimization.ts - paging and counting on the v2 Query action
import { QueryCommand } from 'dynamodb-toolbox/table/actions/query';
import {
  MainTable,
  MembershipEntity,
  type Membership,
} from '../database/entities';

const encodeCursor = (key: Record<string, unknown>) =>
  Buffer.from(JSON.stringify(key)).toString('base64url');

const decodeCursor = (cursor: string) =>
  JSON.parse(Buffer.from(cursor, 'base64url').toString());

export async function listMemberships(
  userId: string,
  options: { pageSize?: number; cursor?: string } = {},
): Promise<{ items: Membership[]; nextCursor?: string }> {
  const { Items = [], LastEvaluatedKey } = await MainTable.build(QueryCommand)
    .query({ index: 'GSI1', partition: `USER#${userId}` })
    .entities(MembershipEntity)
    .options({
      limit: options.pageSize ?? 20,
      exclusiveStartKey: options.cursor
        ? decodeCursor(options.cursor)
        : undefined,
    })
    .send();

  // Deliberately not a `hasMore` flag. A key here means the read stopped at a
  // page boundary; only an absent key proves the end of the result set.
  return {
    items: Items,
    nextCursor: LastEvaluatedKey ? encodeCursor(LastEvaluatedKey) : undefined,
  };
}

// COUNT costs the same read capacity as fetching the items, and one call is
// bounded by the same 1 MB page, so the tally has to walk.
export async function countMemberships(userId: string): Promise<number> {
  const command = MainTable.build(QueryCommand)
    .query({ index: 'GSI1', partition: `USER#${userId}` })
    .options({ select: 'COUNT' });

  let total = 0;
  for await (const page of command.paginate()) {
    total += page.Count ?? 0;
  }

  return total;
}

Testing Strategies

Local Testing with DynamoDB Local

// tests/setup/dynamodb-local.ts - DynamoDB Local harness for integration tests
import { spawn, ChildProcess } from 'child_process';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';

// The entity file reads the environment the moment it is imported, so the Jest
// setup file sets AWS_REGION, fake credentials, MAIN_TABLE_NAME and
// DYNAMODB_ENDPOINT before any test pulls the entities in. The harness reads
// the same two variables, so it cannot create a table the entities never use.
const TABLE_NAME = process.env.MAIN_TABLE_NAME!;
const ENDPOINT = process.env.DYNAMODB_ENDPOINT!;

export class DynamoDBLocalTestEnvironment {
  private dynamoProcess: ChildProcess | null = null;
  private client: DynamoDBClient;

  constructor() {
    this.client = new DynamoDBClient({
      region: process.env.AWS_REGION,
      endpoint: ENDPOINT,
      credentials: {
        accessKeyId: 'fake',
        secretAccessKey: 'fake',
      },
    });
  }

  async start(): Promise<void> {
    return new Promise((resolve, reject) => {
      // Start DynamoDB Local
      this.dynamoProcess = spawn('java', [
        '-Djava.library.path=./DynamoDBLocal_lib',
        '-jar', 'DynamoDBLocal.jar',
        '-sharedDb',
        '-port', new URL(ENDPOINT).port,
      ], {
        cwd: './dynamodb-local',
        stdio: 'pipe',
      });

      this.dynamoProcess.stdout?.on('data', (data) => {
        if (data.toString().includes('Initializing DynamoDB Local')) {
          resolve();
        }
      });

      this.dynamoProcess.on('error', reject);

      // Timeout after 10 seconds
      setTimeout(() => reject(new Error('DynamoDB Local startup timeout')), 10000);
    });
  }

  // Both indexes are created, because the user entity links GSI2 as well and a
  // username lookup fails against a table that only carries GSI1.
  async createTable(): Promise<void> {
    const createTableCommand = new CreateTableCommand({
      TableName: TABLE_NAME,
      KeySchema: [
        { AttributeName: 'PK', KeyType: 'HASH' },
        { AttributeName: 'SK', KeyType: 'RANGE' },
      ],
      AttributeDefinitions: [
        { AttributeName: 'PK', AttributeType: 'S' },
        { AttributeName: 'SK', AttributeType: 'S' },
        { AttributeName: 'GSI1PK', AttributeType: 'S' },
        { AttributeName: 'GSI1SK', AttributeType: 'S' },
        { AttributeName: 'GSI2PK', AttributeType: 'S' },
        { AttributeName: 'GSI2SK', AttributeType: 'S' },
      ],
      GlobalSecondaryIndexes: [
        {
          IndexName: 'GSI1',
          KeySchema: [
            { AttributeName: 'GSI1PK', KeyType: 'HASH' },
            { AttributeName: 'GSI1SK', KeyType: 'RANGE' },
          ],
          Projection: { ProjectionType: 'ALL' },
        },
        {
          IndexName: 'GSI2',
          KeySchema: [
            { AttributeName: 'GSI2PK', KeyType: 'HASH' },
            { AttributeName: 'GSI2SK', KeyType: 'RANGE' },
          ],
          Projection: { ProjectionType: 'ALL' },
        },
      ],
      BillingMode: 'PAY_PER_REQUEST',
    });

    await this.client.send(createTableCommand);
  }

  async cleanup(): Promise<void> {
    try {
      await this.client.send(new DeleteTableCommand({
        TableName: TABLE_NAME,
      }));
    } catch (error) {
      // Table might not exist
    }

    if (this.dynamoProcess) {
      this.dynamoProcess.kill();
      this.dynamoProcess = null;
    }
  }
}

Integration Tests

// tests/integration/user-service.test.ts - Integration coverage for UserService
import { describe, beforeAll, afterAll, beforeEach, test, expect } from '@jest/globals';
import { randomUUID } from 'node:crypto';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { DynamoDBLocalTestEnvironment } from '../setup/dynamodb-local';
import { UserService } from '../../services/user-service';

describe('UserService Integration Tests', () => {
  let testEnv: DynamoDBLocalTestEnvironment;
  let userService: UserService;

  beforeAll(async () => {
    testEnv = new DynamoDBLocalTestEnvironment();
    await testEnv.start();
    await testEnv.createTable();
    userService = new UserService();
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });

  beforeEach(async () => {
    // Clean up between tests
    // Implementation depends on your cleanup strategy
  });

  test('should create user with validation', async () => {
    const userData = {
      userId: 'test-user-1',
      email: '[email protected]',
      username: 'testuser',
      firstName: 'Test',
      lastName: 'User',
    };

    const user = await userService.createUser({
      ...userData,
      idempotencyKey: randomUUID(),
    });

    expect(user).toBeDefined();
    expect(user.userId).toBe(userData.userId);
    expect(user.email).toBe(userData.email);
    expect(user.version).toBe(1);
    // v2 keeps its own `created` and `modified` attributes when timestamps are on
    expect(user.created).toBeDefined();
  });

  test('should prevent duplicate email registration', async () => {
    const userData1 = {
      userId: 'user1',
      email: '[email protected]',
      username: 'user1',
    };

    const userData2 = {
      userId: 'user2',
      email: '[email protected]', // Same email
      username: 'user2',
    };

    await userService.createUser({ ...userData1, idempotencyKey: randomUUID() });

    // A separate key, so this is a second registration rather than a retry.
    // The transaction is cancelled, and CancellationReasons names the culprit
    await expect(
      userService.createUser({ ...userData2, idempotencyKey: randomUUID() }),
    ).rejects.toThrow('Already taken: email');
  });

  test('should handle concurrent updates with optimistic locking', async () => {
    // Create user
    const user = await userService.createUser({
      userId: 'concurrent-test',
      email: '[email protected]',
      username: 'concurrent',
      idempotencyKey: randomUUID(),
    });

    // Simulate concurrent updates
    const update1Promise = userService.updateUser(user.userId, {
      firstName: 'Update1',
    }, user.version);

    const update2Promise = userService.updateUser(user.userId, {
      firstName: 'Update2',
    }, user.version);

    // One should succeed, one should fail
    const results = await Promise.allSettled([update1Promise, update2Promise]);

    const successes = results.filter(r => r.status === 'fulfilled');
    const failures = results.filter(r => r.status === 'rejected');

    expect(successes).toHaveLength(1);
    expect(failures).toHaveLength(1);
    expect(failures[0].reason).toBeInstanceOf(ConditionalCheckFailedException);
  });

  // The email lookup reads GSI1, and an index read is eventually consistent, so
  // the test polls a bounded window instead of demanding an answer on the first
  // try after the commit.
  async function findUserByEmail(email: string, timeoutMs = 5000) {
    const deadline = Date.now() + timeoutMs;

    while (Date.now() < deadline) {
      const user = await userService.getUserByEmail(email);
      if (user) {
        return user;
      }

      await new Promise(resolve => setTimeout(resolve, 100));
    }

    return null;
  }

  test('should query users by email efficiently', async () => {
    const userData = {
      userId: 'query-test',
      email: '[email protected]',
      username: 'queryuser',
    };

    await userService.createUser({ ...userData, idempotencyKey: randomUUID() });

    const foundUser = await findUserByEmail('[email protected]');

    expect(foundUser).not.toBeNull();
    expect(foundUser!.userId).toBe(userData.userId);
  });
});

What Type Safety Buys

Typed entities and schema validation carry most of the weight. Together they remove a family of defects while the code is still being written, long before a record can be corrupted:

  • Malformed emails and out-of-range usernames are rejected before the write goes out.
  • A new field can be added to an entity without an update quietly replacing the record.
  • Concurrent writers are serialized by the version attribute instead of clobbering each other.
  • Every access pattern is named in one file, so an inefficient query is visible in review.

The secondary wins are smaller but real. A shared client keeps connections warm across invocations, and the entity definition doubles as documentation of the table for whoever joins next.

Practical Lessons

1. Start with Entities, Not Tables

Don’t design your DynamoDB table first. Design your entities and access patterns, then build your table structure around them.

2. Validation is Your Best Friend

Every entity should carry its own validators. In v2 they are chainable methods on the schema, so the rule lives next to the attribute it guards instead of in a service two files away.

3. Always Use Optimistic Locking

Concurrent updates will happen. Plan for them from day one with version fields and optimistic locking.

4. Test with Real Data Patterns

Unit tests are great, but integration tests with realistic data volumes catch the real issues.

5. Monitor Query Performance

DynamoDB Toolbox makes querying easy - maybe too easy. Monitor your read/write units and optimize expensive queries.

Migration Strategy from Raw SDK

If you’re currently using raw DynamoDB SDK, here’s how to migrate safely:

Phase 1: Parallel Implementation

// Implement new operations alongside existing ones
import { GetItemCommand } from 'dynamodb-toolbox/entity/actions/get';
import { UserEntity } from '../database/entities';

class UserRepository {
  // Old method (keep for now)
  async getUserOld(userId: string) {
    const params = {
      TableName: 'Users',
      Key: { PK: { S: `USER#${userId}` }, SK: { S: `USER#${userId}` } }
    };
    return await this.dynamoClient.getItem(params).promise();
  }

  // New method with DynamoDB Toolbox
  async getUser(userId: string) {
    const { Item } = await UserEntity.build(GetItemCommand)
      .key({ userId })
      .send();

    return Item ?? null;
  }
}

Phase 2: Feature Flagged Rollout

// Use feature flags to gradually switch
const useNewRepository = process.env.USE_DYNAMODB_TOOLBOX === 'true';

const user = useNewRepository
  ? await userRepo.getUser(userId)
  : await userRepo.getUserOld(userId);

Phase 3: Full Migration

Once confident in the new implementation, remove old code and clean up.

When It Fits

DynamoDB Toolbox earns its place when you own the table, the access patterns are reasonably settled, and the team already writes TypeScript. Under those conditions the entity file becomes the single place where key construction, defaults, and marshalling live, and the cost of a schema change drops to editing one definition.

Two situations argue for staying with raw commands. If the access patterns are still moving every week, the entity definitions become churn layered on churn, and a thin helper around the DocumentClient is cheaper to maintain. If the service is one Lambda with two queries, the setup cost is larger than the problem you are solving.

Settle the version before writing any code. Everything above holds for v2; on 0.x or v1 the entity definitions are shaped differently enough that the snippets will not compile.

References

Related posts