Migration from ElectroDB
This guide helps ElectroDB users understand effect-dynamodb. It maps ElectroDB concepts to their effect-dynamodb equivalents with side-by-side examples.
Concept Mapping
Section titled “Concept Mapping”| ElectroDB | effect-dynamodb | Notes |
|---|---|---|
Entity definition | Entity.make() | ElectroDB entities define model inline; effect-dynamodb separates model (Schema.Class) from entity binding |
Service | Collection | ElectroDB’s Service groups entities; effect-dynamodb’s Collection provides multi-entity queries |
| Model attributes | Schema.Class / Schema.Struct | Pure domain schemas — no DynamoDB concepts |
model.version | DynamoSchema.version | Application schema versioning |
model.service | DynamoSchema.name | Application namespace |
| Attribute type definitions | Effect Schema types | Schema.String, Schema.Number, Schema.Boolean, etc. |
required: true | Schema-level required/optional | All Schema fields required by default; use Schema.optional() for optional |
readOnly: true | DynamoModel.configure(model, { field: { immutable: true } }) | Excluded from update input type |
field: "dbName" | DynamoModel.configure(model, { field: { field: "dbName" } }) | Domain-to-DynamoDB field renaming |
hidden: true | DynamoModel.Hidden | Excluded from asModel/asRecord decode |
default: value | Schema defaults | Schema.withDefault(Schema.String, () => "default") |
validate: RegExp | Schema.pattern() / .check() | Composable, named validation |
Key templates ("USER#${userId}") | composite: ["userId"] | Attribute-list composition, not template strings |
entity.put(data).go() | yield* db.entities.E.put(data) | Access via db.entities.* from DynamoClient.make({ entities, tables }), returns Effect |
entity.get(key).go() | yield* db.entities.E.get(key) | Access via db.entities.* from DynamoClient.make({ entities, tables }), returns Effect |
entity.query.<name>(key).go() | db.entities.E.indexName(pk).collect() | Entity index accessor on typed client, BoundQuery terminal |
entity.create(data).go() | yield* bound.create(data) | Put with attribute_not_exists condition |
entity.upsert(data).go() | yield* bound.upsert(data) | Create or update with if_not_exists for immutable fields |
entity.patch(key).set({}).go() | bound.patch(key).set(...) | Update with attribute_exists — fails if item doesn’t exist |
entity.remove(key).go() | yield* bound.deleteIfExists(key) | Delete with attribute_exists — fails if item doesn’t exist |
entity.scan.go() | db.entities.E.scan().collect() | Scan returns BoundQuery — compose with filter, limit, etc. |
.where() callback | .condition() / .filter() | Callback or shorthand syntax, chained on builders |
.set({}).go() | bound.update(key).set(updates) | Fluent builder: chain combinators as methods |
.remove([]) | .remove(fields) on BoundUpdate | Chainable REMOVE |
.add({}) | .add(values) on BoundUpdate | Chainable ADD |
.subtract({}) | .subtract(values) on BoundUpdate | Chainable SUBTRACT |
.append({}) | .append(values) on BoundUpdate | Chainable APPEND |
.delete({}) | .deleteFromSet(values) on BoundUpdate | Chainable DELETE from set |
consistent: true | Entity.consistentRead / Query.consistentRead | Composable combinator |
response: "all_old" | Entity.returnValues("allOld") | Control DynamoDB ReturnValues on update/delete |
pages: N | Query.maxPages(n) | Limit total pages fetched |
ignoreOwnership: true | Query.ignoreOwnership | Skip entity type filter |
.params() | Query.asParams | Return DynamoDB command input without executing |
cursor pagination | Query.paginate → Stream | Stream-based automatic pagination |
ElectroError with codes | Tagged errors with catchTag | DynamoError, ItemNotFound, etc. |
client option | DynamoClient.layer() | Layer-based dependency injection |
table on Entity config | Table.make() + table.layer() | Table name injected at runtime via Layer |
Side-by-Side Examples
Section titled “Side-by-Side Examples”Model Definition
Section titled “Model Definition”ElectroDB:
const User = new Entity({ model: { entity: "User", version: "1", service: "myapp" }, attributes: { userId: { type: "string", required: true }, email: { type: "string", required: true }, displayName: { type: "string", required: true }, role: { type: ["admin", "member"], required: true }, createdBy: { type: "string", readOnly: true }, }, indexes: { primary: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, byEmail: { index: "gsi1", pk: { field: "gsi1pk", composite: ["email"] }, sk: { field: "gsi1sk", composite: [] }, }, },}, { client, table: "my-table" })effect-dynamodb:
// 1. Model — pure domain schema (no DynamoDB concepts)class User extends Schema.Class<User>("User")({ userId: Schema.String, email: Schema.String, displayName: Schema.NonEmptyString, role: Schema.Literals(["admin", "member"]), createdBy: Schema.String,}) {}
const UserModel = DynamoModel.configure(User, { createdBy: { immutable: true },})
// 2. Schema (equivalent to service config)const AppSchema = DynamoSchema.make({ name: "myapp", version: 1 })
// 3. Entity definitionconst UserEntity = Entity.make({ model: UserModel, entityType: "User", primaryKey: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byEmail: { name: "gsi1", pk: { field: "gsi1pk", composite: ["email"] }, sk: { field: "gsi1sk", composite: [] }, }, }, timestamps: true,})
// 4. Table — register entities (equivalent to table config)const MainTable = Table.make({ schema: AppSchema, entities: { UserEntity } })CRUD Operations
Section titled “CRUD Operations”ElectroDB:
// Createconst user = await User.put({ userId: "u-1", email: "a@b.com", ... }).go()
// Getconst { data } = await User.get({ userId: "u-1" }).go()
// Updateawait User.update({ userId: "u-1" }).set({ email: "new@b.com" }).go()
// Deleteawait User.delete({ userId: "u-1" }).go()effect-dynamodb:
const program = Effect.gen(function* () { // Get typed client with executable operations const db = yield* DynamoClient.make({ entities: { UserEntity }, tables: { MainTable }, }) const users = db.entities.UserEntity
// Create const user = yield* users.put({ userId: "u-1", email: "a@b.com", ... })
// Get const found = yield* users.get({ userId: "u-1" })
// Update (fluent builder on bound client) yield* users.update({ userId: "u-1" }).set({ email: "new@b.com" })
// Delete yield* users.delete({ userId: "u-1" })})
// Provide dependencies via Layer (instead of constructor options)program.pipe( Effect.provide( Layer.mergeAll( DynamoClient.layer({ region: "us-east-1" }), MainTable.layer({ name: "my-table" }), ) ))Queries
Section titled “Queries”ElectroDB:
// Query by indexconst { data } = await User.query.byEmail({ email: "a@b.com" }).go()
// With sort key condition and filterconst { data } = await Task.query .byProject({ projectId: "p-1" }) .where(({ status }, { eq }) => eq(status, "active")) .go({ limit: 25 })
// Pagination with cursorlet cursor = nulldo { const result = await Task.query.byProject({ projectId: "p-1" }).go({ cursor }) processItems(result.data) cursor = result.cursor} while (cursor)effect-dynamodb:
const db = yield* DynamoClient.make({ entities: { UserEntity, TaskEntity }, tables: { MainTable },})
// Query by index — entity accessor returns BoundQueryconst userResults = yield* db.entities.UserEntity.byEmail({ email: "a@b.com" }).collect()
// With filter and limitconst taskResults = yield* db.entities.TaskEntity .byProject({ projectId: "p-1" }) .filter((t, { eq }) => eq(t.status, "active")) .limit(25) .collect()
// Stream-based pagination (automatic)const stream = db.entities.TaskEntity .byProject({ projectId: "p-1" }) .paginate()yield* Stream.runForEach(stream, (task) => processItem(task))Conditional Writes
Section titled “Conditional Writes”ElectroDB:
await User.put(data) .where(({ email }, { attributeNotExists }) => attributeNotExists(email)) .go()
await User.update({ userId: "u-1" }) .set({ status: "active" }) .where(({ status }, { eq }) => eq(status, "pending")) .go()effect-dynamodb:
const db = yield* DynamoClient.make({ entities: { UserEntity }, tables: { MainTable },})const users = db.entities.UserEntity
yield* users.put(data).condition((t, { notExists }) => notExists(t.email))
yield* users.update({ userId: "u-1" }) .set({ status: "active" }) .condition((t, { eq }) => eq(t.status, "pending"))Update Expressions
Section titled “Update Expressions”ElectroDB:
await Product.update({ productId: "p-1" }) .set({ name: "New Name" }) .add({ viewCount: 1 }) .remove(["description"]) .append({ tags: ["new-tag"] }) .subtract({ stock: 3 }) .go()effect-dynamodb:
const db = yield* DynamoClient.make({ entities: { ProductEntity }, tables: { MainTable },})const products = db.entities.ProductEntity
yield* products.update({ productId: "p-1" }) .set({ name: "New Name" }) .add({ viewCount: 1 }) .remove(["description"]) .append({ tags: ["new-tag"] }) .subtract({ stock: 3 })Collections (Service)
Section titled “Collections (Service)”ElectroDB:
const myService = new Service({ user: User, order: Order,})
const { data } = await myService.collections.byTenant({ tenantId: "t-1" }).go()// data.user: User[], data.order: Order[]effect-dynamodb:
// Collections are auto-discovered from entity indexes sharing the same// `collection` property. No explicit Collection.make() needed.// On UserEntity and OrderEntity, add: collection: "tenantMembers" to the index
const db = yield* DynamoClient.make({ entities: { UserEntity, OrderEntity }, tables: { MainTable },})
const data = yield* db.collections.tenantMembers({ tenantId: "t-1" }).collect()// { UserEntity: User[], OrderEntity: Order[] }Transactions
Section titled “Transactions”ElectroDB:
await myService.transaction.write(({ user, order }) => [ user.put(newUser).commit(), order.delete({ orderId: "o-1" }).commit(), user.check({ userId: "u-1" }).where(({ email }, { attributeExists }) => attributeExists(email) ).commit(),]).go()effect-dynamodb:
yield* Transaction.transactWrite( UserEntity.put(newUser), OrderEntity.delete({ orderId: "o-1" }), Transaction.check( UserEntity.get({ userId: "u-1" }), { attributeExists: "email" }, ),)Error Handling
Section titled “Error Handling”ElectroDB:
try { await User.get({ userId: "u-1" }).go()} catch (err) { if (err.code === 1) { /* configuration error */ } if (err.code === 2) { /* invalid identifier */ } // ... numeric error codes}effect-dynamodb:
const db = yield* DynamoClient.make({ entities: { UserEntity }, tables: { MainTable },})const users = db.entities.UserEntity
yield* users.get({ userId: "u-1" }).pipe( Effect.catchTag("ItemNotFound", () => Effect.succeed(null)), Effect.catchTag("ValidationError", (e) => Effect.die(`Bad data: ${e.message}`)), Effect.catchTag("DynamoError", (e) => Effect.die(`AWS error in ${e.operation}`)),)What effect-dynamodb Adds
Section titled “What effect-dynamodb Adds”Features that effect-dynamodb provides that ElectroDB does not:
| Feature | Description |
|---|---|
| Built-in versioning | versioned: true auto-increments version on every write. versioned: { retain: true } keeps version snapshots. |
| Built-in soft delete | softDelete: true marks items as deleted instead of physical deletion. Includes restore() and purge(). |
| Built-in unique constraints | unique: { email: ["email"] } enforces field-level uniqueness via sentinel items in atomic transactions. |
| Built-in optimistic locking | .expectedVersion(n) on BoundUpdate fails the write if the current version doesn’t match. |
| 7 derived types | Model, Record, Input, Update, Key, Item, Marshalled — all auto-derived from one entity declaration. |
| Stream pagination | Query.paginate returns an Effect Stream for lazy, composable pagination. |
| Layer-based DI | Table names and DynamoDB client injected at runtime via Effect Layers — no hardcoded config. |
| Config-based setup | DynamoClient.layerConfig() reads from environment variables via Effect Config. |
| Table definition export | Table.definition() generates CreateTableCommandInput for CloudFormation/CDK/testing. |
| 4 decode modes | asModel (clean domain), asRecord (with system fields), asItem (with DynamoDB keys), asNative (raw AttributeValue). |
| Dual APIs | All public functions support both data-first and data-last (pipeable) calling conventions. |
| DynamoDB Streams decode | Entity.itemSchema() and Entity.decodeMarshalledItem() for typed stream record processing. |
What’s Different
Section titled “What’s Different”Features in ElectroDB that work differently or are not available in effect-dynamodb:
| Feature | ElectroDB | effect-dynamodb |
|---|---|---|
| Find/Match | Auto-selects index from attributes | Not supported — use explicit entity.query.<indexName>() (more predictable) |
| Getter/setter hooks | get/set callbacks per attribute | Use Effect Schema.transform for equivalent functionality |
| Calculated/virtual attributes | Via watch + set/get | Compute at application layer or with Schema.transform |
| Key templates | "USER#${userId}" template strings | Attribute-list composition (composite: ["userId"]) — more predictable |
| Attribute padding | padding: { length, char } | Automatic zero-padding for numeric composites in sort keys |
| Fluent chaining | entity.query.byEmail(pk).where(...).go() | Fluent BoundQuery: db.entities.E.byEmail(pk).where(...).collect() |
| Async/Promise | Returns Promises | Returns Effects — run via Effect.runPromise at the edge |
See the ElectroDB Comparison for a full feature-by-feature comparison.