Example: CRUD Operations
A comprehensive example demonstrating the full effect-dynamodb high-level API. Starting from model definitions through to advanced lifecycle features, this example covers every major operation you will use when building applications.
What you’ll learn:
- Creating items with
putand reading withget - Updates with fluent builders:
update(key).set(...).expectedVersion(n) - Error handling with tagged errors (
ItemNotFound,OptimisticLockError) - Index queries with fluent combinators (
reverse,filter,collect) - Atomic transactions with
Transaction.transactWriteandtransactGet - Sparse GSI behavior and the all-or-none update constraint
- Version history with
versioned: { retain: true } - Soft delete, restore, and purge
Step 1: Define Models
Section titled “Step 1: Define Models”Pure domain models with no DynamoDB concepts:
const Role = { Admin: "admin", Member: "member" } as constconst RoleSchema = Schema.Literals(Object.values(Role))
const TaskStatus = { Todo: "todo", InProgress: "in-progress", Done: "done" } as constconst TaskStatusSchema = Schema.Literals(Object.values(TaskStatus))
class User extends Schema.Class<User>("User")({ userId: Schema.String, email: Schema.String, displayName: Schema.NonEmptyString, role: RoleSchema, createdBy: Schema.String,}) {}
const UserModel = DynamoModel.configure(User, { createdBy: { immutable: true },})
class Task extends Schema.Class<Task>("Task")({ taskId: Schema.String, userId: Schema.String, title: Schema.NonEmptyString, status: TaskStatusSchema, priority: Schema.Number,}) {}createdBy is marked immutable — once set during creation, updates cannot change it.
Step 2: Entities and Table
Section titled “Step 2: Entities and Table”Entities define their primary key and GSI indexes:
const AppSchema = DynamoSchema.make({ name: "crud-demo", version: 1 })
const Users = Entity.make({ model: UserModel, entityType: "User", primaryKey: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byRole: { name: "gsi1", pk: { field: "gsi1pk", composite: ["role"] }, sk: { field: "gsi1sk", composite: ["userId"] }, }, }, timestamps: true, versioned: { retain: true },})
const Tasks = Entity.make({ model: Task, entityType: "Task", primaryKey: { pk: { field: "pk", composite: ["taskId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byUser: { name: "gsi1", pk: { field: "gsi1pk", composite: ["userId"] }, sk: { field: "gsi1sk", composite: ["status", "taskId"] }, }, }, timestamps: true, versioned: true, softDelete: true,})
const MainTable = Table.make({ schema: AppSchema, entities: { Users, Tasks },})Key configuration:
- Users have
versioned: { retain: true }— every mutation creates a version snapshot in the same partition, enabling full audit history byRoleindex on Users defines the GSI access pattern for querying users by role- Tasks have
softDelete: true— deleting archives the item rather than destroying it byUserindex on Tasks hasstatusandtaskIdin the sort key composite, so tasks for a user are sorted by status
Step 3: Typed Client and Create Items
Section titled “Step 3: Typed Client and Create Items”Before calling any entity operations, obtain a typed client via DynamoClient.make({ entities }) to resolve the DynamoClient and TableConfig dependencies. The returned client provides bound entities (and, when entities declare a collection on their indexes, auto-discovered collection accessors) where all operations have R = never. None of the entities in this tutorial declare a collection, so db.collections is empty here — see the Collections guide for the auto-discovery pattern.
// Typed execution gateway — binds all entities and collectionsconst db = yield* DynamoClient.make({ entities: { Users, Tasks, Projects, Employees },})Create items with put — returns the model type (e.g. User):
// Default: yield* returns the model type (User) — clean, no system fieldsconst alice = yield* db.entities.Users.put({ userId: "u-alice", email: "alice@example.com", displayName: "Alice", role: "admin", createdBy: "system",})// alice: User — has userId, email, displayName, role, createdBy
// For asRecord decode mode, use entity definitions with Effect.provideconst bob = yield* db.entities.Users.put({ userId: "u-bob", email: "bob@example.com", displayName: "Bob", role: "member", createdBy: "system",})The same pattern applies to Tasks:
// Tasks: bare yield* returns Taskconst task1 = yield* db.entities.Tasks.put({ taskId: "t-1", userId: "u-alice", title: "Design the API", status: "done", priority: 1,})
yield* db.entities.Tasks.put({ taskId: "t-2", userId: "u-alice", title: "Write tests", status: "in-progress", priority: 2,})Step 4: Reading Items
Section titled “Step 4: Reading Items”get returns the model type by default:
// Default: clean domain type — no keys, no system fields, no __edd_e__const userModel = yield* db.entities.Users.get({ userId: "u-alice" })// userModel: UserStep 5: Updates with Variadic Combinators
Section titled “Step 5: Updates with Variadic Combinators”update(key) returns a BoundUpdate builder. Chain .set(...), .expectedVersion(n), .condition(...), .remove([...]), and other combinators as methods:
// #region update // db.entities.Users.update(key).set(...) — update via bound entity const updatedAlice = yield* db.entities.Users.update({ userId: "u-alice" }).set({ role: "member", displayName: "Alice W.", }) // updatedAlice: User — clean model type
// Second update const updatedAlice2 = yield* db.entities.Users.update({ userId: "u-alice" }).set({ displayName: "Alice Wu", }) // updatedAlice2.createdBy → "system" (immutable — unchanged)Step 6: Optimistic Locking
Section titled “Step 6: Optimistic Locking”.expectedVersion(n) chains with .set(...) on the builder. If the item’s current version does not match, the update fails with OptimisticLockError:
// #region locking // .expectedVersion(n) composes with .set(...) const lockResult = yield* db.entities.Users.update({ userId: "u-alice" }) .set({ displayName: "Alice Wrong" }) .expectedVersion(1) // version is now 3 — this will fail .asEffect() .pipe( Effect.catchTag("OptimisticLockError", (e) => Effect.succeed(`Expected v${e.expectedVersion}, item has been updated`), ), )Step 7: Error Handling
Section titled “Step 7: Error Handling”All errors are tagged, enabling precise catchTag handling. ItemNotFound is the most common:
// Bound methods return Effects — error types flow through pipeconst notFoundResult = yield* db.entities.Users.get({ userId: "u-nonexistent" }).pipe( Effect.map((u) => `Found: ${u.userId}`), Effect.catchTag("ItemNotFound", (e) => Effect.succeed(`Not found: entity=${e.entityType}, key=${JSON.stringify(e.key)}`), ),)Bound entity methods return Effect directly, so you can pipe to Effect combinators like Effect.map or Effect.catchTag without any conversion.
Step 8: Index Queries
Section titled “Step 8: Index Queries”Query an entity index by providing the partition key composites. Use .collect() to gather all results:
const aliceTasks = yield* db.entities.Tasks.byUser({ userId: "u-alice" }).collect()
const admins = yield* db.entities.Users.byRole({ role: "admin" }).collect()Query Combinators
Section titled “Query Combinators”Compose query behavior with fluent combinators:
// Reverse sort orderconst reversedTasks = yield* db.entities.Tasks.byUser({ userId: "u-alice" }).reverse().collect()
// Filter expression (post-read filter)const todoTasks = yield* db.entities.Tasks.byUser({ userId: "u-alice" }) .filter({ status: "todo" }) .collect().reverse() flips the scan direction. .filter() adds a post-read filter expression — here, only tasks with status: "todo" are returned.
Step 9: Atomic Transactions
Section titled “Step 9: Atomic Transactions”transactWrite
Section titled “transactWrite”Atomically create multiple items across entities. If either fails, neither is persisted:
yield* Transaction.transactWrite([ Users.put({ userId: "u-charlie", email: "charlie@example.com", displayName: "Charlie", role: "member", createdBy: "admin", }), Tasks.put({ taskId: "t-5", userId: "u-charlie", title: "Onboarding checklist", status: "todo", priority: 1, }),])transactGet
Section titled “transactGet”Fetch multiple items atomically with a typed tuple return:
// transactGet — typed tuple return, no manual cast neededconst [txAlice, txBob, txTask] = yield* Transaction.transactGet([ Users.get({ userId: "u-alice" }), Users.get({ userId: "u-bob" }), Tasks.get({ taskId: "t-1" }),])// txAlice: User | undefined, txBob: User | undefined, txTask: Task | undefinedEach element in the returned tuple is typed to its entity’s model, or undefined if the item was not found.
Step 10: Sparse GSI Behavior
Section titled “Step 10: Sparse GSI Behavior”When GSI composite attributes are optional, DynamoDB’s sparse index behavior applies — items only appear in the index when their key attributes are present:
class Project extends Schema.Class<Project>("Project")({ projectId: Schema.String, name: Schema.NonEmptyString, // Optional — only assigned projects have these ownerId: Schema.optional(Schema.String), department: Schema.optional(Schema.String),}) {}
const Projects = Entity.make({ model: Project, entityType: "Project", primaryKey: { pk: { field: "pk", composite: ["projectId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byOwner: { name: "gsi1", pk: { field: "gsi1pk", composite: ["ownerId"] }, sk: { field: "gsi1sk", composite: ["department"] }, }, }, timestamps: true,})Put items with and without optional GSI composites:
// Assigned project — appears in ProjectsByOwner collectionconst assigned = yield* db.entities.Projects.put({ projectId: "p-1", name: "Alpha", ownerId: "u-alice", department: "engineering",})
// Unassigned project — does NOT appear in ProjectsByOwner (sparse)const unassigned = yield* db.entities.Projects.put({ projectId: "p-2", name: "Beta", // ownerId and department omitted})
// Query only returns assigned projectsconst ownerProjects = yield* db.entities.Projects.byOwner({ ownerId: "u-alice" }).collect()// → [{ projectId: "p-1", name: "Alpha" }]// "Beta" is absent — sparse index at workAll-or-None Update Constraint
Section titled “All-or-None Update Constraint”When updating, if the payload includes ANY composite for a GSI, ALL composites for that GSI must be present. This prevents stale GSI entries:
// #region sparse-update // GOOD: Update without touching GSI composites yield* db.entities.Employees.update({ employeeId: "e-1" }).set({ name: "Alice W." })
// GOOD: Update ALL composites of a GSI yield* db.entities.Employees.update({ employeeId: "e-1" }).set({ tenantId: "t-2", region: "eu-west-1", })
// BAD: Partial GSI composites — runtime ValidationError yield* db.entities.Employees.update({ employeeId: "e-1" }) .set({ tenantId: "t-3" }) // missing region! .asEffect() .pipe( Effect.catchTag("ValidationError", (e) => Effect.succeed(`Must provide both tenantId AND region, or neither`), ), )Step 11: Hard Delete
Section titled “Step 11: Hard Delete”For entities without softDelete, Entity.delete permanently removes the item:
yield* db.entities.Users.delete({ userId: "u-charlie" })
// Confirm deletionconst afterDelete = yield* db.entities.Users.get({ userId: "u-charlie" }).pipe( Effect.map(() => "still exists!"), Effect.catchTag("ItemNotFound", () => Effect.succeed("confirmed deleted")),)Step 12: Version History
Section titled “Step 12: Version History”With versioned: { retain: true }, every put and update creates a snapshot in the same DynamoDB partition. Retrieve individual versions or query the full history:
// Fetch a specific versionconst aliceV1 = yield* db.entities.Users.getVersion({ userId: "u-alice" }, 1)// aliceV1.displayName → "Alice", aliceV1.role → "admin"
const aliceV2 = yield* db.entities.Users.getVersion({ userId: "u-alice" }, 2)// aliceV2.displayName → "Alice W.", aliceV2.role → "member"
// Query all version snapshots — fluent BoundQueryconst allVersions = yield* db.entities.Users.versions({ userId: "u-alice" }).collect()// → [{ version: 1, ... }, { version: 2, ... }, { version: 3, ... }]
// Non-existent version → ItemNotFoundyield* db.entities.Users.getVersion({ userId: "u-alice" }, 99).pipe( Effect.catchTag("ItemNotFound", () => Effect.succeed("not found")),)Version snapshots share the primary partition key but use a version-specific sort key. GSI keys are stripped from snapshots so they do not appear in index queries.
Step 13: Soft Delete, Restore, and Purge
Section titled “Step 13: Soft Delete, Restore, and Purge”Soft Delete
Section titled “Soft Delete”For entities with softDelete: true, Entity.delete archives the item instead of destroying it. GSI keys are stripped, so the item vanishes from all index queries:
yield* db.entities.Tasks.delete({ taskId: "t-1" })
// No longer in collection queriesconst aliceTasksAfterDelete = yield* db.entities.Tasks.byUser({ userId: "u-alice" }).collect()// t-1 is absent
// But retrievable via deleted.getconst deletedTask = yield* db.entities.Tasks.deleted.get({ taskId: "t-1" })// deletedTask.title → "Design the API"Restore
Section titled “Restore”Restore recomposes all keys, increments the version, and brings the item back to life:
const restored = yield* db.entities.Tasks.restore({ taskId: "t-1" })// restored.title → "Design the API"// restored.status → "done"
// Back in collection queriesconst aliceTasksAfterRestore = yield* db.entities.Tasks.byUser({ userId: "u-alice" }).collect()// t-1 is backPurge permanently removes everything for a partition key — the current item, all version snapshots, and any soft-deleted copies:
yield* db.entities.Users.purge({ userId: "u-bob" })
// Completely gone — no item, no versions, no snapshotsyield* db.entities.Users.get({ userId: "u-bob" }).pipe( Effect.catchTag("ItemNotFound", () => Effect.succeed("completely gone")),)Purge is the nuclear option. Use it for GDPR “right to erasure” or test cleanup.
Running the Example
Section titled “Running the Example”The complete runnable example is at examples/crud.ts in the repository.
Start DynamoDB Local:
docker run -d -p 8000:8000 amazon/dynamodb-localRun the example:
npx tsx examples/crud.tsLayer Setup
Section titled “Layer Setup”const AppLayer = Layer.mergeAll( DynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }), MainTable.layer({ name: "crud-demo-table" }), ProjectTable.layer({ name: "crud-demo-table" }),)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then( () => console.log("Done."), (err) => console.error("Failed:", err),)Key Takeaways
Section titled “Key Takeaways”| Concept | How it’s used |
|---|---|
| Typed client | const db = yield* DynamoClient.make({ entities }) resolves dependencies; all ops have R = never |
| Fluent builder updates | db.entities.Users.update(key).set(...).expectedVersion(n) — chain combinators as methods on a BoundUpdate builder |
| Optimistic locking | .expectedVersion(n) on BoundUpdate — composes with .set(...), .condition(...) on the same builder |
| Tagged error handling | Effect.catchTag("ItemNotFound", ...) for precise recovery — bound methods return Effect directly |
| Index queries | db.entities.Tasks.byUser(composites).collect() returns results; .reverse(), .filter() for combinators |
| Transactions | transactWrite for atomic multi-entity creates; transactGet for typed tuple reads |
| Sparse GSI | Optional composites produce sparse indexes; all-or-none constraint prevents stale keys |
| Version history | versioned: { retain: true } creates snapshots; db.entities.Users.getVersion(key, n) and Users.versions(key) for retrieval |
| Soft delete + restore | softDelete: true archives items; db.entities.Tasks.restore(key) recomposes all keys |
| Purge | db.entities.Users.purge(key) permanently removes item + versions + soft-deleted copies |
What’s Next?
Section titled “What’s Next?”- Human Resources Example — Single-table design with 3 entities, 5 GSIs, collections, and transactions
- Modeling Guide — Deep dive into models, schemas, tables, and entities
- Lifecycle — Soft delete, versioning, and TTL
- Indexes & Collections — Access pattern design and collection patterns