Skip to content

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.asRecord for system fields and Entity.asNative for raw DynamoDB items

  1. Get user — Look up a user by userId (primary key)
  2. Get post — Look up a post by postId (primary key)
  3. Posts by author — Find all posts by a user (GSI1: byAuthor index)
  4. Comments on a post — Find all comments on a post (GSI1: byPost index)
  5. Atomic operations — Read or write multiple entities atomically (transactions)
  6. Raw inspection — View the underlying DynamoDB item for debugging

Three pure domain models with no DynamoDB concepts:

models.ts
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 const
const 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:

  • displayName and title use Schema.NonEmptyString for validation — empty strings are rejected at the schema level
  • status uses a const object + Schema.Literals for a constrained set of post states
  • bio is optional — not every user fills in a biography
  • postCount and commentCount are denormalized counters, typical in DynamoDB designs where aggregation queries are expensive

entities.ts
const BlogSchema = DynamoSchema.make({ name: "blog", version: 1 })

Users have a simple primary key and no GSIs. Versioning is enabled for optimistic locking:

entities.ts
const Users = Entity.make({
model: User,
entityType: "User",
primaryKey: {
pk: { field: "pk", composite: ["userId"] },
sk: { field: "sk", composite: [] },
},
timestamps: true,
versioned: true,
})

Posts have a primary key and a byAuthor GSI index for querying by author:

entities.ts
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,
})

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):

entities.ts
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 },
})
EntityIndexPK compositesSK compositesAccess pattern
UserprimaryuserId(empty)Get user by ID
PostprimarypostId(empty)Get post by ID
PostbyAuthor (GSI1)authorIdpostIdPosts by author
CommentprimarycommentId(empty)Get comment by ID
CommentbyPost (GSI1)postIdcommentIdComments 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.


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:

seed.ts
// Typed execution gateway
const db = yield* DynamoClient.make({
entities: { Users, Posts, Comments },
})
yield* db.tables["blog-table"]!.create()
// yield* returns User — clean class name, no system fields
const 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 available
const aliceWithMeta = yield* db.entities.Users.get({ userId: "alice-1" })

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 type
const alice = yield* db.entities.Users.put({
userId: "alice-1",
email: "alice@blog.com",
displayName: "Alice",
postCount: 0,
})
// alice.displayName → "Alice"
// Entity.asRecord exposes system fields
const aliceRecord = yield* db.entities.Users.get({ userId: "alice-1" }).pipe(Entity.asRecord)
// aliceRecord.version → 1
// aliceRecord.createdAt → DateTime.Utc

Query posts by author and comments by post using entity-level index accessors:

// Posts by author
const alicePosts = yield* db.entities.Posts.byAuthor({ authorId: "alice-1" }).collect()
// Comments on a post
const postComments = yield* db.entities.Comments.byPost({ postId: "post-1" }).collect()

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,
})

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.

Transaction.transactGet reads multiple items atomically, and Transaction.transactWrite writes multiple items atomically:

// Atomic multi-entity read — typed tuple, no cast needed
const [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 directly
yield* 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.

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.


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.

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local
Terminal window
npx tsx examples/blog.ts

The example wires dependencies via Effect Layers — no global config or constructor options:

main.ts
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),
)

ConceptHow it’s used
Single-table design3 entities (User, Post, Comment) share one table
Index overloadingGSI1 serves posts-by-author and comments-by-post with different partition keys
Clean model typesyield* returns User, Post, Comment — no system field noise
System fieldsEntity.asRecord exposes version, createdAt, updatedAt when needed
Optimistic lockingversioned: true + Entity.expectedVersion() prevents stale writes
TransactionstransactGet and transactWrite for atomic multi-entity operations
Raw inspectionEntity.asNative reveals internal DynamoDB structure for debugging