A marketplace for freelance services where the same platform serves two opposite intentions: someone selling a skill and someone buying one. Both sides browse the same listings, exchange messages on the same threads, and read the same ratings — but almost never want to see them the same way.
The problem & constraints
A two-sided marketplace's hard part isn't the CRUD, it's that buyer and seller need different answers from identical data, and that money is involved. Two constraints followed from that: the role a request acts under can never come from the client, and neither can the amount charged. A third came from the listing pages — a grid of service cards showing star ratings can't afford to aggregate a reviews collection on every render.
Approach
- One
Usermodel carrying anisSellerflag, embedded in the JWT payload at login.getOrdersthen filters on the role read from the token — one endpoint, two correct answers, and no client-supplied role parameter to forge. - The JWT lives in an httpOnly cookie rather than
localStorage, so browser-side script can't read it. - Stripe
PaymentIntentcreated server-side, with the amount read from the stored listing record — the browser receives only theclientSecretand never states a price. - Ratings denormalized onto the listing as a running
totalStars+starNumberpair, so a card renders its average from two integers it already has. - Conversations and messages modeled as separate collections, keeping the thread list cheap to load independently of the messages inside it.
Described in full:
- client (React) — httpOnly JWT cookie → Express API — role read from JWT — amount from listing
- Express API — role read from JWT — amount from listing → MongoDB — listings · orders · — reviews · messages
- Express API — role read from JWT — amount from listing → Stripe — PaymentIntent
Trade-offs made
Decision
A denormalized rating on the listing over aggregating reviews at read time: two integers on the document a card already fetches, instead of a join per card on a grid — accepting that the pair must be updated transactionally with each new review, and that a deleted review needs explicit correction rather than simply disappearing from a recount.
Decision
Role carried in the token rather than re-read from the database per request: removes a lookup from every authenticated call, at the cost of the role being fixed for the token's lifetime — a seller who changes status stays on the old view until the token is reissued.
The first of those two trade-offs carries its cost inside a subordinate clause: a deleted review has to be corrected for explicitly. Here is that pair, live — add reviews and it stays honest, delete one and watch it stop:
The pair agrees with the collection — every insert updated both at once.
Documents read to render that grid: 24 with the stored pair · 312 recounting per card
Demonstration of the data model, computed live in your browser — the same two-integer update the marketplace performs. Not real listings, not real reviews, and not measured query times.
Results
A working marketplace loop: publish a listing, message about it, pay through Stripe, and leave a review — with the authorization and pricing decisions made server-side rather than trusted to the client.
What I'd do differently
Move order creation to a Stripe webhook. As it stands the order row is
written when the PaymentIntent is created, before payment confirms, so
an abandoned checkout leaves a persisted order that only the
isCompleted filter hides — the data is honest but the collection isn't
clean, and the payment provider's own callback is what should be
authoritative. I'd also lift the hardcoded usd currency into
configuration.