Why We Used Two Databases for One Feature — and Why That Was the Right Call
Building ClearLot's zero-oversell claim system on Aurora PostgreSQL + DynamoDB via Vercel's AWS integration
When we started building ClearLot — a B2B Dutch-auction marketplace for excess inventory — the architecture question we kept coming back to wasn't "which database should we use?" It was: "what has to be absolutely, provably correct, and what just needs to be well-organised?"
The answer to that question split our data into two distinct concerns, and that split drove every infrastructure decision that followed.
The Core Problem: Flash Sales Kill Databases
ClearLot runs Dutch-auction "drops." A brand lists 500 units of a product. The price decays in real time from a start price toward a floor. Buyers watch the price fall and claim units when they're ready. When the demo load-test button fires 200 concurrent claim requests against a drop with 10 units remaining, only 10 claims should succeed. The other 190 should get a clean rejection. Not 11. Not 15. Exactly 10.
This is the overselling problem, and it's harder than it looks. A naive implementation — read the remaining count, check if there's enough, decrement, write back — fails under concurrent load because two requests can both read "10 units remaining," both pass the check, and both write back "9 units remaining." You've just sold the same unit twice.
The standard solutions are either pessimistic locking (which serialises all requests and destroys throughput) or optimistic concurrency control (which requires retries and can still produce anomalies under the exact conditions you most care about: high contention on a single hot item).
There's a better answer for this specific problem, and it lives in DynamoDB.
Why DynamoDB for Inventory
DynamoDB's
UpdateItem with ConditionExpression is an atomic operation. The increment, the check, and the write all happen in a single operation at the storage layer. There's no window between "read the count" and "write the new count" where another request can sneak in.1
2
3
4
5
6
7
8
await dynamo.send(new UpdateCommand({
TableName: TABLE,
Key: { PK: `DROP#${dropId}`, SK: "INVENTORY" },
UpdateExpression: "SET remaining_units = remaining_units - :qty",
ConditionExpression: "remaining_units >= :qty AND #s = :live",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":qty": units, ":live": "live" },
}));If
remaining_units is 10 and 200 requests fire simultaneously asking for 1 unit each, DynamoDB will succeed exactly 10 times and throw ConditionalCheckFailedException 190 times. No overselling. No locks. No retries needed. The mechanism is in the database, not in application logic.This was the first firm architectural decision: DynamoDB owns inventory state.
Why We Ruled Out Aurora DSQL
Before committing to the dual-database design, we evaluated Aurora DSQL — AWS's serverless, distributed PostgreSQL-compatible database. It looked attractive on the surface: distributed, scales to zero, PostgreSQL-compatible, and available directly through Vercel's AWS Marketplace integration.
We ruled it out for one specific reason: DSQL's concurrency model.
DSQL uses optimistic concurrency control (OCC). In OCC, transactions proceed without locks and validate at commit time. If two transactions touch the same row, one commits and one aborts with a conflict error. The application must catch the error and retry.
For most workloads, OCC is excellent. But for a flash-sale inventory counter — exactly one hot key being hit by hundreds of concurrent requests — OCC becomes an amplification problem. Under high contention, conflict rates rise, retry rates rise, and latency becomes unpredictable. AWS's own DSQL documentation explicitly calls out single hot-key contention as an anti-pattern for the system.
DynamoDB's conditional writes don't have this property. They're not OCC — they're atomic operations at the storage level. There's no retry loop. There's no conflict. There's a success or a failure, and both are immediate and definitive.
The rule we arrived at: if your workload involves concurrent modification of a single hot value, DynamoDB conditional writes are the right tool. OCC is the wrong tool.
Why Aurora PostgreSQL for Everything Else
Once DynamoDB owned inventory state, the question was what owned the rest: brands, lots, drops, buyers, claims, invoices, recovery analytics.
This data is fundamentally relational. A claim belongs to a drop. A drop belongs to a lot. A lot belongs to a brand. A buyer can have many claims. An invoice references a claim and a buyer. The recovery rate report joins across all of these tables.
1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT
l.name AS lot_name,
l.msrp_cents,
COALESCE(SUM(c.units_claimed * c.price_per_unit_cents), 0) AS recovered_cents,
ROUND(
COALESCE(SUM(c.units_claimed * c.price_per_unit_cents), 0)::numeric
/ NULLIF(l.msrp_cents, 0) * 100, 2
) AS recovery_rate_pct
FROM lots l
LEFT JOIN drops d ON d.lot_id = l.id
LEFT JOIN claims c ON c.drop_id = d.id
WHERE l.brand_id = $1
GROUP BY l.id, l.name, l.msrp_centsYou can't write this query in DynamoDB. You could build a single-table design with GSIs that approximates some of it, but the query above is exactly what brands need: how much of their MSRP did they recover, across all their lots, sorted by recovery rate. This is a JOIN query. Aurora PostgreSQL runs it natively in milliseconds.
The second firm decision: Aurora PostgreSQL owns relational data and analytics.
The Claim Handler: Dual-Write With Compensation
With two databases, the claim endpoint becomes a coordination problem. The happy path is straightforward:
- DynamoDB conditional decrement (atomic, step 1)
- Aurora transaction: insert claim + invoice (step 2)
- Return success
The failure cases are where it gets interesting.
DynamoDB fails: The
ConditionExpression fired — inventory was exhausted or the drop wasn't live. Return 409 to the buyer. Nothing to undo. Clean.DynamoDB succeeds, Aurora fails: This is the dangerous case. We've decremented inventory (buyer "has" units) but we haven't recorded the transaction. The fix is a compensating write — increment DynamoDB back, return 500 to the buyer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Step 1: atomic DynamoDB decrement
try {
await decrementInventory(dropId, units);
} catch (err: any) {
if (err.name === "ConditionalCheckFailedException") {
return NextResponse.json({ error: "Sold out" }, { status: 409 });
}
return NextResponse.json({ error: "Failed to reserve" }, { status: 500 });
}
// Step 2: Aurora transaction
try {
const { claimId, invoiceId } = await withTransaction((client) =>
recordClaimAndInvoice(client, { dropId, buyerId, unitsClaimed, pricePerUnitCents })
);
return NextResponse.json({ claimId, invoiceId }, { status: 201 });
} catch (err) {
// Compensate: restore DynamoDB units
await compensateInventory(dropId, units);
return NextResponse.json({ error: "Please try again" }, { status: 500 });
}The compensating write can itself fail (network hiccup, etc.). In that case we log the incident for manual reconciliation. For the hackathon, this is an acceptable failure mode — in production you'd add a dead-letter queue or outbox pattern for guaranteed delivery.
Connecting to Vercel via OIDC
The connection story is worth covering because it's one of the cleaner parts of the whole setup.
Vercel's AWS Marketplace integration provisions an IAM role that trusts Vercel's OIDC issuer. When a serverless function runs, Vercel injects a short-lived
VERCEL_OIDC_TOKEN. The @vercel/functions/oidc package exchanges this token for temporary AWS credentials. Those credentials generate a short-lived RDS IAM auth token via @aws-sdk/rds-signer. That token authenticates to Aurora.No stored passwords. No long-lived credentials. No Secrets Manager overhead. The entire chain is:
1
Vercel OIDC token → AWS STS (AssumeRoleWithWebIdentity) → temporary AWS credentials → RDS IAM auth token → Aurora connectionThe environment variables that make this work are auto-populated by the Vercel integration:
1
2
3
4
5
6
7
8
9
10
AWS_ROLE_ARN # IAM role ARN for Aurora
AWS_REGION # us-east-1
PGHOST # Aurora cluster endpoint
PGPORT # 5432
PGUSER # postgres
PGDATABASE # your database name
DYB_AWS_ROLE_ARN # separate IAM role for DynamoDB
DYB_AWS_REGION # us-east-1
DYB_DYNAMODB_TABLE_NAME # table nameThe two different role ARNs are the important detail: Aurora and DynamoDB get separate IAM roles with least-privilege policies. The Aurora role has
rds-db:connect permission. The DynamoDB role has dynamodb:GetItem, dynamodb:UpdateItem, dynamodb:PutItem on the specific table. Nothing else.The Architecture in One Diagram
1
2
3
4
5
6
7
8
9
10
11
12
13
Buyers / Brands
│
▼
Vercel platform
├── Next.js frontend (V0-generated)
├── Claim handler API route ──────────┐
└── Analytics API route │
│ │
① DynamoDB ② Aurora PostgreSQL
drop_inventory brands, lots, drops
(atomic decrement) claims, invoices
drop_activity recovery analytics
(live ticker + TTL)The numbers show the order of operations in a claim: DynamoDB first (atomic lock), Aurora second (relational record). The arrow goes one way — DynamoDB never reads from Aurora, and Aurora never reads from DynamoDB. They're independent stores with a coordination seam in the claim handler.
What We Learned
Three things that weren't obvious before building this:
The database choice should follow the access pattern, not the other way around. We didn't start with "we want to use DynamoDB." We started with "we need an atomic decrement under concurrent load" and arrived at DynamoDB as the tool that solves that specific problem.
The Vercel × AWS integration removes a category of operational overhead. OIDC federation, IAM role provisioning, secret rotation, SSL certificate management — all of it is handled by the integration. For a two-person hackathon team with a three-day deadline, this matters enormously.
Two databases is only as complex as you make the seam between them. The claim handler is the only place in the codebase where both databases appear. Every other route touches exactly one. The dual-database design didn't add complexity to 90% of the code — it just required being deliberate about one specific endpoint.
ClearLot was built for the H0: Hack the Zero Stack hackathon on Devpost #H0Hackathon*. Live demo at* clearlot.vercel.app .
Stack: Next.js 16, TypeScript, Aurora PostgreSQL, DynamoDB, Vercel, @vercel/functions.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article