Example: Library System
A library management system with authors, books, genres, and members in a single DynamoDB table. Adapted from the ElectroDB Library System example to showcase effect-dynamodb’s approach to the same problem.
What you’ll learn:
- 4 entities sharing one table (single-table design)
- 3 GSIs with index overloading
- 4 cross-entity collection patterns via shared
collectionnames on entity indexes - Sparse index pattern (items appear in a GSI only when optional fields are present)
- Book loan and return workflows
- Atomic book returns via transactions
Requirements
Section titled “Requirements”As stakeholders of this library system, we need to support these access patterns:
- Librarian — Get author details by name
- Patron — Get book and genre details by ISBN
- Catalog — Get an author’s complete works (books + genres)
- Account — See a member’s profile and loaned books
- Search — Find books and genres by title
- Browse — Browse genres by category (Sci-Fi, Fantasy, etc.)
- Circulation — Loan a book to a member (sparse index)
- Circulation — Return a book atomically via transaction
Table Design
Section titled “Table Design”All four entities share a single table with 3 Global Secondary Indexes:
| Key | Field | Used by |
|---|---|---|
| PK | pk | Author (by lastName), Book (by isbn), Genre (by isbn), Member (by memberId) |
| SK | sk | Author (firstName), Book (bookId), Genre (genre + subgenre), Member |
| GSI1 PK | gsi1pk | Book (by memberId — loans), Genre (by genre), Member (by memberId) |
| GSI1 SK | gsi1sk | Book (loanEndDate), Genre (subgenre), Member |
| GSI2 PK | gsi2pk | Author (by name), Book (by author name), Genre (by author name) |
| GSI2 SK | gsi2sk | Author, Book (bookId), Genre (genre) |
| GSI3 PK | gsi3pk | Book (by bookTitle), Genre (by bookTitle) |
| GSI3 SK | gsi3sk | Book (releaseDate), Genre (genre + subgenre) |
GSI1 is overloaded — it serves Book loans (sparse), Genre categories (standalone), and Member accounts. GSI2 and GSI3 each serve collection patterns grouping multiple entity types under the same partition key.
Collections:
| Collection | Index | Partition Key | Entities |
|---|---|---|---|
Works | GSI2 | authorLastName + authorFirstName | Author, Book, Genre |
Account | GSI1 | memberId | Member, Book |
Titles | GSI3 | bookTitle | Book, Genre |
byGenre (index) | GSI1 | genre | Genre (standalone) |
Step 1: Models
Section titled “Step 1: Models”Pure domain models with no DynamoDB concepts:
class Author extends Schema.Class<Author>("Author")({ authorFirstName: Schema.String, authorLastName: Schema.String, birthday: Schema.String, bio: Schema.String,}) {}
class Book extends Schema.Class<Book>("Book")({ bookId: Schema.String, bookTitle: Schema.String, description: Schema.String, publisher: Schema.String, releaseDate: Schema.String, authorFirstName: Schema.String, authorLastName: Schema.String, isbn: Schema.String, memberId: Schema.optional(Schema.String), loanEndDate: Schema.optional(Schema.String),}) {}
class Genre extends Schema.Class<Genre>("Genre")({ genre: Schema.String, subgenre: Schema.String, isbn: Schema.String, bookTitle: Schema.String, authorFirstName: Schema.String, authorLastName: Schema.String,}) {}
class Member extends Schema.Class<Member>("Member")({ memberId: Schema.String, membershipStartDate: Schema.String, membershipEndDate: Schema.String, city: Schema.String, state: Schema.String,}) {}Note that Book has two optional fields — memberId and loanEndDate. When these are absent, the book won’t appear in the loans GSI. This is the sparse index pattern: DynamoDB only writes an item to a GSI when all key attributes for that index are present.
Step 2: Schema, Table, and Entities
Section titled “Step 2: Schema, Table, and Entities”Schema and Table
Section titled “Schema and Table”const LibSchema = DynamoSchema.make({ name: "library", version: 1 })const LibTable = Table.make({ schema: LibSchema, entities: { Authors, Books, Genres, Members } })Author Entity
Section titled “Author Entity”Author defines a primary key by last name + first name, plus a GSI index participating in the works collection:
const Authors = Entity.make({ model: Author, entityType: "Author", primaryKey: { pk: { field: "pk", composite: ["authorLastName"] }, sk: { field: "sk", composite: ["authorFirstName"] }, }, indexes: { works: { collection: "works", name: "gsi2", pk: { field: "gsi2pk", composite: ["authorLastName", "authorFirstName"] }, sk: { field: "gsi2sk", composite: [] }, }, }, timestamps: true,})Book Entity
Section titled “Book Entity”Book defines a primary key and three GSI indexes. The account index on GSI1 uses optional fields, creating a sparse index:
const Books = Entity.make({ model: Book, entityType: "Book", primaryKey: { pk: { field: "pk", composite: ["isbn"] }, sk: { field: "sk", composite: ["bookId"] }, }, indexes: { account: { collection: "account", name: "gsi1", pk: { field: "gsi1pk", composite: ["memberId"] }, sk: { field: "gsi1sk", composite: ["loanEndDate"] }, }, works: { collection: "works", name: "gsi2", pk: { field: "gsi2pk", composite: ["authorLastName", "authorFirstName"] }, sk: { field: "gsi2sk", composite: ["bookId"] }, }, titles: { collection: "titles", name: "gsi3", pk: { field: "gsi3pk", composite: ["bookTitle"] }, sk: { field: "gsi3sk", composite: ["releaseDate"] }, }, }, timestamps: true,})Genre Entity
Section titled “Genre Entity”Genre defines a primary key and three GSI indexes. It participates in two auto-discovered collections (works, titles) plus a standalone byGenre index for category browsing:
const Genres = Entity.make({ model: Genre, entityType: "Genre", primaryKey: { pk: { field: "pk", composite: ["isbn"] }, sk: { field: "sk", composite: ["genre", "subgenre"] }, }, indexes: { byGenre: { name: "gsi1", pk: { field: "gsi1pk", composite: ["genre"] }, sk: { field: "gsi1sk", composite: ["subgenre"] }, }, works: { collection: "works", name: "gsi2", pk: { field: "gsi2pk", composite: ["authorLastName", "authorFirstName"] }, sk: { field: "gsi2sk", composite: ["genre"] }, }, titles: { collection: "titles", name: "gsi3", pk: { field: "gsi3pk", composite: ["bookTitle"] }, sk: { field: "gsi3sk", composite: ["genre", "subgenre"] }, }, }, timestamps: true,})Member Entity
Section titled “Member Entity”Member defines a primary key and one GSI index. GSI1 participates in the account collection alongside Book loans:
const Members = Entity.make({ model: Member, entityType: "Member", primaryKey: { pk: { field: "pk", composite: ["memberId"] }, sk: { field: "sk", composite: [] }, }, indexes: { account: { collection: "account", name: "gsi1", pk: { field: "gsi1pk", composite: ["memberId"] }, sk: { field: "gsi1sk", composite: [] }, }, }, timestamps: true,})GSI Access Patterns
Section titled “GSI Access Patterns”GSI access patterns are defined as entity-level indexes. Multi-entity collections (account, works, titles) are auto-discovered from matching collection names across entities:
// GSI access patterns are now defined as entity-level indexes above.// Multi-entity collections (account, works, titles) are auto-discovered// from matching collection names across entities.Key design decisions:
accountcollection (GSI1) usesmemberIdas PK. Book’sloanEndDateisSchema.optional, so books without a loan are invisible in this index (sparse index pattern)workscollection (GSI2) groups Author, Book, and Genre by author name — three entity types in one partitiontitlescollection (GSI3) groups Books and Genres by titlebyGenreindex (GSI1) is a standalone entity-level index for genre browsing, not part of any collection
Step 3: Seed Data
Section titled “Step 3: Seed Data”const authors = { tolkien: { authorFirstName: "J.R.R.", authorLastName: "Tolkien", birthday: "1892-01-03", bio: "English writer and philologist, best known for The Hobbit and The Lord of the Rings", }, asimov: { authorFirstName: "Isaac", authorLastName: "Asimov", birthday: "1920-01-02", bio: "American author and biochemist, prolific writer of science fiction and popular science", },} as const
const books = { hobbit: { bookId: "b-hobbit", bookTitle: "The Hobbit", description: "A fantasy novel about the adventures of Bilbo Baggins", publisher: "George Allen & Unwin", releaseDate: "1937-09-21", authorFirstName: "J.R.R.", authorLastName: "Tolkien", isbn: "978-0-547-928227", }, foundation: { bookId: "b-foundation", bookTitle: "Foundation", description: "A science fiction novel about the fall of a galactic empire", publisher: "Gnome Press", releaseDate: "1951-06-01", authorFirstName: "Isaac", authorLastName: "Asimov", isbn: "978-0-553-293357", }, iRobot: { bookId: "b-irobot", bookTitle: "I, Robot", description: "A collection of nine science fiction short stories about robots", publisher: "Gnome Press", releaseDate: "1950-12-02", authorFirstName: "Isaac", authorLastName: "Asimov", isbn: "978-0-553-294385", },} as const
const genres = { hobbitFantasy: { genre: "Fantasy", subgenre: "High Fantasy", isbn: "978-0-547-928227", bookTitle: "The Hobbit", authorFirstName: "J.R.R.", authorLastName: "Tolkien", }, foundationSciFi: { genre: "Sci-Fi", subgenre: "Space Opera", isbn: "978-0-553-293357", bookTitle: "Foundation", authorFirstName: "Isaac", authorLastName: "Asimov", }, iRobotSciFi: { genre: "Sci-Fi", subgenre: "Robotics", isbn: "978-0-553-294385", bookTitle: "I, Robot", authorFirstName: "Isaac", authorLastName: "Asimov", },} as const
const members = { alice: { memberId: "m-alice", membershipStartDate: "2023-01-15", membershipEndDate: "2026-01-15", city: "New York", state: "NY", }, bob: { memberId: "m-bob", membershipStartDate: "2024-03-01", membershipEndDate: "2027-03-01", city: "Los Angeles", state: "CA", },} as const// Typed execution gateway — binds all entities and collectionsconst db = yield* DynamoClient.make({ entities: { Authors, Books, Genres, Members },})
// --- Setup: create table ---yield* db.tables["library-table"]!.create()
// --- Seed data ---for (const author of Object.values(authors)) { yield* db.entities.Authors.put(author)}for (const book of Object.values(books)) { yield* db.entities.Books.put(book)}for (const g of Object.values(genres)) { yield* db.entities.Genres.put(g)}for (const member of Object.values(members)) { yield* db.entities.Members.put(member)}Step 4: Fulfilling the Requirements
Section titled “Step 4: Fulfilling the Requirements”Requirement 1: Get Author by Name
Section titled “Requirement 1: Get Author by Name”As a librarian, look up an author by name.
Author’s primary key uses authorLastName as PK and authorFirstName as SK, enabling direct gets by full name.
const tolkien = yield* db.entities.Authors.get({ authorLastName: "Tolkien", authorFirstName: "J.R.R.",})// tolkien.birthday → "1892-01-03"// tolkien.bio → "English writer and philologist..."
const asimov = yield* db.entities.Authors.get({ authorLastName: "Asimov", authorFirstName: "Isaac",})// asimov.birthday → "1920-01-02"Requirement 2: Book + Genre Details by ISBN
Section titled “Requirement 2: Book + Genre Details by ISBN”As a patron, get book and genre details for a specific ISBN.
Book and Genre share the same primary partition key (isbn). Querying each entity by ISBN retrieves related items from the same item collection.
// Get the bookconst hobbit = yield* db.entities.Books.get({ isbn: "978-0-547-928227", bookId: "b-hobbit",})// hobbit.bookTitle → "The Hobbit"// hobbit.publisher → "George Allen & Unwin"
// Get the genre for the same ISBNconst hobbitGenre = yield* db.entities.Genres.get({ isbn: "978-0-547-928227", genre: "Fantasy", subgenre: "High Fantasy",})// hobbitGenre.genre → "Fantasy"// hobbitGenre.subgenre → "High Fantasy"Requirement 3: Author’s Works (Works Collection)
Section titled “Requirement 3: Author’s Works (Works Collection)”As a catalog system, retrieve all of an author’s books and genres.
Author, Book, and Genre share GSI2 via the Works collection. All three use authorLastName + authorFirstName as the partition key, so a single partition holds the author’s complete catalog.
// Asimov's booksconst { Books: asimovBooks } = yield* db.collections.works!({ authorLastName: "Asimov", authorFirstName: "Isaac",}).collect()
// Asimov's genresconst { Genres: asimovGenres } = yield* db.collections.works!({ authorLastName: "Asimov", authorFirstName: "Isaac",}).collect()
// Author's own record in the works collectionconst { Authors: tolkienInfo } = yield* db.collections.works!({ authorLastName: "Tolkien", authorFirstName: "J.R.R.",}).collect()All three queries hit GSI2 with the same partition key. DynamoDB serves them from the same item collection — three entity types, one efficient read.
Requirement 4: Member Account (Account Collection)
Section titled “Requirement 4: Member Account (Account Collection)”As an account system, show a member’s profile alongside their loaned books.
Member and Book share GSI1 via the Account collection. Both use memberId as the partition key.
// Member profileconst { Members: aliceAccount } = yield* db.collections.account!({ memberId: "m-alice",}).collect()
// Alice's loaned books (initially empty -- sparse index)const { Books: aliceLoans } = yield* db.collections.account!({ memberId: "m-alice" }).collect()Books only appear in the Account collection when memberId and loanEndDate are populated. This is the sparse index pattern in action.
Requirement 5: Books by Title (Titles Collection)
Section titled “Requirement 5: Books by Title (Titles Collection)”Search for books and genres by title.
Book and Genre share GSI3 via the Titles collection. Both use bookTitle as the partition key.
const { Books: foundationByTitle } = yield* db.collections.titles!({ bookTitle: "Foundation",}).collect()
const { Genres: foundationGenresByTitle } = yield* db.collections.titles!({ bookTitle: "Foundation",}).collect()Requirement 6: Genre Categories
Section titled “Requirement 6: Genre Categories”Browse all books in a genre category.
The byGenre index on Genre (GSI1) is a standalone entity-level index for genre browsing. It uses genre as PK and subgenre as SK for hierarchical browsing.
// All Sci-Fi subgenresconst sciFiGenres = yield* db.entities.Genres.byGenre({ genre: "Sci-Fi" }).collect()// → [// { subgenre: "Robotics", bookTitle: "I, Robot", ... },// { subgenre: "Space Opera", bookTitle: "Foundation", ... },// ]
// All Fantasy subgenresconst fantasyGenres = yield* db.entities.Genres.byGenre({ genre: "Fantasy" }).collect()// → [{ subgenre: "High Fantasy", bookTitle: "The Hobbit", ... }]Requirement 7: Loan a Book (Sparse Index)
Section titled “Requirement 7: Loan a Book (Sparse Index)”As circulation, loan a book to a member so it appears in their account.
Updating a book’s memberId and loanEndDate populates the GSI1 composites, causing the book to appear in the Account collection’s sparse index. Before the update, those fields are absent, so the book is invisible in GSI1.
// #region loan-book yield* db.entities.Books.update({ isbn: "978-0-547-928227", bookId: "b-hobbit" }).set({ memberId: "m-alice", loanEndDate: "2025-04-01", // Must provide all GSI composites for affected indexes authorLastName: "Tolkien", authorFirstName: "J.R.R.", bookTitle: "The Hobbit", releaseDate: "1937-09-21", })
// Now the book appears in Alice's loans const { Books: aliceLoansAfter } = yield* db.collections.account!({ memberId: "m-alice", }).collect() // → [{ bookTitle: "The Hobbit", isbn: "978-0-547-928227", ... }]
// Bob still has no loans const { Books: bobLoans } = yield* db.collections.account!({ memberId: "m-bob" }).collect() // → []When you update fields that participate in GSI composites, you must provide all composites for every affected GSI. Here the update touches memberId (Account GSI1), so loanEndDate is also required. The author and title fields are needed for GSI2 and GSI3 recomposition.
Requirement 8: Return a Book (Transaction)
Section titled “Requirement 8: Return a Book (Transaction)”As circulation, return a book by clearing the loan and removing it from the loans index.
To return a book, re-put it without the loan fields. Since memberId and loanEndDate are omitted, the book drops out of the sparse Account collection. Transaction.transactWrite ensures the operation is atomic.
yield* Transaction.transactWrite([ Books.put({ bookId: "b-hobbit", bookTitle: "The Hobbit", description: "A fantasy novel about the adventures of Bilbo Baggins", publisher: "George Allen & Unwin", releaseDate: "1937-09-21", authorFirstName: "J.R.R.", authorLastName: "Tolkien", isbn: "978-0-547-928227", // memberId and loanEndDate intentionally omitted -- clears the loan }),])
// Book is no longer in Alice's loansconst { Books: aliceLoansCleared } = yield* db.collections.account!({ memberId: "m-alice",}).collect()
// But the book still exists and is intactconst returnedHobbit = yield* db.entities.Books.get({ isbn: "978-0-547-928227", bookId: "b-hobbit",})
// And still appears in author's worksconst { Books: tolkienBooksAfter } = yield* db.collections.works!({ authorLastName: "Tolkien", authorFirstName: "J.R.R.",}).collect()Running the Example
Section titled “Running the Example”The complete runnable example is at examples/library-system.ts in the repository. It seeds data, runs all 8 access patterns, and verifies each with assertions.
docker run -d -p 8000:8000 amazon/dynamodb-localnpx tsx examples/library-system.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" }, }), LibTable.layer({ name: "library-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 | 4 entities, 1 table, structured key values |
| Index overloading | GSI1 serves book loans, genre categories, and member accounts |
| Collections | Works (GSI2), Account (GSI1), and Titles (GSI3) enable cross-entity queries |
| Sparse index | Book loans collection only populated when memberId + loanEndDate are present |
| All-or-none GSI updates | Loaning a book requires all composites for every affected GSI |
| Transactions | Atomic book returns via Transaction.transactWrite |
| Optional fields drive visibility | Schema.optional fields control sparse index membership |
What’s Next?
Section titled “What’s Next?”- Modeling Guide — Deep dive into models, schemas, tables, and entities
- Indexes & Collections — Access pattern design and collection patterns
- Human Resources — Similar patterns with soft delete and restore
- Task Manager — Status workflows, range queries, and soft delete archival