Skip to content

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 collection names 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

As stakeholders of this library system, we need to support these access patterns:

  1. Librarian — Get author details by name
  2. Patron — Get book and genre details by ISBN
  3. Catalog — Get an author’s complete works (books + genres)
  4. Account — See a member’s profile and loaned books
  5. Search — Find books and genres by title
  6. Browse — Browse genres by category (Sci-Fi, Fantasy, etc.)
  7. Circulation — Loan a book to a member (sparse index)
  8. Circulation — Return a book atomically via transaction

All four entities share a single table with 3 Global Secondary Indexes:

KeyFieldUsed by
PKpkAuthor (by lastName), Book (by isbn), Genre (by isbn), Member (by memberId)
SKskAuthor (firstName), Book (bookId), Genre (genre + subgenre), Member
GSI1 PKgsi1pkBook (by memberId — loans), Genre (by genre), Member (by memberId)
GSI1 SKgsi1skBook (loanEndDate), Genre (subgenre), Member
GSI2 PKgsi2pkAuthor (by name), Book (by author name), Genre (by author name)
GSI2 SKgsi2skAuthor, Book (bookId), Genre (genre)
GSI3 PKgsi3pkBook (by bookTitle), Genre (by bookTitle)
GSI3 SKgsi3skBook (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:

CollectionIndexPartition KeyEntities
WorksGSI2authorLastName + authorFirstNameAuthor, Book, Genre
AccountGSI1memberIdMember, Book
TitlesGSI3bookTitleBook, Genre
byGenre (index)GSI1genreGenre (standalone)

Pure domain models with no DynamoDB concepts:

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


entities.ts
const LibSchema = DynamoSchema.make({ name: "library", version: 1 })
entities.ts
const LibTable = Table.make({ schema: LibSchema, entities: { Authors, Books, Genres, Members } })

Author defines a primary key by last name + first name, plus a GSI index participating in the works collection:

entities.ts
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 defines a primary key and three GSI indexes. The account index on GSI1 uses optional fields, creating a sparse index:

entities.ts
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 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:

entities.ts
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 defines a primary key and one GSI index. GSI1 participates in the account collection alongside Book loans:

entities.ts
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 are defined as entity-level indexes. Multi-entity collections (account, works, titles) are auto-discovered from matching collection names across entities:

collections.ts
// 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:

  • account collection (GSI1) uses memberId as PK. Book’s loanEndDate is Schema.optional, so books without a loan are invisible in this index (sparse index pattern)
  • works collection (GSI2) groups Author, Book, and Genre by author name — three entity types in one partition
  • titles collection (GSI3) groups Books and Genres by title
  • byGenre index (GSI1) is a standalone entity-level index for genre browsing, not part of any collection

seed.ts
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
seed.ts
// Typed execution gateway — binds all entities and collections
const 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)
}

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 book
const 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 ISBN
const 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 books
const { Books: asimovBooks } = yield* db.collections.works!({
authorLastName: "Asimov",
authorFirstName: "Isaac",
}).collect()
// Asimov's genres
const { Genres: asimovGenres } = yield* db.collections.works!({
authorLastName: "Asimov",
authorFirstName: "Isaac",
}).collect()
// Author's own record in the works collection
const { 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 profile
const { 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()

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 subgenres
const sciFiGenres = yield* db.entities.Genres.byGenre({ genre: "Sci-Fi" }).collect()
// → [
// { subgenre: "Robotics", bookTitle: "I, Robot", ... },
// { subgenre: "Space Opera", bookTitle: "Foundation", ... },
// ]
// All Fantasy subgenres
const fantasyGenres = yield* db.entities.Genres.byGenre({ genre: "Fantasy" }).collect()
// → [{ subgenre: "High Fantasy", bookTitle: "The Hobbit", ... }]

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 loans
const { Books: aliceLoansCleared } = yield* db.collections.account!({
memberId: "m-alice",
}).collect()
// But the book still exists and is intact
const returnedHobbit = yield* db.entities.Books.get({
isbn: "978-0-547-928227",
bookId: "b-hobbit",
})
// And still appears in author's works
const { Books: tolkienBooksAfter } = yield* db.collections.works!({
authorLastName: "Tolkien",
authorFirstName: "J.R.R.",
}).collect()

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.

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local
Terminal window
npx tsx examples/library-system.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" },
}),
LibTable.layer({ name: "library-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 design4 entities, 1 table, structured key values
Index overloadingGSI1 serves book loans, genre categories, and member accounts
CollectionsWorks (GSI2), Account (GSI1), and Titles (GSI3) enable cross-entity queries
Sparse indexBook loans collection only populated when memberId + loanEndDate are present
All-or-none GSI updatesLoaning a book requires all composites for every affected GSI
TransactionsAtomic book returns via Transaction.transactWrite
Optional fields drive visibilitySchema.optional fields control sparse index membership