DynamoDB deserves a real ORM

A strongly typed data modeler and active-record style ORM for DynamoDB — relationship-aware, schema-enforced, and fully transactional.

TypeScript-first
ACID transactions
Single-table
Minimal dependencies

DynamoDB with real relationships

DynamoDB offers unmatched scalability and performance. But relational modeling has traditionally required manual partition key design and hand-rolled adjacency lists — dyna-record manages both for you.

dyna-record combines single-table design with the adjacency list pattern for true relational modeling. Your data is pre-joined across partitions, giving you single-query retrieval without expensive joins.

@HasOne@HasMany@BelongsTo@HasAndBelongsToMany
typescript
@Entity
class Store extends StoreTable {
  declare readonly type: "Store";

  @StringAttribute({ alias: "Name" })
  public readonly name: string;

  @HasMany(() => Product, { foreignKey: "storeId" })
  public readonly products: Product[];
}

// Fetch with related data
const store = await Store.findById(id, {
  include: [{ association: "products" }]
});

A data modeler & ORM

Define your schema, enforce it at runtime, and model relationships — all with a familiar TypeScript interface.

Decorator-based models

Define entities with TypeScript decorators. String, number, boolean, date, and object attributes.

typescript
@Entity
class Product extends StoreTable {
  declare readonly type: "Product";

  @IdAttribute // Customizable unique ID field
  @StringAttribute({ alias: "Sku" })
  public readonly sku: string;

  @StringAttribute({ alias: "Category" })
  public readonly category: string;

  @NumberAttribute({ alias: "Price", nullable: true })
  public readonly price?: number;

  @BooleanAttribute({ alias: "InStock" })
  public readonly inStock: boolean;
}

Foreign key constraints

Referential integrity enforced through DynamoDB condition checks. Invalid references fail before the write.

typescript
// Fails: Store with ID '123' does not exist
await Product.create({ sku: "TRK-880", storeId: "123" });
// TransactionWriteFailedError

// @IdAttribute enforces uniqueness
await Product.create({ sku: "TRK-881", storeId }); // OK
await Product.create({ sku: "TRK-881", storeId }); // Fails

Expressive queries

Filter with $beginsWith, $contains, $or, and IN arrays — every condition validated against your entity definition.

typescript
const orders = await Store.query(storeId, {
  skCondition: { $beginsWith: "Order" },
  filter: {
    status: ["PENDING", "SHIPPED"],
    $or: [{ city: "NYC" }, { priority: true }]
  }
});

Compile + runtime safety

The type system rejects invalid input at compile time; runtime schema validation enforces the same contract on live data.

typescript
// Compile error — the field does not exist
await Product.create({ invalidField: "value" });

// Plain JavaScript callers hit the same contract
await Product.create(untypedInput);
// ValidationError from runtime schema validation
typescript
123456789101112131415161718192021222324@Entity
class Order extends StoreTable {
  declare readonly type: "Order";

  @ForeignKeyAttribute(() => Store, { alias: "StoreId" })
  public readonly storeId: ForeignKey<Store>;

  @BelongsTo(() => Store, { foreignKey: "storeId" })
  public readonly store: Store;

  @HasAndBelongsToMany(() => Product, {
    targetKey: "orders",
    through: () => ({ joinTable: OrderProduct, foreignKey: "orderId" })
  })
  public readonly products: Product[];
}

// Eager loading in one query
const order = await Order.findById(id, {
  include: [
    { association: "store" },
    { association: "products" }
  ]
});

Single-table design, without the complexity

dyna-record automatically manages partitions for each entity and handles denormalization across related partitions. Think of it as pre-joining your data.

All operations are ACID-compliant transactions. Updates propagate atomically to every denormalized copy, ensuring consistency.

Foreign key constraints enforced. Invalid references fail the transaction atomically with TransactionWriteFailedError— no partial writes.

Dive deeper into how dyna-record handles relational modeling on the blog →

Type-aware queries

Filter fields, SK conditions, and include options are all validated at compile time. Results narrow automatically based on your query.

Type-aware SK conditions

SK conditions are validated at compile time. Results automatically narrow to the matching entity type.

typescript
// Result type narrows to Order[]
const orders = await Store.query(storeId, {
  skCondition: { $beginsWith: "Order" }
});

// Exact match — narrows to Address[]
const address = await Store.query(storeId, {
  skCondition: "Address"
});

Type-checked filter building

Filter field names and value types are validated against your entity definition. Typos and type mismatches are compile errors.

typescript
// Field names autocomplete in your IDE
const results = await Store.query(storeId, {
  filter: {
    inStock: true,                   // boolean field
    category: { $contains: "Trail" },// string operation
    status: ["PENDING", "SHIPPED"]   // IN array
  }
});
// filter: { invalid: true } -> TS Error!

