Skip to content

DynamoDB Schema Pattern: Marketplace (Two-Sided)

A two-sided marketplace - Etsy, Airbnb, Uber, eBay - has the same data model whether you’re moving handcrafted goods or vacation rentals. Buyers and sellers are users with different verbs. Listings flow into orders. Orders flow into reviews. Reviews flow back into seller reputation, which influences future listings.

The schema problem is fitting all these entities and access patterns into a single DynamoDB table without exploding the GSI count. The trick is recognizing that buyers and sellers are the same entity with different roles, and that “reviews about user X” and “reviews written by user X” are two cuts of the same data.

This pattern handles 11 access patterns with 4 entities and 2 GSIs.

Access patterns

#Access PatternOperation
AP1Get user by IDGetItem
AP2Get user by email (login)Query (GSI)
AP3Get listing by IDGetItem
AP4List seller’s listings (any status)Query (GSI)
AP5List seller’s active listingsQuery (GSI, prefix on status)
AP6Browse active listings by categoryQuery (GSI, sparse)
AP7Get order by IDGetItem
AP8List buyer’s order historyQuery (GSI)
AP9List seller’s sales historyQuery (GSI)
AP10List reviews about a userQuery (primary)
AP11List reviews written by a userQuery (GSI)

Eleven patterns, two GSIs (overloaded).

Entities

  • User: buyer, seller, or both. One entity, one role attribute.
  • Listing: what’s for sale, scoped to a seller, with a category for browse.
  • Order: the purchase transaction. References buyer, seller, and listing.
  • Review: mutual feedback after an order. Each order produces up to two reviews (buyer reviews seller, seller reviews buyer).
Marketplace entity diagram showing User, Listing, Order, Review relationships
4 entities · 2 GSIs · users play both roles, reviews flow both ways

Table design

Primary key structure

EntityPKSKPurpose
UserUSER#<userId>#METADATAUser profile
ListingLISTING#<listingId>#METADATAListing details
OrderORDER#<orderId>#METADATAOrder details
ReviewUSER#<reviewedUserId>REVIEW#<ulid>Review about a user (lives in user partition)

The clever bit: reviews are stored in the partition of the user being reviewed, not in their own partition. That makes “list reviews about user X” (AP10) a single primary-table query - no GSI needed. The Review entity uses ULIDs for the SK so reviews sort chronologically and can be looked up directly.

GSI design (overloaded)

GSIEntitygsi1pk / gsi2pkgsi1sk / gsi2skPurpose
GSI1UserUSER_EMAIL#<email>USER#<userId>Login by email
GSI1ListingSELLER#<sellerId>LISTING#<status>#<listingId>Seller dashboard, filter by status
GSI1OrderBUYER#<buyerId>ORDER#<orderId>Buyer’s order history
GSI1ReviewAUTHOR#<authorId>REVIEW#<reviewId>Reviews written by user
GSI2ListingCATEGORY#<category>LISTING#<listingId>Active-listings browse (sparse)
GSI2OrderSELLER#<sellerId>ORDER#<orderId>Seller’s sales history

