Advanced Topics
This guide covers rich update operations, batch operations, multi-table design, and the low-level expression builder API.
Updating Items
Section titled “Updating Items”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.
Removing Attributes
Section titled “Removing Attributes”Deletes attributes entirely from the item:
// #region remove yield* db.entities.Products.update({ productId: "p-1" }).remove(["description"])Atomic Increment
Section titled “Atomic Increment”Atomically increments numeric attributes. Thread-safe — no read-modify-write cycle:
yield* db.entities.Products.update({ productId: "p-1" }).add({ viewCount: 1, stock: 50 })Atomic Decrement
Section titled “Atomic Decrement”Synthesizes SET #field = #field - :val (DynamoDB has no native subtract):
yield* db.entities.Products.update({ productId: "p-1" }).subtract({ stock: 3 })Appending to Lists
Section titled “Appending to Lists”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"], })Removing from Sets
Section titled “Removing from Sets”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"]), })Composing Multiple Update Types
Section titled “Composing Multiple Update Types”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)Working in Bulk
Section titled “Working in Bulk”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 deleteyield* 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.
Conditional Writes
Section titled “Conditional Writes”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"))Composing with Optimistic Locking
Section titled “Composing with Optimistic Locking”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)Handling ConditionalCheckFailed
Section titled “Handling ConditionalCheckFailed”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")))Creating Items Safely
Section titled “Creating Items Safely”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")), )Working with Multiple Tables
Section titled “Working with Multiple Tables”While single-table design is the default pattern, effect-dynamodb supports entities on different tables.
Declaring Multiple Tables
Section titled “Declaring Multiple Tables”const UserTable = Table.make({ schema: AppSchema, entities: { Users } })const AuditTable = Table.make({ schema: AppSchema, entities: { AuditEntries } })
// Provide physical table names at runtimeprogram.pipe( Effect.provide( Layer.mergeAll( DynamoClient.layer({ region: "us-east-1" }), UserTable.layer({ name: "Users" }), AuditTable.layer({ name: "AuditLog" }), ) ),)When to Use Multiple Tables
Section titled “When to Use Multiple Tables”| Single Table | Multiple Tables |
|---|---|
| Related entities queried together | Independent entity groups |
| Need cross-entity queries (collections) | No cross-entity query need |
| Fewer provisioned tables to manage | Different capacity settings per table |
| Index overloading (one GSI serves many patterns) | Simpler, entity-specific indexes |
Considerations
Section titled “Considerations”- 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.
Building Expressions
Section titled “Building Expressions”Entity-Level Expressions
Section titled “Entity-Level Expressions”Type-safe conditions, filters, and projections using PathBuilder:
// Condition — type-safe, nested paths, OR/NOT compositionProducts.condition((t, { eq, gt, and }) => and(eq(t.category, "peripherals"), gt(t.stock, 0)))
// Filter — shorthand applied to collection queriesconst filtered = yield* db.entities.Products.byCategory({ category: "peripherals" }) .filter({ name: "Widget Pro" }) .collect()
// Projection — select specific attributesconst projected = yield* db.entities.Products.scan() .select((t) => [t.name, t.price]) .collect()Low-Level Builders
Section titled “Low-Level Builders”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 expressionconst 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.
What’s Next?
Section titled “What’s Next?”- 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