ElectroDB Patterns
This skill covers ElectroDB v2+ patterns for building type-safe, performant DynamoDB applications with single-table design.
Core Concepts
ElectroDB Benefits:
-
Type-safe queries and mutations
-
Automatic key generation
-
Collection queries (fetch related entities)
-
Composite attributes and computed keys
-
Built-in validation
-
Query building with IntelliSense
Key Terms:
-
Entity: Table schema definition
-
Service: Multiple entities working together
-
Collection: Group related entities for efficient queries
-
Access Pattern: How you retrieve data (GSI, queries)
-
Composite Attributes: Combine fields into keys
Installation
npm install electrodb
Entity Definition
Basic Entity
// src/entities/user.entity.ts import { Entity } from "electrodb"; import { dynamoDBClient } from "../lib/db";
export const UserEntity = new Entity( { model: { entity: "user", version: "1", service: "myapp", }, attributes: { userId: { type: "string", required: true, }, email: { type: "string", required: true, }, name: { type: "string", required: true, }, role: { type: ["ADMIN", "TEACHER", "STUDENT"] as const, required: true, default: "STUDENT", }, verified: { type: "boolean", default: false, }, createdAt: { type: "string", required: true, default: () => new Date().toISOString(), readOnly: true, }, updatedAt: { type: "string", required: true, default: () => new Date().toISOString(), set: () => new Date().toISOString(), watch: "*", // Update on any attribute change }, }, indexes: { primary: { pk: { field: "pk", composite: ["userId"], }, sk: { field: "sk", composite: [], }, }, byEmail: { index: "gsi1", pk: { field: "gsi1pk", composite: ["email"], }, sk: { field: "gsi1sk", composite: [], }, }, byRole: { index: "gsi2", pk: { field: "gsi2pk", composite: ["role"], }, sk: { field: "gsi2sk", composite: ["createdAt"], }, }, }, }, { client: dynamoDBClient, table: "MyAppTable" } );
// Type inference export type User = typeof UserEntity.model.schema; export type UserItem = ReturnType<typeof UserEntity.parse>;
Advanced Attributes
export const SessionEntity = new Entity({
model: {
entity: "session",
version: "1",
service: "myapp",
},
attributes: {
sessionId: {
type: "string",
required: true,
},
userId: {
type: "string",
required: true,
},
courseId: {
type: "string",
required: true,
},
// Composite attribute (virtual)
userCourse: {
type: "string",
hidden: true, // Not returned in queries
readOnly: true,
get: (_, item) => ${item.userId}#${item.courseId},
},
// Date as string
scheduledDate: {
type: "string",
required: true,
validate: (value) => {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new Error("Invalid date format");
}
},
},
// Duration in minutes
durationMinutes: {
type: "number",
required: true,
validate: (value) => {
if (value < 15 || value > 480) {
throw new Error("Duration must be between 15 and 480 minutes");
}
},
},
// Enum-like status
status: {
type: ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED"] as const,
required: true,
default: "SCHEDULED",
},
// Optional nested object
metadata: {
type: "map",
properties: {
zoomMeetingId: { type: "string" },
recordingUrl: { type: "string" },
notes: { type: "string" },
},
},
// Array of strings
tags: {
type: "list",
items: {
type: "string",
},
},
// Set attribute (for watch)
watchedAttributes: {
type: "set",
items: "string",
},
},
indexes: {
primary: {
pk: {
field: "pk",
composite: ["sessionId"],
},
sk: {
field: "sk",
composite: [],
},
},
byCourse: {
index: "gsi1",
pk: {
field: "gsi1pk",
composite: ["courseId"],
},
sk: {
field: "gsi1sk",
composite: ["scheduledDate"],
},
},
byUser: {
index: "gsi2",
pk: {
field: "gsi2pk",
composite: ["userId"],
},
sk: {
field: "gsi2sk",
composite: ["scheduledDate"],
},
},
},
});
CRUD Operations
Create
// Create single item const user = await UserEntity.create({ userId: "user_123", email: "john@example.com", name: "John Doe", role: "STUDENT", }).go();
// Create with custom options const user = await UserEntity.create({ userId: "user_123", email: "john@example.com", name: "John Doe", }).go({ response: "all_new", // Return all attributes });
// Conditional create (fail if exists) const user = await UserEntity.create({ userId: "user_123", email: "john@example.com", name: "John Doe", }).go({ conditions: { exists: false }, // Only create if doesn't exist });
Read (Get)
// Get single item const user = await UserEntity.get({ userId: "user_123", }).go();
// user.data contains the item or null if not found if (!user.data) { throw new Error("User not found"); }
// Get with specific attributes const user = await UserEntity.get({ userId: "user_123", }).go({ attributes: ["name", "email"], // Only fetch these fields });
// Get with consistent read const user = await UserEntity.get({ userId: "user_123", }).go({ consistent: true, // Consistent read (costs more) });
Update
// Update specific attributes const updated = await UserEntity.update({ userId: "user_123", }).set({ name: "Jane Doe", verified: true, }).go();
// Add to number await SessionEntity.update({ sessionId: "session_123", }).add({ attendeeCount: 1, // Increment by 1 }).go();
// Remove attribute await UserEntity.update({ userId: "user_123", }).remove(["temporaryToken"]).go();
// Conditional update await UserEntity.update({ userId: "user_123", }).set({ verified: true, }).go({ conditions: { verified: false }, // Only update if not already verified });
// Update with custom condition expression await UserEntity.update({ userId: "user_123", }).set({ name: "New Name", }).go({ conditions: { attr: "role", eq: "ADMIN", // Only update if role is ADMIN }, });
Delete
// Delete item await UserEntity.delete({ userId: "user_123", }).go();
// Conditional delete await UserEntity.delete({ userId: "user_123", }).go({ conditions: { role: "STUDENT" }, // Only delete if student });
// Return deleted item const deleted = await UserEntity.delete({ userId: "user_123", }).go({ response: "all_old", // Return the deleted item });
Queries
Basic Queries
// Query by primary key const users = await UserEntity.query .primary({ userId: "user_123", }) .go();
// users.data is an array of items
// Query with begins_with const sessions = await SessionEntity.query .byCourse({ courseId: "course_123", }) .begins({ scheduledDate: "2025-01", // All sessions in January 2025 }) .go();
// Query with between const sessions = await SessionEntity.query .byCourse({ courseId: "course_123", }) .between( { scheduledDate: "2025-01-01" }, { scheduledDate: "2025-01-31" } ) .go();
// Query with gt/gte/lt/lte const sessions = await SessionEntity.query .byCourse({ courseId: "course_123", }) .gt({ scheduledDate: "2025-01-01" }) // Greater than .go();
Query Options
// Limit results const users = await UserEntity.query .byRole({ role: "STUDENT", }) .go({ limit: 10, // Only return 10 items });
// Pagination const firstPage = await UserEntity.query .byRole({ role: "STUDENT", }) .go({ limit: 10, });
// Get next page using cursor if (firstPage.cursor) { const secondPage = await UserEntity.query .byRole({ role: "STUDENT", }) .go({ limit: 10, cursor: firstPage.cursor, }); }
// Scan index forward/backward const latest = await SessionEntity.query .byCourse({ courseId: "course_123", }) .go({ order: "desc", // Most recent first (ScanIndexForward: false) });
// Filter after query const activeSessions = await SessionEntity.query .byCourse({ courseId: "course_123", }) .where( ({ status }, { eq }) => eq(status, "IN_PROGRESS") ) .go();
// Select specific attributes const users = await UserEntity.query .byRole({ role: "STUDENT", }) .go({ attributes: ["userId", "name", "email"], });
Complex Filters
// Multiple conditions (AND)
const sessions = await SessionEntity.query
.byCourse({
courseId: "course_123",
})
.where(
({ status, durationMinutes }, { eq, gte }) => ${eq(status, "COMPLETED")} AND ${gte(durationMinutes, 60)}
)
.go();
// OR conditions
const sessions = await SessionEntity.query
.byCourse({
courseId: "course_123",
})
.where(
({ status }, { eq }) => ${eq(status, "SCHEDULED")} OR ${eq(status, "IN_PROGRESS")}
)
.go();
// NOT condition
const users = await UserEntity.query
.byRole({
role: "STUDENT",
})
.where(
({ verified }, { eq }) => NOT ${eq(verified, true)}
)
.go();
// Contains (for strings) const users = await UserEntity.scan .where( ({ email }, { contains }) => contains(email, "@gmail.com") ) .go();
// Between (in filter) const sessions = await SessionEntity.query .byCourse({ courseId: "course_123", }) .where( ({ durationMinutes }, { between }) => between(durationMinutes, 30, 120) ) .go();
Scan Operations
// Scan entire table (use sparingly!) const allUsers = await UserEntity.scan.go();
// Scan with filter const verifiedUsers = await UserEntity.scan .where( ({ verified }, { eq }) => eq(verified, true) ) .go();
// Scan with pagination const firstPage = await UserEntity.scan.go({ limit: 100, });
if (firstPage.cursor) { const secondPage = await UserEntity.scan.go({ cursor: firstPage.cursor, }); }
// Parallel scan for large tables const segment1 = await UserEntity.scan.go({ segments: { total: 4, segment: 0 }, }); const segment2 = await UserEntity.scan.go({ segments: { total: 4, segment: 1 }, }); // ... segments 2 and 3
Batch Operations
Batch Get
// Batch get multiple items const results = await UserEntity.get([ { userId: "user_1" }, { userId: "user_2" }, { userId: "user_3" }, ]).go();
// results.data is an array of items (nulls for not found)
// Batch get with options const results = await UserEntity.get([ { userId: "user_1" }, { userId: "user_2" }, ]).go({ unprocessed: "raw", // Return unprocessed keys consistent: true, });
Batch Write
// Batch put await UserEntity.put([ { userId: "user_1", email: "user1@example.com", name: "User 1", role: "STUDENT", }, { userId: "user_2", email: "user2@example.com", name: "User 2", role: "STUDENT", }, ]).go();
// Batch delete await UserEntity.delete([ { userId: "user_1" }, { userId: "user_2" }, ]).go();
// Note: Batch operations support up to 25 items // For more, chunk them: const chunks = chunk(items, 25); for (const chunk of chunks) { await UserEntity.put(chunk).go(); }
Transactions
import { Entity, Service } from "electrodb";
// Define entities first const service = new Service({ user: UserEntity, session: SessionEntity, });
// Transactional write await service .transaction .write(({ user, session }) => [ user.create({ userId: "user_123", email: "john@example.com", name: "John Doe", role: "STUDENT", }), session.create({ sessionId: "session_456", userId: "user_123", courseId: "course_789", scheduledDate: "2025-01-15", durationMinutes: 60, status: "SCHEDULED", }), ]) .go();
// Conditional transaction await service .transaction .write(({ user, session }) => [ user.update({ userId: "user_123" }) .set({ verified: true }) .commit({ conditions: { verified: false } }), session.create({ sessionId: "session_456", userId: "user_123", courseId: "course_789", scheduledDate: "2025-01-15", durationMinutes: 60, }).commit({ conditions: { exists: false } }), ]) .go();
// Transactional get (requires primary keys) const results = await service .transaction .get([ { user: { userId: "user_123" } }, { session: { sessionId: "session_456" } }, ]) .go();
Collections
Collections allow querying multiple entity types together:
export const UserEntity = new Entity({ model: { entity: "user", version: "1", service: "myapp", }, attributes: { userId: { type: "string", required: true }, email: { type: "string", required: true }, name: { type: "string", required: true }, }, indexes: { primary: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, byOrg: { index: "gsi1", pk: { field: "gsi1pk", composite: ["orgId"] }, sk: { field: "gsi1sk", composite: ["userId"] }, collection: "organization", // Collection name }, }, });
export const CourseEntity = new Entity({ model: { entity: "course", version: "1", service: "myapp", }, attributes: { courseId: { type: "string", required: true }, orgId: { type: "string", required: true }, title: { type: "string", required: true }, }, indexes: { primary: { pk: { field: "pk", composite: ["courseId"] }, sk: { field: "sk", composite: [] }, }, byOrg: { index: "gsi1", pk: { field: "gsi1pk", composite: ["orgId"] }, sk: { field: "gsi1sk", composite: ["courseId"] }, collection: "organization", // Same collection }, }, });
// Create service const service = new Service({ user: UserEntity, course: CourseEntity, });
// Query collection (gets both users and courses for an org) const orgData = await service.collections .organization({ orgId: "org_123" }) .go();
// orgData.data contains { user: [...], course: [...] }
Service Patterns
// Create a service with multiple entities const AppService = new Service( { user: UserEntity, session: SessionEntity, course: CourseEntity, enrollment: EnrollmentEntity, }, { client: dynamoDBClient, table: "MyAppTable" } );
// Use service for transactions await AppService.transaction.write(({ user, enrollment }) => [ user.update({ userId: "user_123" }).set({ verified: true }), enrollment.create({ enrollmentId: "enroll_456", userId: "user_123", courseId: "course_789", enrolledAt: new Date().toISOString(), }), ]).go();
// Collections query const userData = await AppService.collections .userCourses({ userId: "user_123" }) .go();
Advanced Patterns
Optimistic Locking
// Add version attribute export const UserEntity = new Entity({ model: { entity: "user", version: "1", service: "myapp" }, attributes: { userId: { type: "string", required: true }, name: { type: "string", required: true }, version: { type: "number", required: true, default: 0, watch: "*", // Increment on any change set: (_, item) => (item.version ?? 0) + 1, }, }, indexes: { primary: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, }, });
// Update with version check const user = await UserEntity.get({ userId: "user_123" }).go();
await UserEntity.update({ userId: "user_123" }) .set({ name: "New Name" }) .go({ conditions: { version: user.data!.version }, // Only update if version matches });
Soft Delete
export const UserEntity = new Entity({ model: { entity: "user", version: "1", service: "myapp" }, attributes: { userId: { type: "string", required: true }, name: { type: "string", required: true }, deletedAt: { type: "string" }, // ISO timestamp or null }, indexes: { primary: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, active: { index: "gsi1", pk: { field: "gsi1pk", composite: ["deletedAt"] }, sk: { field: "gsi1sk", composite: ["userId"] }, }, }, });
// Soft delete await UserEntity.update({ userId: "user_123" }) .set({ deletedAt: new Date().toISOString() }) .go();
// Query only active users (where deletedAt is not set) // Use sparse index - items without gsi1pk won't appear const activeUsers = await UserEntity.query .active({ deletedAt: "ACTIVE" }) // Special marker .go();
Sparse Indexes
// Only index published posts export const PostEntity = new Entity({ model: { entity: "post", version: "1", service: "myapp" }, attributes: { postId: { type: "string", required: true }, title: { type: "string", required: true }, published: { type: "boolean", default: false }, publishedAt: { type: "string" }, // Only set when published }, indexes: { primary: { pk: { field: "pk", composite: ["postId"] }, sk: { field: "sk", composite: [] }, }, published: { index: "gsi1", pk: { field: "gsi1pk", composite: [], // No composite, just a constant template: "PUBLISHED#POST", // All published posts }, sk: { field: "gsi1sk", composite: ["publishedAt"], }, }, }, });
// Only items with gsi1pk set will appear in the index await PostEntity.create({ postId: "post_123", title: "My Post", published: true, publishedAt: new Date().toISOString(), }).go();
// Query only published posts const publishedPosts = await PostEntity.query .published({}) .go();
Time-to-Live (TTL)
export const SessionTokenEntity = new Entity({ model: { entity: "sessionToken", version: "1", service: "myapp" }, attributes: { token: { type: "string", required: true }, userId: { type: "string", required: true }, expiresAt: { type: "number", // Unix timestamp in seconds required: true, default: () => Math.floor(Date.now() / 1000) + 86400, // 24 hours }, }, indexes: { primary: { pk: { field: "pk", composite: ["token"] }, sk: { field: "sk", composite: [] }, }, }, });
// Configure TTL on the table (do this once) // Table attribute: expiresAt // DynamoDB will automatically delete expired items
Type Helpers
// Extract types from entity type User = typeof UserEntity.model.schema; type UserItem = ReturnType<typeof UserEntity.parse>; type UserKeys = Parameters<typeof UserEntity.get>[0];
// Infer return types type QueryResult = Awaited<ReturnType<typeof UserEntity.query.byRole>>; type CreateResult = Awaited<ReturnType<typeof UserEntity.create>>;
// Custom typed helpers export async function getUser(userId: string): Promise<UserItem | null> { const result = await UserEntity.get({ userId }).go(); return result.data; }
export async function requireUser(userId: string): Promise<UserItem> { const user = await getUser(userId); if (!user) { throw new Error("User not found"); } return user; }
Error Handling
import { ElectroError } from "electrodb";
try { await UserEntity.create({ userId: "user_123", email: "john@example.com", name: "John Doe", role: "STUDENT", }).go(); } catch (error) { if (error instanceof ElectroError) { // ElectroDB-specific error console.error("ElectroDB error:", error.message);
// Check error codes
if (error.code === 4001) {
// Validation error
} else if (error.code === 5003) {
// Item already exists (conditional check failed)
}
} else { // DynamoDB SDK error console.error("DynamoDB error:", error); } }
// Common error codes: // 4001: Invalid/missing required attribute // 4002: Invalid attribute value // 5001: DynamoDB operation failed // 5003: Conditional check failed
Best Practices
- Use Type Exports
// Export types for reuse export type User = typeof UserEntity.model.schema; export type UserItem = ReturnType<typeof UserEntity.parse>;
- Validate Complex Data
attributes: { email: { type: "string", required: true, validate: (email) => { if (!/^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email)) { throw new Error("Invalid email format"); } }, }, }
- Use Readonly for Timestamps
createdAt: { type: "string", required: true, default: () => new Date().toISOString(), readOnly: true, // Cannot be updated }
- Watch for Auto-Updates
updatedAt: { type: "string", required: true, default: () => new Date().toISOString(), set: () => new Date().toISOString(), watch: "*", // Update on any attribute change }
- Use Hidden Attributes for Computed Values
fullName: {
type: "string",
hidden: true, // Not stored in DB
get: (_, item) => ${item.firstName} ${item.lastName},
}
- Design Access Patterns First
Before creating entities, list your access patterns:
-
Get user by ID
-
Get user by email
-
List users by role
-
List sessions by course
-
List sessions by user
Then design indexes to support these patterns.
- Use Collections for Related Entities
Group related entities under the same partition key for efficient queries.
- Check .data Before Using
const result = await UserEntity.get({ userId }).go(); if (!result.data) { throw new Error("Not found"); } const user = result.data; // Now TypeScript knows it's not null
- Use Services for Related Operations
const service = new Service({ user: UserEntity, session: SessionEntity, });
// Better for transactions and collections
- Leverage Composite Attributes
sk: { field: "sk", composite: ["courseId", "userId"], // Creates: COURSE#123#USER#456 }
Common Gotchas
-
Batch size limit: Max 25 items per batch operation
-
Transaction limit: Max 100 items per transaction (across all tables)
-
Cursor is opaque: Don't parse or modify cursors
-
Consistent reads: Cost more, use sparingly
-
Scan is expensive: Avoid scanning large tables
-
Index projections: Consider what attributes you need in GSIs
-
Type inference: Let TypeScript infer types from entity definitions
-
Hidden attributes: Not stored in DB, computed on read
-
ReadOnly attributes: Can't be updated after creation
-
Watch attribute: Triggers on specified attribute changes
Testing
import { describe, test, expect, beforeAll } from "vitest"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { UserEntity } from "./user.entity";
// Use local DynamoDB for tests const localClient = new DynamoDBClient({ endpoint: "http://localhost:8000", });
describe("UserEntity", () => { beforeAll(async () => { // Create test table await createTestTable(); });
test("creates user", async () => { const result = await UserEntity.create({ userId: "user_123", email: "test@example.com", name: "Test User", role: "STUDENT", }).go();
expect(result.data).toMatchObject({
userId: "user_123",
email: "test@example.com",
name: "Test User",
});
});
test("queries users by role", async () => { const result = await UserEntity.query .byRole({ role: "STUDENT" }) .go();
expect(result.data.length).toBeGreaterThan(0);
}); });
Resources
-
ElectroDB Documentation
-
ElectroDB GitHub
-
DynamoDB Best Practices