Skip to content

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 put and reading with get
  • 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.transactWrite and transactGet
  • Sparse GSI behavior and the all-or-none update constraint
  • Version history with versioned: { retain: true }
  • Soft delete, restore, and purge

Pure domain models with no DynamoDB concepts:

const Role = { Admin: "admin", Member: "member" } as const
const RoleSchema = Schema.Literals(Object.values(Role))
const TaskStatus = { Todo: "todo", InProgress: "in-progress", Done: "done" } as const
const 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.


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
  • byRole index on Users defines the GSI access pattern for querying users by role
  • Tasks have softDelete: true — deleting archives the item rather than destroying it
  • byUser index on Tasks has status and taskId in the sort key composite, so tasks for a user are sorted by status

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 collections
const 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 fields
const 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.provide
const 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 Task
const 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,
})

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: User

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)

.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`),
),
)

All errors are tagged, enabling precise catchTag handling. ItemNotFound is the most common:

// Bound methods return Effects — error types flow through pipe
const 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.


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()

Compose query behavior with fluent combinators:

// Reverse sort order
const 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.


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,
}),
])

Fetch multiple items atomically with a typed tuple return:

// transactGet — typed tuple return, no manual cast needed
const [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 | undefined

Each element in the returned tuple is typed to its entity’s model, or undefined if the item was not found.


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 collection
const 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 projects
const ownerProjects = yield* db.entities.Projects.byOwner({ ownerId: "u-alice" }).collect()
// → [{ projectId: "p-1", name: "Alpha" }]
// "Beta" is absent — sparse index at work

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`),
),
)

For entities without softDelete, Entity.delete permanently removes the item:

yield* db.entities.Users.delete({ userId: "u-charlie" })
// Confirm deletion
const afterDelete = yield* db.entities.Users.get({ userId: "u-charlie" }).pipe(
Effect.map(() => "still exists!"),
Effect.catchTag("ItemNotFound", () => Effect.succeed("confirmed deleted")),
)

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 version
const 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 BoundQuery
const allVersions = yield* db.entities.Users.versions({ userId: "u-alice" }).collect()
// → [{ version: 1, ... }, { version: 2, ... }, { version: 3, ... }]
// Non-existent version → ItemNotFound
yield* 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.


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 queries
const aliceTasksAfterDelete = yield* db.entities.Tasks.byUser({ userId: "u-alice" }).collect()
// t-1 is absent
// But retrievable via deleted.get
const deletedTask = yield* db.entities.Tasks.deleted.get({ taskId: "t-1" })
// deletedTask.title → "Design the API"

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 queries
const aliceTasksAfterRestore = yield* db.entities.Tasks.byUser({ userId: "u-alice" }).collect()
// t-1 is back

Purge 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 snapshots
yield* 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.


The complete runnable example is at examples/crud.ts in the repository.

Start DynamoDB Local:

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local

Run the example:

Terminal window
npx tsx examples/crud.ts
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),
)

ConceptHow it’s used
Typed clientconst db = yield* DynamoClient.make({ entities }) resolves dependencies; all ops have R = never
Fluent builder updatesdb.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 handlingEffect.catchTag("ItemNotFound", ...) for precise recovery — bound methods return Effect directly
Index queriesdb.entities.Tasks.byUser(composites).collect() returns results; .reverse(), .filter() for combinators
TransactionstransactWrite for atomic multi-entity creates; transactGet for typed tuple reads
Sparse GSIOptional composites produce sparse indexes; all-or-none constraint prevents stale keys
Version historyversioned: { retain: true } creates snapshots; db.entities.Users.getVersion(key, n) and Users.versions(key) for retrieval
Soft delete + restoresoftDelete: true archives items; db.entities.Tasks.restore(key) recomposes all keys
Purgedb.entities.Users.purge(key) permanently removes item + versions + soft-deleted copies