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 Teacher extends MyTable {
  @StringAttribute()
  public name: string;

  @HasMany(() => Course, { foreignKey: "teacherId" })
  public readonly courses: Course[];
}

// Fetch with related data
const teacher = await Teacher.findById(id, {
  include: [{ association: "courses" }]
});

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 User extends MyTable {
  @IdAttribute // Customizable unique ID field
  @StringAttribute()
  public email: string;

  @StringAttribute({ alias: "Username" })
  public username: string;

  @NumberAttribute({ nullable: true })
  public age?: number;

  @BooleanAttribute()
  public isActive: boolean;
}

Foreign key constraints

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

typescript
// Fails: User with ID '123' does not exist
await Order.create({ userId: "123" });
// TransactionWriteFailedError

// @IdAttribute enforces uniqueness
await User.create({ email: "alice@co.com" }); // OK
await User.create({ email: "alice@co.com" }); // Fails

Expressive queries

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

typescript
const orders = await Customer.query(customerId, {
  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 User.create({ invalidField: "value" });

// Plain JavaScript callers hit the same contract
await User.create(untypedInput);
// ValidationError from runtime schema validation
typescript
12345678910111213141516171819202122@Entity
class Order extends MyTable {
  @ForeignKeyAttribute(() => User)
  public readonly userId: ForeignKey<User>;

  @BelongsTo(() => User, { foreignKey: "userId" })
  public readonly user: User;

  @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: "user" },
    { 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 Customer.query(customerId, {
  skCondition: { $beginsWith: "Order" }
});

// Exact match — narrows to Invoice[]
const invoices = await Customer.query(customerId, {
  skCondition: "Invoice"
});

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 Student.query(pk, {
  filter: {
    isActive: true,              // boolean field
    name: { $contains: "Alice" },// string operation
    status: ["ENROLLED", "GRADUATED"] // 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 course = await Course.findById(id, {
  include: [
    { association: "teacher" },     // HasOne
    { association: "assignments" }  // 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 course = await Course.findById(id);
course?.teacher; // TS Error — not included!

// With includes — the type narrows
const full = await Course.findById(id, {
  include: [{ association: "teacher" }]
});
full?.teacher.name; // OK — teacher is 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 student = await Student.create({
  username: "alice",
  email: "alice@school.edu"
});
// Returns Student with id, createdAt, updatedAt
Read

Retrieve by id or query, fully typed

typescript
// By ID
const student = await Student.findById(id);

// Query with filter
const students = await Student.query("123", {
  filter: { isActive: true }
});
Update

Partial updates with automatic updatedAt

typescript
await Student.update(id, {
  email: "newemail@school.edu"
});
// Only the specified fields are updated
// updatedAt set automatically
Delete

Remove with relationship cleanup

typescript
await Student.delete(id);
// Removes the entity
// Cleans up denormalized records
// 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 Payment extends MyTable {
  @ObjectAttribute(paymentSchema)
  public payment: InferObjectSchema<typeof paymentSchema>;
}

// Full type narrowing from your schema
const result = await Payment.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
1234567891011121314151617181920212223242526272829303132333435363738394041import DynaRecord, {
  Table, Entity, PartitionKeyAttribute, SortKeyAttribute,
  StringAttribute, HasMany, BelongsTo, ForeignKeyAttribute,
  type PartitionKey, type SortKey, type ForeignKey
} from "dyna-record";

@Table({ name: "my-table" })
abstract class MyTable extends DynaRecord {
  @PartitionKeyAttribute({ alias: "PK" })
  public readonly pk: PartitionKey;

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

@Entity
class User extends MyTable {
  @StringAttribute()
  public name: string;

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

@Entity
class Order extends MyTable {
  @ForeignKeyAttribute(() => User)
  public readonly userId: ForeignKey<User>;

  @BelongsTo(() => User, { foreignKey: "userId" })
  public readonly user: User;
}

// Create with enforced foreign key constraints
const user = await User.create({ name: "Alice" });
const order = await Order.create({ userId: user.id });

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