The GSI1 partition prefixes (USER_EMAIL#, SELLER#, BUYER#, AUTHOR#) never collide, so all four entities share the index without stepping on each other. GSI2 is the same trick (CATEGORY# and SELLER# are distinct).

GSI2 for Listings is sparse: only listings with status = "active" write to it. As soon as a listing is marked sold or removed, the activeFlag attribute is removed and the listing drops out of the category browse index. The browse query is always cheap because the index only contains live listings. See DynamoDB Sparse Indexes for the full pattern.

Marketplace DynamoDB schema: PK, SK, GSI columns for User, Listing, Order, Review
4 entities · 2 GSIs · sparse category browse

Sample data

pkskgsi1pkgsi1skgsi2pkgsi2skEntity Data
USER#u_alice#METADATAUSER_EMAIL#alice@x.comUSER#u_alice--{ name: "Alice", role: "both", rating: 4.8, reviewCount: 12 }
USER#u_bob#METADATAUSER_EMAIL#bob@x.comUSER#u_bob--{ name: "Bob", role: "buyer", rating: 5.0 }
USER#u_aliceREVIEW#01HW...AUTHOR#u_bobREVIEW#01HW...--{ authorId: "u_bob", rating: 5, body: "Great seller!", orderId: "ord_01HV..." }
LISTING#l_01#METADATASELLER#u_aliceLISTING#active#l_01CATEGORY#potteryLISTING#l_01{ title: "Stoneware mug", price: 28, status: "active", category: "pottery" }
LISTING#l_02#METADATASELLER#u_aliceLISTING#sold#l_02--{ title: "Vase", price: 65, status: "sold" } (no GSI2 - sparse)
ORDER#ord_01HV...#METADATABUYER#u_bobORDER#ord_01HV...SELLER#u_aliceORDER#ord_01HV...{ listingId: "l_02", total: 65, status: "delivered" }

Notice the sold listing (l_02) has no gsi2pk - it’s not in the category browse index. Active listing (l_01) is. That’s the sparse index in action.

Resolving each access pattern

AP1 - Get user by ID: GetItem(pk=USER#u_alice, sk=#METADATA)

AP2 - Get user by email: Query(GSI1, gsi1pk=USER_EMAIL#alice@x.com)

AP3 - Get listing by ID: GetItem(pk=LISTING#l_01, sk=#METADATA)

AP4 - List seller’s listings (any status):

Query(GSI1, gsi1pk=SELLER#u_alice, sk begins_with LISTING#)

Returns all listings, sorted by status then ID.

AP5 - List seller’s active listings:

Query(GSI1, gsi1pk=SELLER#u_alice, sk begins_with LISTING#active#)

Same partition, narrower SK prefix. No extra GSI needed - the sort-key-as-hierarchy trick handles status filtering.

AP6 - Browse active listings by category:

Query(GSI2, gsi2pk=CATEGORY#pottery)

Sparse GSI - only active listings are in this index. Sorted by ULID (newest first with ScanIndexForward: false).

AP7 - Get order by ID: GetItem(pk=ORDER#ord_01HV..., sk=#METADATA)

AP8 - List buyer’s order history:

Query(GSI1, gsi1pk=BUYER#u_bob, ScanIndexForward=false)

AP9 - List seller’s sales history:

Query(GSI2, gsi2pk=SELLER#u_alice, ScanIndexForward=false)

AP10 - List reviews about a user:

Query(pk=USER#u_alice, sk begins_with REVIEW#)

Primary table. Reviews live in the partition of the user being reviewed.

AP11 - List reviews written by a user:

Query(GSI1, gsi1pk=AUTHOR#u_bob)

ElectroDB entity definitions

export const UserEntity = new Entity({
  model: { entity: "user", version: "1", service: "marketplace" },
  attributes: {
    userId: { type: "string", required: true },
    name:   { type: "string", required: true },
    email:  { type: "string", required: true },
    role:   { type: ["buyer", "seller", "both"], required: true, default: "buyer" },
    rating: { type: "number" },              // computed avg of reviews about user
    reviewCount: { type: "number", default: 0 },
    createdAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(), readOnly: true,
    },
    updatedAt: {
      type: "string", required: true,
      default: () => new Date().toISOString(),
      set: () => new Date().toISOString(), watch: "*",
    },
  },
  indexes: {
    primary: {
      pk: { field: "pk", composite: ["userId"], template: "USER#${userId}" },
      sk: { field: "sk", composite: [],         template: "#METADATA" },
    },
    byEmail: {
      index: "GSI1",
      pk: { field: "gsi1pk", composite: ["email"],  template: "USER_EMAIL#${email}" },
      sk: { field: "gsi1sk", composite: ["userId"], template: "USER#${userId}" },
    },
  },
}, { client, table });

Why this design

Buyer and seller are not different tables. A user transitions from buyer-only to “both” the first time they list something. This avoids duplicating user records and lets reputation aggregate across both sides of their behavior.

Reviews live in the reviewed user’s partition. The most common review query is “show me reviews about this seller” (AP10). Storing reviews as USER#<reviewedUserId> / REVIEW#<ulid> makes that a primary-table query with no GSI cost. The reverse direction (“reviews I’ve written”) is the GSI lookup, which is rarer.

Putting status in the GSI sort key as a prefix lets the same GSI serve “all my listings” and “my active listings” with different prefix scopes. No extra GSI for the status filter.

Active listings represent maybe 5-10% of all listings ever created. Indexing every listing in the category browse GSI would be 10-20x the storage and write cost for no read benefit. The sparse index pattern keeps GSI2 lean.

Order placement should be transactional. Creating an order, decrementing the listing’s status to “sold”, and possibly updating buyer/seller balances are all part of the same logical operation. Wrap them in TransactWriteItems to prevent the “order created but listing still says active” failure mode. The e-commerce orders pattern shows the multi-item transaction shape.

What I’d add for production:

  • Search. “Find listings matching ‘blue ceramic mug’” is not a DynamoDB query. Stream listings to OpenSearch via DynamoDB Streams.
  • Wishlist / favorites. Junction entity: USER#<userId> / FAVORITE#<listingId> for “things this user has saved.”
  • Messages between buyer and seller. A separate conversation pattern - see the chat messaging pattern.
  • Rating aggregation. The rating and reviewCount on User are denormalized for fast display. Update them via a Stream-driven projection rather than recomputing on every read.
  • Idempotency on order creation. Buyer double-clicks should not create two orders. Use idempotency keys keyed on the buyer’s request.

What this schema doesn’t support

Unsupported QueryWhyIf You Need It
Full-text search across listingsDynamoDB doesn’t do searchStream to OpenSearch
”Listings near me” (geospatial)No geohash in keysAdd a geohash GSI or use Aurora
Listings priced under $XNo index on priceAdd price-bucketed sparse GSI
All orders in date range across all usersNo global timestamp indexStream to a warehouse, or add a date-bucketed sparse GSI
Average rating per categoryAggregation across many itemsMaintain in a separate aggregate item, updated via Streams

This is the “what you’re not supporting” exercise from unsupported queries - none of these are mistakes, but they’re choices worth being explicit about.

Design this visually → coming soon

Imagine dragging User, Listing, Order, Review onto a canvas and watching the GSI overloads collapse the index count from 4 to 2. That’s what I’m building at singletable.dev.

Join the waitlist →


Pattern #6 of 10 in the SingleTable pattern library. The two-sided design pairs naturally with the e-commerce orders pattern (one-sided variant) and the SaaS multi-tenant pattern (organizational variant). I’m building singletable.dev to make GSI overloading visible at design time.

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.