Example: Shopping Mall
A shopping mall directory application managing mall stores in a single DynamoDB table. Adapted from the ElectroDB Shopping Mall example to showcase effect-dynamodb’s approach to the same problem.
What you’ll learn:
- Single entity with a composite primary key (2 PK + 2 SK composites)
- 2 GSI indexes for different access patterns on the same entity
- Sort key prefix queries to filter by building
- Date range queries for lease expiration windows
- CRUD operations with composite keys
- Layer-based dependency injection
Access Patterns
Section titled “Access Patterns”This example supports six access patterns on a single entity:
- Get store — Look up a specific store by city, mall, building, and store ID (primary key)
- Stores in a mall — Find all stores in a given mall (
byMallindex, GSI1) - Stores in a building — Narrow to a specific building within a mall (
byMallindex + SK composites) - Lease renewals — Find leases expiring within a date range (
byStoreindex, GSI2) - Update store — Change rent and discount for a store
- Delete store — Remove a store from the directory
Step 1: Model
Section titled “Step 1: Model”A pure domain model with no DynamoDB concepts:
const StoreCategory = { FoodCoffee: "food/coffee", FoodMeal: "food/meal", Clothing: "clothing", Electronics: "electronics", Department: "department", Misc: "misc",} as constconst StoreCategorySchema = Schema.Literals(Object.values(StoreCategory))
class MallStore extends Schema.Class<MallStore>("MallStore")({ cityId: Schema.String, mallId: Schema.String, storeId: Schema.String, buildingId: Schema.String, unitId: Schema.String, category: StoreCategorySchema, leaseEndDate: Schema.String, rent: Schema.String, discount: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("0.00"))), deposit: Schema.optional(Schema.Number),}) {}Key modeling decisions:
categoryuses a const object +Schema.Literals(Object.values(...))for a constrained set of store typesdiscounthas a decoding default of"0.00"— if omitted duringput, it defaults automaticallydepositis optional — not every store has onerent,leaseEndDateare strings for lexicographic sort key ordering in GSI queries
Step 2: Schema, Entity, and Table
Section titled “Step 2: Schema, Entity, and Table”Schema
Section titled “Schema”const MallSchema = DynamoSchema.make({ name: "mall", version: 1 })MallStore Entity and Table
Section titled “MallStore Entity and Table”The entity defines a composite primary key and two GSI indexes. The table declares the entity as a member:
const MallStores = Entity.make({ model: MallStore, entityType: "MallStore", primaryKey: { pk: { field: "pk", composite: ["cityId", "mallId"] }, sk: { field: "sk", composite: ["buildingId", "storeId"] }, }, indexes: { byMall: { name: "gsi1", pk: { field: "gsi1pk", composite: ["mallId"] }, sk: { field: "gsi1sk", composite: ["buildingId", "unitId"] }, }, byStore: { name: "gsi2", pk: { field: "gsi2pk", composite: ["storeId"] }, sk: { field: "gsi2sk", composite: ["leaseEndDate"] }, }, }, timestamps: true,})
const MallTable = Table.make({ schema: MallSchema, entities: { MallStores } })GSI Access Patterns
Section titled “GSI Access Patterns”GSI access patterns are now defined as entity-level indexes on the entity definition above:
// GSI access patterns are now defined as entity-level indexes above.| Index | Physical GSI | PK composites | SK composites | Access pattern |
|---|---|---|---|---|
byMall | gsi1 | mallId | buildingId, unitId | Stores in a mall or building |
byStore | gsi2 | storeId | leaseEndDate | Lease expiration queries |
The composite primary key uses four attributes — cityId and mallId form the partition key, while buildingId and storeId form the sort key. This means a get requires all four values.
Step 3: Seed Data
Section titled “Step 3: Seed Data”const stores = { // Mall: EastPointe — Building A starCoffee: { cityId: "atlanta", mallId: "eastpointe", storeId: "star-coffee", buildingId: "bldg-a", unitId: "a-101", category: "food/coffee" as const, leaseEndDate: "2025-03-31", rent: "3500.00", discount: "0.00", deposit: 7000, }, burgerBarn: { cityId: "atlanta", mallId: "eastpointe", storeId: "burger-barn", buildingId: "bldg-a", unitId: "a-102", category: "food/meal" as const, leaseEndDate: "2025-06-30", rent: "4200.00", discount: "200.00", deposit: 8400, }, // Mall: EastPointe — Building B techZone: { cityId: "atlanta", mallId: "eastpointe", storeId: "tech-zone", buildingId: "bldg-b", unitId: "b-201", category: "electronics" as const, leaseEndDate: "2025-09-30", rent: "5500.00", discount: "0.00", deposit: 11000, }, trendyThreads: { cityId: "atlanta", mallId: "eastpointe", storeId: "trendy-threads", buildingId: "bldg-b", unitId: "b-202", category: "clothing" as const, leaseEndDate: "2025-12-31", rent: "4800.00", discount: "500.00", }, // Mall: WestGate (different mall, same city) megaMart: { cityId: "atlanta", mallId: "westgate", storeId: "mega-mart", buildingId: "bldg-c", unitId: "c-301", category: "department" as const, leaseEndDate: "2025-06-15", rent: "8000.00", discount: "0.00", deposit: 16000, }, giftsGalore: { cityId: "atlanta", mallId: "westgate", storeId: "gifts-galore", buildingId: "bldg-c", unitId: "c-302", category: "misc" as const, leaseEndDate: "2025-03-15", rent: "2800.00", discount: "0.00", },}Six stores across two malls (EastPointe and WestGate) in Atlanta, with leases expiring at different times throughout 2025.
const db = yield* DynamoClient.make({ entities: { MallStores },})
// --- Setup: create table ---yield* db.tables["mall-table"]!.create()
// --- Seed data ---for (const store of Object.values(stores)) { yield* db.entities.MallStores.put(store)}Step 4: Access Patterns
Section titled “Step 4: Access Patterns”Pattern 1: Get Store by Primary Key
Section titled “Pattern 1: Get Store by Primary Key”The composite primary key requires all four attributes — cityId, mallId, buildingId, and storeId:
const coffee = yield* db.entities.MallStores.get({ cityId: "atlanta", mallId: "eastpointe", buildingId: "bldg-a", storeId: "star-coffee",})// coffee.category → "food/coffee"// coffee.rent → "3500.00"// coffee.deposit → 7000Pattern 2: All Stores in a Mall (byMall Index)
Section titled “Pattern 2: All Stores in a Mall (byMall Index)”The byMall index on GSI1 uses mallId as the partition key. Querying with just the PK composites returns all stores in that mall:
const eastpointeStores = yield* db.entities.MallStores.byMall({ mallId: "eastpointe" }).collect()// → 4 stores: star-coffee, burger-barn, tech-zone, trendy-threads
const westgateStores = yield* db.entities.MallStores.byMall({ mallId: "westgate" }).collect()// → 2 stores: mega-mart, gifts-galorePattern 3: Stores in a Building (byMall Index + SK Composites)
Section titled “Pattern 3: Stores in a Building (byMall Index + SK Composites)”To narrow to a specific building, pass buildingId as an additional composite in the index query:
const bldgAStores = yield* db.entities.MallStores.byMall({ mallId: "eastpointe", buildingId: "bldg-a",}).collect()// → 2 stores: star-coffee, burger-barn
const bldgBStores = yield* db.entities.MallStores.byMall({ mallId: "eastpointe", buildingId: "bldg-b",}).collect()// → 2 stores: tech-zone, trendy-threadsThe sort key for byMall is composed from buildingId + unitId. Passing buildingId as an additional parameter filters stores to that building.
Pattern 4: Lease Renewals by Date Range (byStore Index)
Section titled “Pattern 4: Lease Renewals by Date Range (byStore Index)”The byStore index on GSI2 uses storeId as the partition key and leaseEndDate as the sort key. Query by store and filter the results by date range:
// star-coffee lease ends 2025-03-31 — within Q1const starCoffeeLeases = yield* db.entities.MallStores.byStore({ storeId: "star-coffee",}).collect()const starCoffeeQ1 = starCoffeeLeases.filter( (s) => s.leaseEndDate >= "2025-01-01" && s.leaseEndDate <= "2025-03-31",)// → 1 result (lease ends 2025-03-31)
// tech-zone lease ends 2025-09-30 — NOT in Q1const techZoneLeases = yield* db.entities.MallStores.byStore({ storeId: "tech-zone" }).collect()const techZoneQ1 = techZoneLeases.filter( (s) => s.leaseEndDate >= "2025-01-01" && s.leaseEndDate <= "2025-03-31",)// → 0 results
// Q3 2025 bounds for tech-zoneconst techZoneQ3 = techZoneLeases.filter( (s) => s.leaseEndDate >= "2025-07-01" && s.leaseEndDate <= "2025-09-30",)// → 1 result (lease ends 2025-09-30)Because leaseEndDate is an ISO date string, it sorts lexicographically in chronological order — no padding or transformation needed.
Pattern 5: Update Store
Section titled “Pattern 5: Update Store”Update non-key attributes with Entity.set(). The composite key identifies which item to update:
// #region pattern-5 const updated = yield* db.entities.MallStores.update({ cityId: "atlanta", mallId: "eastpointe", buildingId: "bldg-a", storeId: "star-coffee", }).set({ rent: "4000.00", discount: "100.00", }) // updated.rent → "4000.00" // updated.discount → "100.00" // updated.category → "food/coffee" (preserved)Pattern 6: Delete Store
Section titled “Pattern 6: Delete Store”Delete requires the full composite key:
yield* db.entities.MallStores.delete({ cityId: "atlanta", mallId: "westgate", buildingId: "bldg-c", storeId: "gifts-galore",})
// Verify: westgate now has 1 storeconst westgateAfter = yield* db.entities.MallStores.byMall({ mallId: "westgate" }).collect()// → 1 store: mega-martAfter deletion, the store disappears from both the primary key and all GSI queries.
Running the Example
Section titled “Running the Example”The complete runnable example is at examples/shopping-mall.ts in the repository. It seeds data, runs all 6 access patterns, and verifies each with assertions.
docker run -d -p 8000:8000 amazon/dynamodb-localnpx tsx examples/shopping-mall.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" }, }), MallTable.layer({ name: "mall-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 |
|---|---|
| Composite primary key | 4 attributes (2 PK + 2 SK) uniquely identify each store |
| Entity-level GSI indexes | byMall index queries by mall; byStore index queries by store + date |
| SK composite filtering | Passing buildingId to byMall index filters stores within a building |
| Date range queries | Lease expiration date filtering for date windows |
| Schema defaults | discount defaults to "0.00" via Schema.withDecodingDefaultKey |
| Optional fields | deposit is Schema.optional — not every store requires one |
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: Blog Platform — Multi-entity single-table design with transactions
- Example: Human Resources — 3 entities, 5 GSIs, collections, and soft delete