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 how updates recompose GSI keys
- 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. Because Users and Tasks are versioned, each put reads its item first, so the same call would also replace an existing user or task, continuing its version (and snapshotting the user), just as the entity’s own put does:
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 workUpdating GSI Composites
Section titled “Updating GSI Composites”An update never reads the stored item, so the library recomposes GSI keys from what the update carries: the payload plus the primary key. It does this per key half — partition key and sort key separately — and only for the halves the update touches. A half the update doesn’t touch keeps its stored value:
// Touches no byTenant composite — its keys are left as storedyield* db.entities.Employees.update({ employeeId: "e-1" }).set({ name: "Alice W." })
// Touches both halves of byTenant — gsi1pk and gsi1sk are recomposedyield* db.entities.Employees.update({ employeeId: "e-1" }).set({ tenantId: "t-2", region: "eu-west-1",})
// Touches only the partition half — gsi1pk moves to t-3, gsi1sk keeps eu-west-1yield* db.entities.Employees.update({ employeeId: "e-1" }).set({ tenantId: "t-3" })const inT3 = yield* db.entities.Employees.byTenant({ tenantId: "t-3" }).collect()// → [{ employeeId: "e-1", tenantId: "t-3", region: "eu-west-1", ... }]
// Removing the only partition composite removes gsi1pk — Alpha leaves byOwneryield* db.entities.Projects.update({ projectId: "p-1" }).remove(["ownerId"])const aliceProjects = yield* db.entities.Projects.byOwner({ ownerId: "u-alice" }).collect()// → []A partial update is not an error. When a touched half has only some of its composites, the outcome depends on which ones are missing:
- Trailing composites missing — the half is recomposed from the leading composites only, giving a shorter, coarser key.
- A leading composite missing — the half can’t be composed. By default (
indexPolicy'preserve') the stored half is left as it was and may now be stale; under'sparse'the half is removed and the item drops out of the index.
So when you change a composite, also pass the other composites of the same half. Index Policy has the full rules, including how several writers can share one item.
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, upsert 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.layerConfig({ region: Config.succeed("us-east-1"), endpoint: Config.String("DYNAMODB_ENDPOINT").pipe(Config.withDefault("http://localhost:8000")), credentials: Config.succeed({ 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."))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; updates recompose only the GSI key halves they touch, and .remove() drops a composite’s half |
| 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