DEV Community

Mohan
Mohan

Posted on

Building Skill Exchange: what running a two-sided marketplace on ONE DynamoDB table taught me

Single-table DynamoDB design gets talked about like a religion — either you've seen the light or you're "doing it wrong with five tables." That framing wasn't much help when I actually had to build a two-sided marketplace on it. What helped was ignoring the doctrine and starting from the only thing DynamoDB really forces you to do: know your queries before your schema.

I built Skill Exchange — a marketplace for reusable AI skills (link at the end) — on a single table. Sellers publish, buyers browse and purchase, everything has a review queue, and there's a "my library" and a leaderboard. That's a lot of different reads for one table. Here's what building it actually taught me.

Model the access patterns first — the table shape falls out of the queries

In a relational DB you model entities and figure out queries later. DynamoDB punishes that: there are no joins, and a query can only ever hit one partition (or one index). So I started by listing every read the app does, before writing a single PutItem:

  • get a skill / a user / a purchase by id
  • list a category's skills, sorted by downloads
  • list one seller's skills
  • list one buyer's purchases ("my library")
  • list skills awaiting review (the moderation queue)
  • list the live marketplace

Every one of those is a partition-key lookup or a GSI query. The table shape is just whatever makes those cheap. You don't design the schema and derive the queries — you design the queries and the schema is the residue.

One table, many entity types — the key prefix is the type

There's no type column doing the work; the PK/SK prefix is the discriminator:

PK                     SK                 entity
USER#<id>              PROFILE            a user
USER#<id>              BADGE#<type>       a badge they hold
SKILL#<id>             META               a listing
SKILL#<id>             REVIEW#<id>        a review of it
PURCHASE#<skill>#<buyer>  META            a purchase (id encodes uniqueness)
COUPON#<CODE>          META               a discount code
COUPON#<CODE>          USED#<userId>      one redemption (one per user)
Enter fullscreen mode Exit fullscreen mode

Two things this buys you. A single Query on PK = SKILL#<id> returns the listing and all its reviews in one round trip. And a purchase's PK is PURCHASE#<skill>#<buyer> — the uniqueness is in the key, which matters in a second.

Overload GSIs — one index serves many lists

You get a limited number of GSIs, so each one earns its keep by serving several access patterns via a synthetic partition key:

GSI1  PK = CAT#<category>     SK = downloadsCount   → browse a category, sorted by popularity
GSI2  PK = SELLER#<userId>    SK = createdAt        → a seller's skills, newest first
GSI3  PK = BUYER#<userId>     SK = purchasedAt      → "my library"
GSI4  PK = SKILL#<status>     SK = submittedAt      → review queue AND the live marketplace
Enter fullscreen mode Exit fullscreen mode

Nothing here is "the skills index" or "the users index." They're query indexes. GSI1's partition key is a category string that only exists to make category-browse a single partition read.

Your moderation queue is just a GSI on status

GSI4 is my favourite because one index quietly powers two completely different screens. Every skill carries GSI4PK = SKILL#<status> where status is draftpendingapproved:

// The founder's review queue: everything waiting on approval
const queue = await db.queryAll({
  IndexName: "GSI4",
  KeyConditionExpression: "GSI4PK = :pk",
  ExpressionAttributeValues: { ":pk": "SKILL#pending" },
});

// The public marketplace: exactly the same query, different partition
const live = await db.queryAll({
  IndexName: "GSI4",
  KeyConditionExpression: "GSI4PK = :pk",
  ExpressionAttributeValues: { ":pk": "SKILL#approved" },
});
Enter fullscreen mode Exit fullscreen mode

Approving a skill is a single UpdateItem that flips GSI4PK from SKILL#pending to SKILL#approved — the item moves from one index partition to the other, and it leaves the queue and enters the marketplace atomically. No status column to scan, no separate "published" flag to keep in sync.

Make writes idempotent with the key + a conditional put

Payment webhooks retry. Users double-click. So a purchase must never be recorded twice. Because the purchase's uniqueness lives in its key, one conditional write does it:

await db.put(purchase, { ConditionExpression: "attribute_not_exists(PK)" });
// PK = PURCHASE#<skill>#<buyer> — a second attempt throws
// ConditionalCheckFailedException, which I catch and treat as "already done."
Enter fullscreen mode Exit fullscreen mode

Only the first write proceeds; there it bumps the skill's download counter and the seller's sales counter. A retry or a race is a no-op, not a double-count. The same trick enforces "one coupon redemption per user" — the redemption row's key is COUPON#<CODE> / USED#<userId>, so a second redeem simply can't be written.

