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 how updates recompose GSI keys
  • 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. 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,
}),
])

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

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 stored
yield* db.entities.Employees.update({ employeeId: "e-1" }).set({ name: "Alice W." })
// Touches both halves of byTenant — gsi1pk and gsi1sk are recomposed
yield* 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-1
yield* 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 byOwner
yield* 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.


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, upsert 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.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."))

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; updates recompose only the GSI key halves they touch, and .remove() drops a composite’s half
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