- Published on
Building Modern Marketplace Platforms End-to-End: Frontend, Backend, Security, Retries, and Cloud Deployment
- Authors

- Name
- Motions Technologies
Building Modern Marketplace Platforms End-to-End
Food delivery and multi-merchant marketplaces look deceptively simple: browse a store, pay, cook, assign a driver, deliver. In production they are distributed systems with money, identity, geo, and real-time state—and every hop can fail.
This article walks through how we build platforms in that class (exemplified by UmaMeats): product surfaces, service boundaries, frontend and backend stacks, security, retry and resilience, event architecture, and how we deploy to the cloud without burning the budget. The patterns are portable to grocery, courier, and multi-sided SaaS marketplaces.
Table of contents
- What you are actually building
- Reference architecture
- Frontend: web, mobile, and headless clients
- Backend: bounded microservices
- Data plane: DynamoDB, Redis, Kafka
- The happy path as a state machine
- Payments as a modular subsystem
- Delivery orchestration and GPS
- Retry mechanisms that earn their keep
- Security: defense in depth
- Deploying to the cloud
- Cost-aware infrastructure defaults
- Observability and ops runbooks
- A professional delivery checklist
- Closing
1. What you are actually building
A marketplace like UmaMeats is not one app. It is a product system:
| Surface | Audience | Typical host |
|---|---|---|
| Marketing / restaurant SaaS dashboard | Merchants, ops | www. + Vercel |
| Customer web app | Diners / shoppers | customer. subdomain |
| Driver web app | Couriers | driver. subdomain |
| Customer + driver mobile | Same roles, native UX | Expo / React Native, TestFlight & Play |
| Public API edge | All clients | api. behind a shared ALB |
| Async backbone | Services only | Kafka + Redis |
Professionally, you treat those surfaces as separate deployables that share APIs, design tokens, and domain language—not one monorepo UI that grows forever.
Capabilities that force architecture
- Multi-tenant stores with menus, hours, and fulfillment modes (kitchen vs proxy/grocery)
- Payments with capture, refunds, connected accounts / payouts
- Order lifecycle that must stay consistent when Kafka or a consumer hiccups
- Driver presence and soft offers with GPS freshness
- Identity for drivers (documents + biometric verification)
- Optional POS transmission with vendor adapters and retries
- Auth across Google OAuth, credentials, email verification, and 2FA
If any of those live “inside the Next.js route handler,” you will eventually rewrite them under load.
2. Reference architecture
System context
Design principles we enforce
- Clients are thin. Matching, fees, and payment confirmation live in APIs.
- One concern per service. Order lifecycle ≠ payment capture ≠ driver marketplace.
- Events for cross-service side effects. HTTP for queries and commands; Kafka for fan-out.
- Idempotency is mandatory on money and assignment paths.
- Cost defaults are intentional. On-demand DynamoDB, shared ALB, Fargate sized to fit, no NAT for demos.
3. Frontend: web, mobile, and headless clients
Web stack
We ship Next.js apps (App Router) with:
- NextAuth for session management (credentials + Google)
- Strong typing against shared API contracts
- i18n (at least English + French for Canadian markets)
- Role-specific UX: customer checkout, driver online toggle, merchant kitchen board
Custom subdomains are not cosmetic. They clarify cookies, OAuth callback URLs, and CORS allow-lists:
- Merchant / SaaS → apex or
www - Customer →
customer. - Driver →
driver. - API →
api.(ALB + TLS)
When domains change, every microservice CORS config and every OAuth redirect URI must move together. Treat domain migration as a security change, not a DNS ticket.
Mobile parity
Mobile (Expo / React Native) should reuse:
- The same API base URL and auth model
- The same status enums (
PENDING_PAYMENT,CREATED,PREPARING,READY_FOR_PICKUP, …) - Maps for in-app navigation on the driver side
- Sentry (or equivalent) for crash and release health
Ship TestFlight / Play with a cookbook: reviewer accounts, help URLs that actually 200, and no hardcoded staging hosts in production builds.
Frontend responsibilities (and non-responsibilities)
| Do on the client | Do not on the client |
|---|---|
| Optimistic UI for cart edits | Final payment capture truth |
Show Active orders from CREATED+ | Hide PENDING_PAYMENT as “active” if product says otherwise |
| Poll / SSE for tracking UX | Implement Haversine matching |
| Collect tip and address | Mint Stripe secrets |
4. Backend: bounded microservices
A practical service map for a UmaMeats-class platform:
| Service | Owns |
|---|---|
| customer-api | Customer identity, profiles, addresses |
| user-api | Merchant users and dashboard auth surfaces |
| store-api | Stores, hours, fulfillment mode |
| menu-item-api | Catalog and checkout line items |
| order-api | Order aggregate, kitchen transitions, payment event consumption |
| payment-api | Stripe intents, webhooks, transactions, payouts hooks |
| driver-api | Driver profile, marketplace accept, earnings views, identity gating |
| events-api | HTTP → Kafka bridge for delivery lifecycle |
| delivery-orchestration-api | Presence, soft offers, nearby queries, tracking |
| pos-integration-api | Vendor adapters, transmit + webhook + retry |
| reviews-api / user-content-api | Ratings and media |
Thin controllers, fat services
Controllers validate auth and shape HTTP. Services own business rules and throw domain errors the API layer maps to status codes. This keeps Spring Boot (or any framework) from becoming a junk drawer of @RestController logic.
Shared libraries worth extracting
- Messaging serializers, error handlers, DLT wiring, outbox helpers
- Auth / JWT validation filters
- Structured logging with redaction
- Trace id propagation (HTTP headers → MDC → Kafka headers)
Centralizing “how we talk to Kafka” prevents twelve slightly wrong retry stories.
5. Data plane: DynamoDB, Redis, Kafka
DynamoDB (system of record)
Use on-demand (PAY_PER_REQUEST) tables for early and mid scale. Typical domain tables:
- customers, users, stores, menu items, orders, transactions
- drivers, reviews, payout methods
- 2FA secrets, verification tokens
- POS config / sync logs
Messaging tables (when outbox + idempotency are enabled):
| Table | Purpose |
|---|---|
| event-outbox | Durable publish queue (PENDING → PUBLISHED) with nextAttemptAt |
| processed-events | Consumer idempotency keys {consumerGroup}#{eventId} + TTL |
Access patterns need GSIs early (for example status indexes for marketplace queries). Design keys around how you query, not how slides look.
Redis (ephemeral presence)
Driver GPS is a hot, loss-tolerant dataset:
GEOADD/GEOSEARCHfor nearby drivers- Short TTL so stale couriers disappear
- Update interval on the order of ~10 seconds while online
Lock Redis behind security groups so only the ECS task SG can reach port 6379. Never expose Redis to the public internet “for convenience.”
Kafka (async backbone)
Critical topic families:
payment.events— e.g.PAYMENT_SUCCESSorder.events— e.g.ORDER_PAIDdelivery.events/status/eta- Soft-offer / assignment topics
- Matching
*.DLTsiblings for every consumed topic
Partitions (commonly 3 at early scale) give parallel consumers without over-sharding.
6. The happy path as a state machine
Professional platforms document the canonical flow and refuse to invent ad-hoc status strings in each app.
Customer pay → kitchen → driver → deliver
Product rules that prevent support tickets
PENDING_PAYMENTis not Active for the customer “Active orders” tab—by design.- Kitchen modes wait for
READY_FOR_PICKUP+UNASSIGNEDbefore marketplace visibility. - Proxy / grocery modes may dispatch at
CREATED(no kitchen step). - Soft-offer windows and radius filters can briefly hide offers; ops UIs must show why.
When Stripe shows COMPLETED but the order stuck in PENDING_PAYMENT, you do not “fix the UI”—you replay PAYMENT_SUCCESS (or drain the outbox) so the consumer runs the full side-effect path.
7. Payments as a modular subsystem
Payment code is where vendor lock-in and security bugs concentrate. We structure it as:
PaymentApiController
↓
PaymentFacadeService (transactions, refunds, webhooks, Kafka)
↓
PaymentProcessorFactory
↓
Stripe | PayPal | Square | Adyen adapters
↓
PaymentProcessor / PayoutProcessor / WebhookHandler interfaces
Why the facade + adapters matter
- Controllers stay stable while processors evolve
- Tests inject mocks without hitting Stripe
- Multi-country or multi-acquirer strategies become config, not forks
- Webhook signature verification stays next to the adapter that understands it
Hard rules
- Never trust the client for payment success—webhooks (or server-side confirm) are source of truth.
- Persist a transaction record before/with event publish.
- Prefer outbox when enabling durable messaging so Dynamo commit and Kafka produce cannot diverge silently.
- Store Stripe secrets in Secrets Manager, not env files in git or mobile binaries.
8. Delivery orchestration and GPS
Matching is a state machine with a map, not a CRUD list.
Presence
- Drivers ping location while online
- Redis GEO for radius queries
- Durable driver attributes (docs, region, rating) stay in DynamoDB
Soft offers
- Query nearby eligible drivers
- Score (distance + simple business weights)
- Offer with timeout
- On decline/timeout → next candidate
- On accept → emit assignment; mark order assigned
Separation of concerns
| Layer | Responsibility |
|---|---|
| order-api | Lifecycle truth |
| driver-api | Accept UX + driver records |
| delivery-orchestration | Presence + offer algorithm |
| events-api | HTTP gateway into Kafka topics |
Clients never embed matching loops. That keeps App Store builds and web apps from disagreeing on radius.
9. Retry mechanisms that earn their keep
Retries without taxonomy create duplicate charges, duplicate offers, and silent loss. Professionals classify failures first.
Failure classes
| Class | Example | Strategy |
|---|---|---|
| Transient | 429, 503, network blip | Exponential backoff + jitter |
| Poison | Invalid JSON, schema break | No retry → DLT immediately |
| Business rejection | Card declined, ineligible driver | No retry; user-visible outcome |
| Dual-write gap | DB committed, Kafka produce failed | Outbox + publisher retries |
| Idempotent replay | Same PAYMENT_SUCCESS redelivered | Processed-events + status guards |
HTTP / SDK retries
attempt 1 → wait 200ms
attempt 2 → wait 400ms
attempt 3 → wait 800ms
attempt 4 → wait 1600ms (+ jitter)
give up → dead letter / alert / manual replay
Apply retries to idempotent or safely-replayable calls. For non-idempotent POSTs, use idempotency keys (Stripe-style) or server-side dedupe.
Kafka consumer stop-loss
- Do not catch-and-swallow in listeners while auto-commit is on.
- Use a
DefaultErrorHandler(or equivalent) with backoff. - Route exhausted failures to
.DLT. - Prefer manual ack with clear success boundaries.
- Enforce idempotency:
eventId+ durable processed record + status machine guards.
Outbox publisher
Operational footgun: enabling outbox-enabled=true while the publisher is not draining leaves payments completed and orders forever PENDING_PAYMENT. Gate the flag on verified tables + a live publisher.
POS transmission retries
Vendor APIs fail. A POS integration service should:
- Transform order → vendor payload via adapters
- Transmit with exponential backoff
- Log sync attempts (
pos-sync-log) - Emit
transmitted/transmission.failedevents - Allow ops to replay a single order without re-charging the customer
Client-side retries
Mobile and web should retry reads aggressively and writes carefully:
- Idempotent GETs: retry with backoff
- Place order / accept delivery: disable double-submit, show in-flight state, reconcile from server
- Location sync: drop-and-replace latest point (last write wins) rather than queueing stale GPS
10. Security: defense in depth
Security is not a final sprint checklist. It is part of every boundary above.
Identity and access
- NextAuth sessions on web; secure token handling on mobile
- Google OAuth with correct callback URLs per subdomain
- Email verification for credential accounts
- TOTP 2FA (and backup codes) for elevated roles (merchants, drivers)
- Backend endpoints for Google login, verify-email, enable/verify/disable 2FA per audience
Driver identity verification
Before a driver can go online:
- Create Stripe Identity session (license + selfie)
- Drive UX via Stripe.js / mobile SDK
- Process webhooks →
NOT_STARTED | PENDING | VERIFIED | FAILED | REQUIRES_INPUT - Gate
AVAILABLEstatus onVERIFIED
Secrets for Stripe stay in AWS Secrets Manager; ECS task roles fetch them—apps never embed restricted keys.
Network and CORS
- Explicit origin allow-lists per environment (localhost + production subdomains)
allowCredentialsonly when cookies/sessions require it- ALB terminates TLS; prefer HTTPS everywhere
- Redis and Kafka on private IPs; SG rules limited to ECS
Webhook security
- Verify Stripe signatures
- Reject unsigned or skewed timestamps
- Treat webhooks as untrusted input even though they are “from Stripe”
Data protection
- Encrypt sensitive fields where needed (e.g. backup codes)
- Short CloudWatch retention in non-prod; redact PII in structured logs
- Separate demo/reviewer accounts from real PII in shared environments
- S3 buckets for logos/media with least-privilege IAM on task roles
Application hardening
- Validate all status transitions server-side
- Authorize every order/driver/store access by subject, not by “I know the UUID”
- Rate-limit auth and payment session creation
- Keep dependency and container image updates in CI
Threat-focused thinking for marketplaces
| Threat | Mitigation |
|---|---|
| Replay payment success | Idempotent consumers + transaction status |
| Stolen driver account | 2FA + Stripe Identity gate |
| CORS misconfig after domain move | Shared checklist + integration test of preflight |
| Kafka silent loss | No swallow; DLT; lag alerts |
| Secret leak in mobile | Public keys only; restricted keys server-side |
11. Deploying to the cloud
Split deploy model
| Tier | Platform | Why |
|---|---|---|
| Web frontends | Vercel | CDN, previews, custom domains, fast iteration |
| Mobile | Expo EAS / Fastlane + store pipelines | Reviewer builds, channel separation |
| APIs | AWS ECS Fargate | Stable JVM services, shared ALB path routing |
| Data | DynamoDB + Redis + Kafka | Managed scale for DB; right-sized brokers for events |
ECS shape
- Cluster per product (e.g.
umameats-api) - One service per microservice
- Target groups with
/actuator/health(or equivalent) - ALB path rules, for example:
/api/v1/delivery/*→ delivery-orchestration/api/v1/pos/*→ pos-integration- other
/api/v1/...prefixes → owning services
CI/CD loop
Prefer building on CI (correct JDK, reproducible images) over “works on my laptop” Docker from mismatched local JDKs.
Environment discipline
- Separate env vars for Vercel projects (
NEXTAUTH_URL, API base URLs, OAuth client IDs) - ECS task definitions inject Kafka bootstrap, Redis host, table names, feature flags (
outbox-enabled) - Never promote a frontend pointing at the wrong API host
Domain cutover checklist
- Add Vercel domains; confirm HTTP 200 + TLS
- Update
NEXTAUTH_URLand OAuth console redirect URIs - Update Spring CORS allow-lists across all services
- Redeploy APIs; smoke preflight from each subdomain
- Update docs and mobile config
12. Cost-aware infrastructure defaults
Marketplace demos often overspend on always-on capacity. Defaults that still look professional:
| Choice | Rationale |
|---|---|
| DynamoDB on-demand | Traffic is spiky; provisioned “for later” wastes money |
| One shared ALB | Many target groups beat many idle balancers |
| Fargate right-sized tasks | Many services fit 0.25 vCPU / 0.5 GB; grow with evidence |
| Desired count 1 for early sandboxes | Scale critical paths (order, driver, events, delivery) first |
| Skip NAT for public Fargate demos | NAT is a classic silent bill |
| Short log retention non-prod | CloudWatch adds up across 10+ services |
| Stay on ECS until EKS is justified | Control plane + ops cost rarely win under mid scale |
At a few thousand orders per day, compute is rarely the bottleneck—Kafka memory, Redis HA, and missing auto-scaling usually bite first. Fix those before rewriting the platform on Kubernetes.
Nightly scale-to-zero can be valid for demos; turn it off when revenue needs 24/7 dispatch.
13. Observability and ops runbooks
What to measure
- Payment → order transition latency (
COMPLETED→CREATED) - Consumer lag and DLT depth
- Soft-offer accept rate and time-to-first-offer
- GEO query latency
- ECS CPU/memory on order/driver/payment
- Mobile crash-free sessions
Tracing
Propagate a trace id across:
- Browser / mobile request headers
- API logs (MDC)
- Kafka headers
- Downstream consumers
Without this, “payment worked but kitchen never lit up” becomes archaeology.
Runbook snippets worth owning
- Replay
PAYMENT_SUCCESSfor stuck paid orders - Inspect outbox
PENDINGrows and publisher health - Drain / replay DLT after poison fix
- Verify Redis GEO membership for an online driver
- Confirm ECS
runningCount == desiredCountfor the critical set
Smoke checklist before calling a release “done”
- Core ECS services healthy
- Messaging tables active if outbox/idempotency enabled
- Critical topics + DLTs exist
- New pay: transaction
COMPLETEDand orderCREATEDwithin seconds - Customer Active shows the order
- Merchant can advance to
READY_FOR_PICKUP - Driver marketplace includes the order when filters allow
14. A professional delivery checklist
Use this as a program-level scoreboard—not a motivational poster.
Architecture
- Service boundaries documented with owners
- Sequence diagram for pay → cook → assign → deliver
- Status enums shared across web, mobile, and APIs
Resilience
- Kafka retries + DLT on every money/assignment consumer
- Idempotency store for critical consumers
- Outbox strategy decided and verified (or explicitly off)
- POS and Stripe webhook paths have replay stories
Security
- OAuth callbacks and CORS match production domains
- Secrets in Secrets Manager; IAM least privilege
- Driver identity gate before online
- 2FA for elevated roles
- Redis/Kafka not public
Delivery
- CI builds images; ECS rolling deploys
- Vercel envs correct per app
- Health checks and ALB rules verified
- Cost defaults recorded in the PR / runbook
Product proof
- E2E path with reviewer accounts
- Ops can answer “why is this order stuck?” from timeline + metrics
15. Closing
Building a platform like UmaMeats is less about picking fashionable frameworks and more about honoring boundaries under failure:
- Frontends that sell the experience without owning money or matching
- Microservices that each protect one invariant
- DynamoDB for durable truth, Redis for presence, Kafka for fan-out
- Retries that know the difference between transient, poison, and business outcomes
- Security woven into auth, webhooks, network, and identity gates
- Cloud deploy that is boring: Vercel for UI, ECS + shared ALB for APIs, cost-aware data plane
Do that well and “professional” stops being a vibe. It becomes a system you can operate at dinner rush—when retries, DLTs, and clear state machines matter more than any slide deck.
If you are designing a similar marketplace or modernizing a monolith into this shape, start with the payment → order → dispatch spine, make it idempotent and observable, then grow POS, reviews, and mobile polish on a foundation that already survives redelivery.
Written for Motions Technologies — sharing the engineering patterns we use when shipping real multi-sided platforms.