Denormalize for reads, reconcile on a schedule

The marketplace card shows the seller's "verified" checkmark. Joining to the user on every card render is exactly what DynamoDB won't do — so I copy sellerVerified onto the skill item and read it for free. The cost of denormalization is drift: verify a creator and their existing cards are briefly stale. I don't fix that inline; a nightly job re-stamps the denormalized fields (and recomputes badges + leaderboard ranks) in one pass. Reads stay single-item and fast; consistency is a scheduled concern, not a per-request one.

What I'd tell the next person

  • Write the list of every read first. The schema is downstream of the queries, always.
  • The key prefix is your type system; a shared PK lets you fetch a parent and its children in one query.
  • GSIs are query indexes, not entity mirrors — overload them with synthetic partition keys.
  • Model state transitions (draft→pending→approved) as a GSI partition, not a scanned attribute.
  • Put uniqueness in the key and let attribute_not_exists make writes idempotent.
  • Denormalize what reads need; reconcile drift on a schedule, not on the request path.

Single-table isn't magic and it isn't a purity test. It's just what you get when you let the access patterns — not the entities — decide the shape.


This is from building Skill Exchange, a marketplace for reusable AI skills where every listing has to prove it works.

I'm launching it on Product Hunt — you can follow along / see it here, and I'd genuinely love your feedback there. Happy to go deeper on any of the DynamoDB bits in the comments.

Top comments (3)

Collapse
 
wrobeltomasz profile image
Tomasz

Great writeup on the access-pattern-first approach! One question though: why DynamoDB over Postgres with JSONB here? You could've kept it in one table too — a data JSONB column plus GIN indexes for the category/seller/buyer/status lookups, and a partial index for the moderation queue instead of a synthetic GSI partition key.

What you'd gain: real ACID transactions across rows (handy for purchase + counter updates), a UNIQUE constraint instead of key-encoded uniqueness, and ad-hoc queries for reporting without pre-modeling every access pattern. At this scale, was DynamoDB's ops-free scaling worth trading those away?

Collapse
 
mohanvenkatakrishnan profile image
Mohan

Honestly, you're right on all three — I went back and forth on exactly this.

The deciding factor wasn't that DynamoDB is technically better here; it was cost + the serverless fit. It's a solo, pre-revenue product on Lambda, and DynamoDB's permanent free tier and zero idle cost are hard to beat — Postgres means either an always-on RDS instance (plus RDS Proxy to survive Lambda's connection churn) or Aurora Serverless with a minimum-ACU floor, both of which cost money while nothing's happening. On-demand DynamoDB is $0 at rest, which matters a lot when you're trying to break even.

On your specific points:

  • ACID: I lean on TransactWriteItems for the purchase + counter write and idempotent conditional puts elsewhere — but you're right it's more constrained than real cross-row transactions.
  • UNIQUE: yep, key-encoded uniqueness is a workaround for not having a real UNIQUE constraint. Fair hit.
  • Ad-hoc queries: this is the one I actually feel. My daily analytics job literally scans the whole table because there's no ad-hoc query path — in Postgres I'd just write SQL.

So at this scale, Postgres + JSONB + GIN would've been completely reasonable, arguably nicer for reporting. If the analytics side grows I'd probably move that to Postgres rather than keep scanning. The DynamoDB choice is really "cheapest thing that fits a Lambda stack," not "the objectively right database."

Appreciate the sharp question — this is exactly the trade-off worth being honest about.

(Unrelated: the marketplace this is from, Skill Exchange, is live on Product Hunt today — if you've got a minute I'd genuinely value your feedback there, you clearly know your stuff → producthunt.com/products/skill-exc...)

Collapse
 
wrobeltomasz profile image
Tomasz

One more thought on the DynamoDB vs Postgres question — have you looked at Neon specifically (as opposed to Aurora)? A few concrete numbers from their docs that seem relevant to your Lambda setup:

Cold start after scale-to-zero is a few hundred ms (their docs cite "typically takes a few hundred milliseconds" to reactivate — a different order of magnitude than Aurora's ~15s pause/resume)
They ship a serverless driver (@neondatabase/serverless) that queries over HTTP or WebSockets instead of raw TCP, specifically built for Lambda/Edge functions — no persistent connection pool needed, which sidesteps the RDS Proxy problem you mentioned
Storage and compute are separated, so scale-to-zero doesn't touch your data — reactivation just reattaches compute to existing storage

Given your pain point was really "ad-hoc analytics require a full table scan," this seems like it'd solve that specifically while keeping the near-zero idle cost. Curious if you evaluated it and what pushed you toward DynamoDB/Aurora instead of Neon.