Example: Blog Platform
A blog platform managing users, posts, and comments in a single DynamoDB table. This example demonstrates how multiple entity types coexist in one table with index overloading, and covers the full range of everyday operations: CRUD, GSI queries, optimistic locking, transactions, and raw item inspection.
What you’ll learn:
- 3 entities sharing one table (single-table design)
- GSI queries for posts by author and comments by post via entity-level indexes
Entity.set()for type-safe updates with combinators- Optimistic locking with
Entity.expectedVersion() - Atomic multi-entity reads and writes via transactions
Entity.asRecordfor system fields andEntity.asNativefor raw DynamoDB items
Access Patterns
Section titled “Access Patterns”- Get user — Look up a user by
userId(primary key) - Get post — Look up a post by
postId(primary key) - Posts by author — Find all posts by a user (GSI1:
byAuthorindex) - Comments on a post — Find all comments on a post (GSI1:
byPostindex) - Atomic operations — Read or write multiple entities atomically (transactions)
- Raw inspection — View the underlying DynamoDB item for debugging
Step 1: Models
Section titled “Step 1: Models”Three pure domain models with no DynamoDB concepts:
class User extends Schema.Class<User>("User")({ userId: Schema.String, email: Schema.String, displayName: Schema.NonEmptyString, bio: Schema.optional(Schema.String), postCount: Schema.Number,}) {}
const PostStatus = { Draft: "draft", Published: "published", Archived: "archived" } as constconst PostStatusSchema = Schema.Literals(Object.values(PostStatus))
class Post extends Schema.Class<Post>("Post")({ postId: Schema.String, authorId: Schema.String, title: Schema.NonEmptyString, content: Schema.String, status: PostStatusSchema, commentCount: Schema.Number,}) {}
class Comment extends Schema.Class<Comment>("Comment")({ commentId: Schema.String, postId: Schema.String, authorId: Schema.String, body: Schema.NonEmptyString,}) {}Key modeling decisions:
displayNameandtitleuseSchema.NonEmptyStringfor validation — empty strings are rejected at the schema levelstatususes a const object +Schema.Literalsfor a constrained set of post statesbiois optional — not every user fills in a biographypostCountandcommentCountare denormalized counters, typical in DynamoDB designs where aggregation queries are expensive
Step 2: Schema, Entities, and Table
Section titled “Step 2: Schema, Entities, and Table”Schema
Section titled “Schema”const BlogSchema = DynamoSchema.make({ name: "blog", version: 1 })User Entity
Section titled “User Entity”Users have a simple primary key and no GSIs. Versioning is enabled for optimistic locking:
const Users = Entity.make({ model: User, entityType: "User", primaryKey: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, timestamps: true, versioned: true,})Post Entity
Section titled “Post Entity”Posts have a primary key and a byAuthor GSI index for querying by author:
const Posts = Entity.make({ model: Post, entityType: "Post", primaryKey: { pk: { field: "pk", composite: ["postId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byAuthor: { name: "gsi1", pk: { field: "gsi1pk", composite: ["authorId"] }, sk: { field: "gsi1sk", composite: ["postId"] }, }, }, timestamps: true,})Comment Entity
Section titled “Comment Entity”Comments also use GSI1 but with a different partition key — this is index overloading. Posts partition GSI1 by authorId (via byAuthor), while Comments partition it by postId (via byPost):
const Comments = Entity.make({ model: Comment, entityType: "Comment", primaryKey: { pk: { field: "pk", composite: ["commentId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byPost: { name: "gsi1", pk: { field: "gsi1pk", composite: ["postId"] }, sk: { field: "gsi1sk", composite: ["commentId"] }, }, }, timestamps: true,})
const BlogTable = Table.make({ schema: BlogSchema, entities: { Users, Posts, Comments },})| Entity | Index | PK composites | SK composites | Access pattern |
|---|---|---|---|---|
| User | primary | userId | (empty) | Get user by ID |
| Post | primary | postId | (empty) | Get post by ID |
| Post | byAuthor (GSI1) | authorId | postId | Posts by author |
| Comment | primary | commentId | (empty) | Get comment by ID |
| Comment | byPost (GSI1) | postId | commentId | Comments on a post |
GSI1 is overloaded — Posts use it for byAuthor (partitioned by authorId) and Comments use it for byPost (partitioned by postId). Different entity types, different partition keys, same physical index.
Step 3: Gateway, Table, and First Writes
Section titled “Step 3: Gateway, Table, and First Writes”Resolve the typed gateway from the registered entities, create the physical table, and seed an initial user. The gateway (db = yield* DynamoClient.make(...)) is what every subsequent operation in this tutorial reaches through:
// Typed execution gatewayconst db = yield* DynamoClient.make({ entities: { Users, Posts, Comments },})
yield* db.tables["blog-table"]!.create()
// yield* returns User — clean class name, no system fieldsconst alice = yield* db.entities.Users.put({ userId: "alice-1", email: "alice@blog.com", displayName: "Alice", postCount: 0,})
// get returns model instance by default, with system fields availableconst aliceWithMeta = yield* db.entities.Users.get({ userId: "alice-1" })Step 4: Operations
Section titled “Step 4: Operations”Put and Get with System Fields
Section titled “Put and Get with System Fields”yield* on a put or get returns the clean model type. To access system fields like createdAt, updatedAt, and version, pipe through Entity.asRecord:
// yield* returns User — clean model typeconst alice = yield* db.entities.Users.put({ userId: "alice-1", email: "alice@blog.com", displayName: "Alice", postCount: 0,})// alice.displayName → "Alice"
// Entity.asRecord exposes system fieldsconst aliceRecord = yield* db.entities.Users.get({ userId: "alice-1" }).pipe(Entity.asRecord)// aliceRecord.version → 1// aliceRecord.createdAt → DateTime.UtcGSI Queries
Section titled “GSI Queries”Query posts by author and comments by post using entity-level index accessors:
// Posts by authorconst alicePosts = yield* db.entities.Posts.byAuthor({ authorId: "alice-1" }).collect()
// Comments on a postconst postComments = yield* db.entities.Comments.byPost({ postId: "post-1" }).collect()Pipeable Updates
Section titled “Pipeable Updates”The update key identifies the item, and the remaining fields specify the changes:
// #region update // db.entities.Users.update(key).set(...) — fluent, type-safe const updatedAlice = yield* db.entities.Users.update({ userId: "alice-1" }).set({ displayName: "Alice B.", postCount: 3, })Optimistic Locking
Section titled “Optimistic Locking”When versioned: true is configured, every write increments the item’s version. Use Entity.expectedVersion() to enforce that the item hasn’t changed since you last read it:
// #region optimistic-locking // expectedVersion for optimistic locking const lockResult = yield* db.entities.Users.update({ userId: "alice-1" }) .set({ displayName: "Wrong Name" }) .expectedVersion(1) // version is now 2 — this will fail .asEffect() .pipe( Effect.map(() => "unexpected success"), Effect.catchTag("OptimisticLockError", (e) => Effect.succeed(`Caught OptimisticLockError: expected v${e.expectedVersion}`), ), )The Entity.expectedVersion() combinator adds a condition that the stored version must match. If another write has incremented the version, the update fails with an OptimisticLockError.
Transactions
Section titled “Transactions”Transaction.transactGet reads multiple items atomically, and Transaction.transactWrite writes multiple items atomically:
// Atomic multi-entity read — typed tuple, no cast neededconst [txUser, txPost] = yield* Transaction.transactGet([ Users.get({ userId: "alice-1" }), Posts.get({ postId: "post-1" }),])// txUser: User | undefined, txPost: Post | undefined
// Atomic multi-entity write — entity intermediates directlyyield* Transaction.transactWrite([ Posts.put({ postId: "post-4", authorId: "alice-1", title: "Atomic Operations", content: "Transactions ensure consistency...", status: "published", commentCount: 0, }), Posts.delete({ postId: "post-3" }),])The transaction ensures both operations succeed or both fail. If post-4 can’t be created, post-3 won’t be deleted.
Raw DynamoDB Item
Section titled “Raw DynamoDB Item”For debugging, Entity.asNative returns the raw DynamoDB marshalled item with all internal attributes visible:
const rawPost = yield* db.entities.Posts.get({ postId: "post-1" }).pipe(Entity.asNative)// rawPost includes pk, sk, gsi1pk, gsi1sk, __edd_e__, createdAt, updatedAt, ...This is useful for verifying how composite keys are composed and inspecting the internal structure of items in the table.
Running the Example
Section titled “Running the Example”The complete runnable example is at examples/blog.ts in the repository. It creates all entities, demonstrates each access pattern, and cleans up after itself.
docker run -d -p 8000:8000 amazon/dynamodb-localnpx tsx examples/blog.tsLayer Setup
Section titled “Layer Setup”The example wires dependencies via Effect Layers — no global config or constructor options:
const AppLayer = Layer.mergeAll( DynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }), BlogTable.layer({ name: "blog-table" }),)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then( () => console.log("\nDone."), (err) => console.error("\nFailed:", err),)Key Takeaways
Section titled “Key Takeaways”| Concept | How it’s used |
|---|---|
| Single-table design | 3 entities (User, Post, Comment) share one table |
| Index overloading | GSI1 serves posts-by-author and comments-by-post with different partition keys |
| Clean model types | yield* returns User, Post, Comment — no system field noise |
| System fields | Entity.asRecord exposes version, createdAt, updatedAt when needed |
| Optimistic locking | versioned: true + Entity.expectedVersion() prevents stale writes |
| Transactions | transactGet and transactWrite for atomic multi-entity operations |
| Raw inspection | Entity.asNative reveals internal DynamoDB structure for debugging |
What’s Next?
Section titled “What’s Next?”- Modeling Guide — Deep dive into models, schemas, tables, and entities
- Queries Guide — Sort key conditions, filters, and pagination
- Example: Shopping Mall — Composite keys,
beginsWith, and date range queries - Example: Human Resources — 3 entities, 5 GSIs, collections, and soft delete