Skip to content

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

This example supports six access patterns on a single entity:

  1. Get store — Look up a specific store by city, mall, building, and store ID (primary key)
  2. Stores in a mall — Find all stores in a given mall (byMall index, GSI1)
  3. Stores in a building — Narrow to a specific building within a mall (byMall index + SK composites)
  4. Lease renewals — Find leases expiring within a date range (byStore index, GSI2)
  5. Update store — Change rent and discount for a store
  6. Delete store — Remove a store from the directory

A pure domain model with no DynamoDB concepts:

models.ts
const StoreCategory = {
FoodCoffee: "food/coffee",
FoodMeal: "food/meal",
Clothing: "clothing",
Electronics: "electronics",
Department: "department",
Misc: "misc",
} as const
const 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:

  • category uses a const object + Schema.Literals(Object.values(...)) for a constrained set of store types
  • discount has a decoding default of "0.00" — if omitted during put, it defaults automatically
  • deposit is optional — not every store has one
  • rent, leaseEndDate are strings for lexicographic sort key ordering in GSI queries

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

The entity defines a composite primary key and two GSI indexes. The table declares the entity as a member:

entities.ts
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 are now defined as entity-level indexes on the entity definition above:

collections.ts
// GSI access patterns are now defined as entity-level indexes above.
IndexPhysical GSIPK compositesSK compositesAccess pattern
byMallgsi1mallIdbuildingId, unitIdStores in a mall or building
byStoregsi2storeIdleaseEndDateLease 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.


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

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

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 → 7000

Pattern 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-galore

Pattern 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-threads

The 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 Q1
const 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 Q1
const 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-zone
const 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.

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)

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 store
const westgateAfter = yield* db.entities.MallStores.byMall({ mallId: "westgate" }).collect()
// → 1 store: mega-mart

After deletion, the store disappears from both the primary key and all GSI queries.


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.

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local
Terminal window
npx tsx examples/shopping-mall.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" },
}),
MallTable.layer({ name: "mall-table" }),
)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then(
() => console.log("\nDone."),
(err) => console.error("\nFailed:", err),
)

ConceptHow it’s used
Composite primary key4 attributes (2 PK + 2 SK) uniquely identify each store
Entity-level GSI indexesbyMall index queries by mall; byStore index queries by store + date
SK composite filteringPassing buildingId to byMall index filters stores within a building
Date range queriesLease expiration date filtering for date windows
Schema defaultsdiscount defaults to "0.00" via Schema.withDecodingDefaultKey
Optional fieldsdeposit is Schema.optional — not every store requires one