FAQ & Troubleshooting
Common questions and solutions for working with effect-dynamodb.
How does the client gateway pattern work?
Section titled “How does the client gateway pattern work?”Entity definitions created with Entity.make() are not executable — they carry type information, schemas, and index config. To get executable operations, register entities on a table with Table.make({ schema, entities: {...} }), then call DynamoClient.make({ entities, tables }) to get a typed client. This resolves DynamoClient and TableConfig from the Effect context and returns bound operations with R = never.
The typed client provides executable operations:
const program = Effect.gen(function* () { const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable }, }) const users = db.entities.Users
const user = yield* users.get({ userId: "123" }) yield* users.put({ userId: "456", name: "Alice" }) yield* users.delete({ userId: "789" })})Bound methods return fluent builders (or Effect for simple reads), yieldable in Effect.gen. Chain combinators as methods:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})
// get() returns an Effect — Effect combinators work directlyyield* db.entities.Users.get({ userId: "123" }).pipe(Effect.map((u) => u.name))For updates with combinators, chain them on the builder:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})
yield* db.entities.Users.update({ userId: "123" }) .set({ name: "Alice" }) .expectedVersion(1)For Effect.map/Effect.catchTag/etc. after the builder, call .asEffect() first:
yield* db.entities.Users.update({ userId: "123" }) .set({ name: "Alice" }) .expectedVersion(1) .asEffect() .pipe(Effect.catchTag("OptimisticLockError", () => Effect.succeed(undefined)))Rule of thumb:
- Call
DynamoClient.make({ entities, tables })at the top of eachEffect.genblock — access entities viadb.entities.* - Use bound methods for all operations (
db.entities.Users.get,.put,.update,.delete,.upsert,.restore,.purge, etc.) - Chain combinators as methods on the builder (
.set(...).expectedVersion(3).condition(...)) yield*the builder to execute;.asEffect()for interop with Effect combinators
What are the 7 derived types?
Section titled “What are the 7 derived types?”Every entity automatically derives 7 types from your domain model and configuration:
| Type | Extractor | Description |
|---|---|---|
| Model | Entity.Model<E> | Pure domain object — exactly what your Schema.Class defines |
| Record | Entity.Record<E> | Domain fields + system metadata (version, createdAt, updatedAt) |
| Input | Entity.Input<E> | Creation input — model fields without system-managed fields |
| Update | Entity.Update<E> | Mutable fields only — keys and immutable fields excluded, all optional |
| Key | Entity.Key<E> | Primary key attributes — the fields needed for get, update, delete |
| Item | Entity.Item<E> | Full unmarshalled DynamoDB item including internal fields (__edd_e__, key fields) |
| Marshalled | Entity.Marshalled<E> | DynamoDB AttributeValue format — what gets sent to/from the wire |
Key relationships:
put()andcreate()acceptInputget(),update(),delete()acceptKeyget()andqueryreturnRecordby default (orModelviayield*)set()combinator on updates acceptsUpdate
How do GSI composite updates work?
Section titled “How do GSI composite updates work?”An update doesn’t read the stored item, so GSI keys are recomposed from what the update carries: the payload plus the primary key attributes. Each GSI key has two halves, the partition key and the sort key, and each half is handled on its own:
- The update doesn’t touch the half (none of its composites is in the payload, the primary key, or
.remove()): the stored half is left as it is. - All of the half’s composites are available: the half is recomposed in full.
- Only trailing composites are missing: the half is recomposed from the leading ones, a shorter and coarser key.
- A leading composite is missing: the half can’t be composed. Under the default
indexPolicy('preserve') the stored half is kept and may now be stale; under'sparse'it is removed and the item drops out of the index.
None of these is an error. Rule of thumb: when you change a composite, also pass the other composites of the same half.
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// byTenant: pk composite ["tenantId"], sk composite ["region", "office"]
// Partition half only — gsi1pk moves to t-new, the sort key is kept as storedyield* users.update({ userId: "u-1" }).set({ tenantId: "t-new" })
// Leading sort composite without the trailing one — gsi1sk is truncated to the regionyield* users.update({ userId: "u-1" }).set({ region: "eu-west-1" })
// Whole sort half — gsi1sk is recomposed in fullyield* users.update({ userId: "u-1" }).set({ region: "eu-west-1", office: "dublin" })What about Entity.remove()? Removing a GSI composite attribute recomposes the key half (pk or sk) that contains it without that attribute. If the half can no longer be composed (the removed attribute was a leading composite), the half is removed whatever its policy, and the item drops out of the index; if it was a trailing composite, the half is truncated. The other half is kept, so the item rejoins the index once a later write composes the removed half again. If SET and REMOVE target the same composite, removal wins.
See Index Policy for the full rules.
How does single-table design work?
Section titled “How does single-table design work?”Multiple entity types share one physical DynamoDB table. Three mechanisms make this work:
-
Entity type discriminator (
__edd_e__) — Every item carries a hidden__edd_e__attribute containing the entity type string (e.g.,"User","Order"). All queries automatically include aFilterExpressionon this field to isolate entity types. Two other hidden attributes may appear on raw items:__edd_i__, the incarnation token of a versioned entity’s item, and__edd_d__, the unique fields holding only a decoding default. Decoded models never include them. -
Structured key format — Keys follow the pattern
$schema#v1#entity_type#attr_value#attr_value. The schema name, version, and entity type prefix ensure that different entity types produce non-overlapping key spaces even when they share the same physical index. -
Index overloading — Generic GSI names (
gsi1,gsi2) serve different access patterns for different entity types. Each entity maps its logical index names (e.g.,byTenant,byProject) to physical GSI names.
// Both entities use gsi1 for different patternsconst Users = Entity.make({ // ... primaryKey: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] } }, indexes: { byTenant: { name: "gsi1", pk: { field: "gsi1pk", composite: ["tenantId"] }, sk: { field: "gsi1sk", composite: ["userId"] } }, },})
const Orders = Entity.make({ // ... primaryKey: { pk: { field: "pk", composite: ["orderId"] }, sk: { field: "sk", composite: [] } }, indexes: { byCustomer: { name: "gsi1", pk: { field: "gsi1pk", composite: ["customerId"] }, sk: { field: "gsi1sk", composite: ["createdAt"] } }, },})What’s the difference between put, create, and upsert?
Section titled “What’s the difference between put, create, and upsert?”| Operation | Behavior | On Duplicate Key | DynamoDB API |
|---|---|---|---|
put | Creates or overwrites | Replaces the existing item; on a versioned or unique-constrained entity it continues the item’s version and rotates its sentinels | PutItem (TransactWriteItems with sentinels or retain) |
create | Insert-only — put + automatic attribute_not_exists condition; never reads the item (a retain entity queries its version history: one Limit 1 query, plus a keys-only Limit 1 query for older history when the primary sort key has composites) | Fails with ConditionalCheckFailed | PutItem (with condition) |
upsert | Create-or-update | Creates if missing, updates the supplied fields if it exists | UpdateItem (with if_not_exists); reads first on a unique or retain entity, or when the input omits a defaulted index composite |
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// put: overwrites if existsyield* users.put({ userId: "u-1", name: "Alice" })
// create: fails if existsyield* users.create({ userId: "u-1", name: "Alice" }).pipe( Effect.catchTag("ConditionalCheckFailed", () => Effect.fail(new Error("User already exists")) ),)
// upsert: creates or updates// - Immutable fields and createdAt use if_not_exists (only set on first creation)// - Version is incremented atomically: if_not_exists(version, 0) + 1// - Returns the full record (ReturnValues: ALL_NEW)yield* users.upsert({ userId: "u-1", name: "Alice" })Note: On an entity with unique constraints or versioned: { retain: true }, upsert reads the item once first, because one UpdateItem can’t write, rotate or check a sentinel, or snapshot the item it replaces. A missing item is created with its sentinels and snapshot; an existing one is updated, with sentinels rotated for changed unique values and the replaced item snapshotted. Either way the whole input is validated. On other entities upsert is a single UpdateItem, unless the input omits a defaulted index composite: then it reads first too, so it stores the default only when it creates the item.
A put of a versioned or unique-constrained entity also reads first. Over an existing item it takes the next version, keeps the incarnation and createdAt, snapshots the replaced item (retain) and rotates changed sentinels, so it never resets the item to version 1. A concurrent write in between is retried: the last writer wins. Transaction.transactWrite and EventStore additionalItems write these puts the same way.
How do version snapshots work?
Section titled “How do version snapshots work?”When versioned: { retain: true } is configured, every write operation (put, update, delete) stores a snapshot of the item at that version.
const Users = Entity.make({ // ... versioned: { retain: true },})How it works:
- Snapshots share the same partition key (PK) as the main item but use a version sort key:
$schema#v1#entity#v#0000001(with sort key composites, the item’s own:$schema#v1#entity#v#line_a#0000001) - GSI key fields are stripped from snapshots so they do not appear in index queries, and primary-key queries and scans leave them out
- The
__edd_e__entity type is preserved for filtering - Retain-aware writes use
transactWriteItemsto atomically write both the main item and the snapshot
Accessing versions:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// Get a specific versionconst v2 = yield* users.getVersion({ userId: "u-1" }, 2)
// Query version history (most recent first)const history = yield* users.collect( Users.versions({ userId: "u-1" }), Query.reverse, Query.limit(10),)How does soft delete work?
Section titled “How does soft delete work?”When softDelete is configured, entity.delete() performs a logical deletion — the item becomes invisible to normal queries but remains in the table.
const Users = Entity.make({ // ... softDelete: true, // Or with auto-purge: // softDelete: { ttl: Duration.days(30) },})What happens on delete:
- The sort key is modified:
$schema#v1#userbecomes$schema#v1#user#deleted#2024-01-15T10:30:00Z - All GSI keys are removed — the item disappears from secondary indexes and collections
- A
deletedAttimestamp is added to the item - If
ttlis configured, a_ttlattribute is set for DynamoDB’s auto-expiry
Visibility:
| Access Path | Sees Deleted Items? |
|---|---|
entity.get(key) | No |
entity.query.byIndex(...) | No |
| Collection queries | No |
entity.deleted.get(key) | Yes |
entity.deleted.list(key) | Yes |
Recovery and cleanup:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// Read a deleted itemconst deleted = yield* users.deleted.get({ userId: "u-1" })
// Restore — recomposes all keys and re-establishes unique sentinelsyield* users.restore({ userId: "u-1" })
// Purge — permanently removes item + all versions + the unique sentinels it ownsyield* users.purge({ userId: "u-1" })When an entity has both softDelete and unique constraints, the preserveUnique option controls whether unique sentinels are freed on delete (default: false, sentinels are freed) or preserved until purge.
Troubleshooting
Section titled “Troubleshooting””Cannot use entity operation directly”
Section titled “”Cannot use entity operation directly””Symptom: Entity operations from Entity.make() cannot be yielded or executed directly.
Cause: Entity.make() returns a definition, not executable operations. You must use DynamoClient.make({ entities, tables }) to get a typed client first.
Solution: Use DynamoClient.make({ entities, tables }) to get a typed client with executable methods:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// BoundEntity methods are yieldable builders (or Effects for .get)yield* users.get({ userId: "123" }).pipe(Effect.map((u) => u.name))
// Updates are fluent: chain combinators as methodsyield* users.update({ userId: "123" }).set({ name: "Alice" })Item missing from a GSI query after an update
Section titled “Item missing from a GSI query after an update”Symptom: After an update, an item no longer shows up in a GSI query, or shows up under its old key values.
Cause: The update touched a GSI key half but didn’t carry all of that half’s composites. The library recomposes keys only from the update’s payload plus the primary key (it doesn’t read the stored item). With a leading composite missing, the half can’t be composed: under indexPolicy 'preserve' (the default) the stored half is kept and is now stale, and under 'sparse' it is removed. With only trailing composites missing, the half is truncated to the leading ones. An item is also invisible in a GSI while one of its two key halves is absent.
Solution: When you change a composite, pass the other composites of the same half:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// If the GSI's sort key has composite: ["region", "office"]// pass both when changing either oneyield* users.update({ userId: "u-1" }).set({ region: "eu-west-1", office: "dublin" })Updates that touch none of a GSI’s composites leave its keys alone. See Index Policy for how each half is evaluated.
”ConditionalCheckFailed on create”
Section titled “”ConditionalCheckFailed on create””Symptom: ConditionalCheckFailed error when calling entity.create().
Cause: An item with the same primary key already exists. create() adds an automatic attribute_not_exists condition that prevents overwrites.
Solution: Choose the appropriate operation for your intent:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// Use put() to overwrite if existsyield* users.put({ userId: "u-1", name: "Alice" })
// Use upsert() for create-or-update semanticsyield* users.upsert({ userId: "u-1", name: "Alice" })
// Or handle the duplicate explicitlyyield* users.create({ userId: "u-1", name: "Alice" }).pipe( Effect.catchTag("ConditionalCheckFailed", () => Effect.logWarning("User already exists, skipping") ),)“UniqueConstraintViolation”
Section titled ““UniqueConstraintViolation””Symptom: UniqueConstraintViolation error on put, create, or update.
Cause: Another item already has the same value for the unique constraint fields. Uniqueness is enforced via transactional sentinel items.
Solution:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
yield* users.put({ userId: "u-1", email: "alice@example.com" }).pipe( Effect.catchTag("UniqueConstraintViolation", (e) => // e.constraint — name of the violated constraint (e.g., "email") // e.fields — the field values that collided (e.g., { email: "alice@example.com" }) Effect.fail(new Error(`${e.constraint} already taken: ${JSON.stringify(e.fields)}`)) ),)To update a unique field, provide the new value in set(). The library creates the new sentinel and releases the old one in a transaction — the old one only if this item owns it, since another item may have claimed the value after this item’s reservation expired. If the new value is already taken, you get UniqueConstraintViolation.
”OptimisticLockError”
Section titled “”OptimisticLockError””Symptom: OptimisticLockError on update with expectedVersion.
Cause: The item was modified between when you read it and when you attempted the update. The version in DynamoDB no longer matches your expected version.
Solution: Re-read the item and retry:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
yield* users.update({ userId: "u-1" }) .set({ name: "Updated" }) .expectedVersion(3) .asEffect() .pipe( Effect.catchTag("OptimisticLockError", (e) => // e.expectedVersion — what you expected // e.actualVersion — what DynamoDB has Effect.gen(function* () { // Re-read and retry with the current version const current = yield* users.get({ userId: "u-1" }) return yield* users.update({ userId: "u-1" }) .set({ name: "Updated" }) .expectedVersion(current.version) }) ), )“AggregateAssemblyError”
Section titled ““AggregateAssemblyError””Symptom: AggregateAssemblyError when fetching or assembling an aggregate.
Cause: The aggregate could not be assembled from the items in DynamoDB. Common reasons:
- Missing items — A required sub-entity or edge target does not exist in the partition
- Structural violations — Items do not match the expected aggregate shape (e.g., wrong entity type, unexpected discriminator)
A stored value that fails schema decode during assembly is reported as a ValidationError with operation: "aggregate.assemble", not as an AggregateAssemblyError.
Solution:
yield* MyAggregate.get({ rootId: "123" }).pipe( Effect.catchTag("AggregateAssemblyError", (e) => // e.aggregate — aggregate name // e.reason — description of what went wrong // e.key — the key that was being assembled Effect.logError(`Assembly failed: ${e.reason}`) ),)Check that all items in the partition are consistent. If items were written outside the aggregate (e.g., direct entity writes), they may not match the expected structure.
”ItemDeleted”
Section titled “”ItemDeleted””Symptom: ItemDeleted error when trying to modify or access an item.
Cause: The item has been soft-deleted. Normal get() and query operations will not find it — only deleted.get() and deleted.list() can access it.
Solution:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// Read the deleted itemconst item = yield* users.deleted.get({ userId: "u-1" })
// Restore it to make it active againyield* users.restore({ userId: "u-1" })
// Or purge it permanently (removes item + versions + owned sentinels)yield* users.purge({ userId: "u-1" })“ItemNotDeleted”
Section titled ““ItemNotDeleted””Symptom: ItemNotDeleted error when calling entity.restore().
Cause: A soft-deleted copy exists, but so does a live item under the same key, usually because the item was recreated after it was deleted. restore won’t overwrite the live item. (restore of a key that has no soft-deleted item fails with ItemNotFound.)
Solution: Decide which copy to keep. restore always brings back the most recent soft-deleted copy, so soft-deleting the live item and then restoring brings back the live item, not the older copy. To revive the older copy, read it with deleted.list(key) and put its fields over the live item.
Common type errors with entity operations
Section titled “Common type errors with entity operations”“Type ‘EntityUpdate<…>’ is not assignable to type ‘Effect.Effect<…>’”
Do not try to extract A/E/R from entity operations using Effect.Effect<infer A, ...>. Entity ops are not Effect.Effect. Use the specific entity op types:
// WRONGtype Result = MyOp extends Effect.Effect<infer A> ? A : never // resolves to never
// CORRECTtype Result = MyOp extends EntityOp<infer A, any, any, any> ? A : never“Property ‘set’ does not exist on EntityUpdate” (unbound entities)
On unbound entities (Entity.make({...}) return value), update combinators (Entity.set, Entity.expectedVersion, Entity.remove, Entity.add, etc.) are module-level pipeable functions. Pipe them into the update:
// Unbound entity — use Entity.* combinators via .pipe()yield* Users.update({ userId: "u-1" }).pipe(Entity.set({ name: "Alice" }))On the bound client (db.entities.*), chain combinators as methods directly:
const db = yield* DynamoClient.make({ entities: { Users }, tables: { MainTable },})const users = db.entities.Users
// Bound client — fluent methods on the builderyield* users.update({ userId: "u-1" }).set({ name: "Alice" })
// Multiple combinators chainedyield* users.update({ userId: "u-1" }) .set({ name: "Alice" }) .expectedVersion(3) .condition((t, { exists }) => exists(t.email))