Skip to content

Advanced Topics

This guide covers rich update operations, batch operations, multi-table design, and the low-level expression builder API.

Beyond .set(), effect-dynamodb provides combinators for all DynamoDB update expression types. Chain them on the BoundUpdate builder from db.entities.X.update(key) — they compile to one UpdateItem call.

Deletes attributes entirely from the item:

// #region remove
yield* db.entities.Products.update({ productId: "p-1" }).remove(["description"])

Atomically increments numeric attributes. Thread-safe — no read-modify-write cycle:

yield* db.entities.Products.update({ productId: "p-1" }).add({ viewCount: 1, stock: 50 })

Synthesizes SET #field = #field - :val (DynamoDB has no native subtract):

yield* db.entities.Products.update({ productId: "p-1" }).subtract({ stock: 3 })

Appends elements to the end of a list attribute using list_append:

// #region append
yield* db.entities.Products.update({ productId: "p-1" }).append({
tags: ["on-sale", "featured"],
})

Removes specific elements from a DynamoDB Set attribute (StringSet, NumberSet):

// #region delete-from-set
yield* db.entities.Products.update({ productId: "p-1" }).deleteFromSet({
categories: new Set(["obsolete"]),
})

All update combinators compose as arguments — they are merged into one UpdateItem call:

// #region composed-updates
yield* db.entities.Products.update({ productId: "p-1" })
.set({ name: "Premium Mouse", price: 39.99 })
.add({ viewCount: 10 })
.subtract({ stock: 5 })
.append({ tags: ["premium"] })
.remove(["description"])
.expectedVersion(5)

The Batch module provides batch operations that automatically handle DynamoDB limits (chunking) and retry unprocessed items:

// Batch write (auto-chunks at 25 items)
yield* Batch.write([
Users.put({ userId: "u-1", email: "a@b.com", displayName: "A" }),
Users.put({ userId: "u-2", email: "b@c.com", displayName: "B" }),
Users.put({ userId: "u-3", email: "c@d.com", displayName: "C" }),
])
// Batch get (auto-chunks at 100 items)
const [u1, u2, u3] = yield* Batch.get([
Users.get({ userId: "u-1" }),
Users.get({ userId: "u-2" }),
Users.get({ userId: "u-3" }),
])
// Batch delete
yield* Batch.write([Users.delete({ userId: "u-2" }), Users.delete({ userId: "u-3" })])

Note: Batch operations do not enforce unique constraints (no transactional guarantees). Use individual put/delete for entities with unique constraints.

Entity-level condition() adds a ConditionExpression to any mutation (put, update, delete). The condition is evaluated server-side — if it fails, the operation is rejected with ConditionalCheckFailed.

Conditional put — only if item doesn’t already exist:

// #region conditional-put
// Conditional put — only if item doesn't already exist
const condPutResult = yield* db.entities.Users.put({
userId: "u-10",
email: "new@example.com",
displayName: "New User",
})
.condition((t, { notExists }) => notExists(t.email))
.asEffect()
.pipe(
Effect.match({
onFailure: (e) => `${e._tag} — already exists`,
onSuccess: (u) => `created ${u.displayName}`,
}),
)

Conditional delete — only if a specific attribute value matches:

// #region conditional-delete
// Conditional delete — only if stock is zero
const condDeleteResult = yield* db.entities.Products.delete({ productId: "p-1" })
.condition((t, { eq }) => eq(t.stock, 0))
.asEffect()
.pipe(
Effect.match({
onFailure: (e) => `${e._tag} — stock not zero, delete prevented`,
onSuccess: () => "deleted (stock was 0)",
}),
)

Conditional update — only if status matches:

// #region conditional-update
// Conditional update — only if status is "active"
yield* db.entities.Tasks.update({ taskId: "t-1" })
.set({ status: "done" })
.condition((t, { eq }) => eq(t.status, "active"))

When using condition() on an update that also has Entity.expectedVersion(), the user condition is ANDed with the version check:

// #region optimistic-condition
yield* db.entities.Products.update({ productId: "p-1" })
.set({ price: 24.99 })
.expectedVersion(6)
.condition((t, { gt }) => gt(t.stock, 0))
// ConditionExpression: (#version = :v_lock) AND (#stock > :v0)

Use Entity.create() for typed ConditionalCheckFailed error handling — create() includes this error in its typed error channel:

// #region handle-conditional
const result = yield* db.entities.Users.create({
userId: "u-10",
email: "new@example.com",
displayName: "New User",
})
.asEffect()
.pipe(Effect.catchTag("ConditionalCheckFailed", () => Effect.succeed("already exists")))

Entity.create() is a convenience for “put if not exists” — it’s Entity.put() with an automatic attribute_not_exists condition on the primary key:

// #region create-safely
// Create — fails with ConditionalCheckFailed if item already exists
const user = yield* db.entities.Users.create({
userId: "u-20",
email: "alice@example.com",
displayName: "Alice",
})
// Duplicate — caught gracefully
const dup = yield* db.entities.Users.create({
userId: "u-20",
email: "alice@example.com",
displayName: "Alice",
})
.asEffect()
.pipe(
Effect.map(() => "created"),
Effect.catchTag("ConditionalCheckFailed", () => Effect.succeed("already exists")),
)

While single-table design is the default pattern, effect-dynamodb supports entities on different tables.

const UserTable = Table.make({ schema: AppSchema, entities: { Users } })
const AuditTable = Table.make({ schema: AppSchema, entities: { AuditEntries } })
// Provide physical table names at runtime
program.pipe(
Effect.provide(
Layer.mergeAll(
DynamoClient.layer({ region: "us-east-1" }),
UserTable.layer({ name: "Users" }),
AuditTable.layer({ name: "AuditLog" }),
)
),
)
Single TableMultiple Tables
Related entities queried togetherIndependent entity groups
Need cross-entity queries (collections)No cross-entity query need
Fewer provisioned tables to manageDifferent capacity settings per table
Index overloading (one GSI serves many patterns)Simpler, entity-specific indexes
  • Collections require the same table. All entities in a collection must share a table.
  • Transactions can span tables. DynamoDB transactions work across tables.
  • Each table is a separate DynamoDB resource. Separate provisioning, backups, and billing.

Type-safe conditions, filters, and projections using PathBuilder:

// Condition — type-safe, nested paths, OR/NOT composition
Products.condition((t, { eq, gt, and }) => and(eq(t.category, "peripherals"), gt(t.stock, 0)))
// Filter — shorthand applied to collection queries
const filtered = yield* db.entities.Products.byCategory({ category: "peripherals" })
.filter({ name: "Widget Pro" })
.collect()
// Projection — select specific attributes
const projected = yield* db.entities.Products.scan()
.select((t) => [t.name, t.price])
.collect()

The Expression module provides raw builders for advanced use cases:

// Condition expression (for conditional writes)
const cond = Expression.condition({
eq: { status: "active" },
gt: { stock: 0 },
})
// Filter expression (for query post-filtering)
const filter = Expression.filter({
between: { price: [10, 50] },
attributeExists: "category",
})
// Update expression
const upd = Expression.update({
set: { displayName: "New Name", updatedAt: new Date().toISOString() },
remove: ["temporaryFlag"],
add: { loginCount: 1 },
})

See the Expressions Guide for the complete reference with DynamoDB mapping tables, all operators, and PathBuilder documentation.

  • Expressions — Complete expression reference with DynamoDB mapping tables
  • DynamoDB Streams — Decode stream records into typed domain objects
  • Testing — Mock DynamoClient and test CRUD operations
  • Modeling — Model, schema, table, entity definitions