FindById with includes

Load relationships in a single query. The include option is type-checked against defined associations.

typescript
const store = await Store.findById(id, {
  include: [
    { association: "address" },  // HasOne
    { association: "products" }  // HasMany
  ]
});
// All loaded in one DynamoDB query

// { association: "typo" } -> TS Error!

Narrowed return types

Return types narrow based on your includes. Without includes, relationship fields are not accessible.

typescript
// Without includes
const store = await Store.findById(id);
store?.address; // TS Error — not included!

// With includes — the type narrows
const full = await Store.findById(id, {
  include: [{ association: "address" }]
});
full?.address.street; // OK — guaranteed

CRUD, simplified

Every operation is transactional, type-safe, and automatically manages timestamps and relationships.

Create

Insert with validation and foreign key checks

typescript
const product = await Product.create({
  sku: "TRK-880",
  description: "Waterproof hiking boots",
  category: "Footwear",
  storeId
});
// Returns Product with id, createdAt, updatedAt
Read

Retrieve by id or query, fully typed

typescript
// By ID
const product = await Product.findById(id);

// Query a partition with a filter
const products = await Store.query(storeId, {
  skCondition: { $beginsWith: "Product" },
  filter: { inStock: true }
});
Update

Partial updates with automatic updatedAt

typescript
await Product.update(id, {
  category: "Trail Running"
});
// Only the specified fields are updated
// updatedAt set automatically
// Searchable values re-embed in the same transaction
Delete

Remove with relationship cleanup

typescript
await Product.delete(id);
// Removes the entity
// Cleans up denormalized records
// Drops it from its vector index
// Transactional and atomic

Rich attribute types

Define your schema with decorators. Each type is validated at runtime and enforced at compile time — including discriminated unions with full type narrowing.

@StringAttribute()string
@NumberAttribute()number
@BooleanAttribute()boolean
@DateAttribute()Date
@ObjectAttribute()typed object / Map / discriminated union
@IdAttributeunique ID field

Most attributes support nullable: true for optional fields. Nullable attributes are typed as T | undefined.

typescript
const paymentSchema = {
  method: {
    type: "discriminatedUnion",
    discriminator: "type",
    variants: {
      creditCard: {
        cardNumber: { type: "string" },
        expiry: { type: "string" }
      },
      bankTransfer: {
        bankName: { type: "string" },
        accountNumber: { type: "string" }
      },
      crypto: {
        walletAddress: { type: "string" },
        network: {
          type: "enum",
          values: ["ethereum", "bitcoin", "solana"]
        }
      }
    }
  },
  amount: { type: "number" }
} as const satisfies ObjectSchema;

@Entity
class Order extends StoreTable {
  declare readonly type: "Order";

  @ObjectAttribute({ schema: paymentSchema, alias: "Payment" })
  public readonly payment: InferObjectSchema<typeof paymentSchema>;
}

// Full type narrowing from your schema
const result = await Order.findById("123");

if (result?.payment.method.type === "crypto") {
  result.payment.method.walletAddress; // string
  result.payment.method.network;       // "ethereum" | "bitcoin" | "solana"
}

Get started in minutes

One base table class, one entity, and you're ready.

typescript
123456789101112131415161718192021222324252627282930313233343536373839404142434445import DynaRecord, {
  Table, Entity, PartitionKeyAttribute, SortKeyAttribute,
  StringAttribute, HasMany, BelongsTo, ForeignKeyAttribute,
  type PartitionKey, type SortKey, type ForeignKey
} from "dyna-record";

@Table({ name: "online-store" })
abstract class StoreTable extends DynaRecord {
  @PartitionKeyAttribute({ alias: "PK" })
  public readonly pk: PartitionKey;

  @SortKeyAttribute({ alias: "SK" })
  public readonly sk: SortKey;
}

@Entity
class Store extends StoreTable {
  declare readonly type: "Store";

  @StringAttribute({ alias: "Name" })
  public readonly name: string;

  @HasMany(() => Order, { foreignKey: "storeId" })
  public readonly orders: Order[];
}

@Entity
class Order extends StoreTable {
  declare readonly type: "Order";

  @ForeignKeyAttribute(() => Store, { alias: "StoreId" })
  public readonly storeId: ForeignKey<Store>;

  @BelongsTo(() => Store, { foreignKey: "storeId" })
  public readonly store: Store;
}

// Create with enforced foreign key constraints
const store = await Store.create({ name: "Trailhead" });
const order = await Order.create({ storeId: store.id });

// Fetch with relationships in a single query
const withOrders = await Store.findById(store.id, {
  include: [{ association: "orders" }]
});