Paul S.

Cart-Checkout Platform

A backend checkout orchestration service across cart, catalog, promotions, payments, and downstream order workflows. It became the layer that let Peloton migrate off a legacy monolith one funnel at a time without risking revenue.

Context

Peloton's commerce platform spanned multiple checkout channels, a commerce backbone in Commercetools, several payment partners, and a long tail of downstream order workflows. Each surface integrated with these systems directly, which made changes risky and latency unpredictable. It runs ~4K orders/week (~200K+/yr) at ~120 order placements/min at peak.

Problem

Frontend and partner teams needed a single, stable contract for cart and checkout. Add-to-cart was ~5s in the worst case, end-to-end checkout ~10–15s, and there was no clean place to coordinate promotions, payments, and order workflows without touching multiple services. Checkout was also the most revenue-critical path in the company, which made it the hardest thing to change safely.

What I did

  • Designed and launched the core cart-checkout service as a backend orchestration layer over Commercetools, catalog, promotions, payments, tax, and downstream order workflows, using hexagonal / ports-and-adapters so each channel, payment provider, and downstream system plugged in without coupling.
  • Exposed REST, GraphQL, and gRPC adapters as one stable contract for web, mobile, and service-to-service checkout surfaces.
  • Built a transactional place-order flow with idempotency keys, retry/backoff, gateway "unknown-outcome" handling, and durable order-sync with DLQ/replay.
  • Drove a strangler rollout behind a feature-flag / A-B router, with live guardrails (conversion, payment success, p95 latency, error budget) and a kill switch to force traffic back to legacy, starting with the warranties funnel and low-traffic countries.
  • Coordinated integration work with Stripe, Affirm, Avatax, and Commercetools partner engineering, plus design, localization, and legal.
Cart-checkout hexagonal ports-and-adapters architecture
Hexagonal ports-and-adapters: the domain core stays stable while vendors and channels plug in through adapters.
Cart-checkout strangler rollout with feature-flag router, guardrails, and kill switch
Strangler rollout: each funnel migrates behind a flag with live guardrails and an instant kill switch.

The decision

The legacy monolith owned everything checkout touched: CMS pages, a custom ORM and domain model, orchestration, payments, promotions, downstream sync. Circular dependencies among them made ordinary changes risky. Rebuilding it was the first option we considered, and it was a multi-year bet that would have paid out only at the end, with the business holding still while engineering caught up.

The second option was to buy the commerce backbone and build only the checkout layer on top of it: an API-first composable platform behind a cart-checkout service acting as the single stable entrypoint for every funnel. I owned the technical evaluation comparing Commercetools, BigCommerce, Shopify, and Magento, and argued for Commercetools on Kotlin/JVM fit, data-model flexibility, and partnership terms. It cleared the architecture board on the strength of the migration path and day-two operations.

With one stable entrypoint in front of both systems, funnels could move across one at a time instead of the org committing revenue to a single cutover.

Service boundaries

The service owns orchestration and validation, cart and session state in Redis, cart/order operations against Commercetools, the calls to payments for tax and authorize/capture, and durable order-sync downstream. Stripe still owns payment processing, the order platform remains the system of record, and Commercetools remains the catalog source of truth. Holding that boundary was most of the value.

Everything external enters through a port, which is what let web, mobile, and service-to-service surfaces share one contract across REST, GraphQL, and gRPC adapters while the domain core stayed still.

interface PaymentPort {
    suspend fun quoteTax(cart: Cart, address: Address): TaxQuote
    suspend fun createIntent(cart: Cart, key: IdempotencyKey): PaymentIntent
    suspend fun capture(intent: PaymentIntentId, key: IdempotencyKey): CaptureResult
}

interface CartStorePort {
    suspend fun load(id: CartId): Cart?
    suspend fun save(cart: Cart): Cart
}

// Durable, DLQ-backed: the order is not lost if a downstream is down.
interface OrderSyncPort {
    suspend fun publish(order: Order): SyncHandle
}
The domain core depends only on ports; each vendor and channel is an adapter behind one. Swapping a payment provider never reaches checkout logic.

Country, currency, and sales channel were modeled explicitly in the same pass, where the legacy system had inferred them ad hoc. That modeling decision unblocked multi-country and multi-currency growth without another rewrite, and let promotions scale to a per-country model.

Place order, and the double-charge problem

Placing an order spans systems that can each fail independently, which makes it the flow where a naive retry costs a customer real money.

  1. Address and cart compute taxes and create a payment intent.
  2. Commit authorizes and captures against that intent, idempotently.
  3. The order is confirmed and handed to durable sync for downstream workflows.

A gateway timeout is the case the design is built around. The charge may have succeeded with the response simply lost, so the caller cannot tell a timeout from a decline. Retrying without carrying the original idempotency key is how a customer gets billed twice. Every step carries a key, retries use backoff, and an unknown outcome is its own terminal state that has to be reconciled against the gateway.

suspend fun capture(
    intent: PaymentIntentId,
    key: IdempotencyKey,
): CaptureResult = when (val result = payments.capture(intent, key)) {
    is Captured -> result
    is Declined -> result                  // terminal: surface it to the customer
    is Unknown  -> reconcile(intent, key)  // never blind-retry a maybe-charge
}
Blind-retrying an unknown capture is the bug; reconciling it against the gateway is the fix.

Order-sync is durable, with a DLQ and replay, so a downstream system being down delays an order instead of losing it.

Rollout

Every funnel moved behind a feature-flag / A-B router with a kill switch that forces traffic back to legacy. The router was watched live on four signals: conversion, payment success, p95 latency, and error budget.

We started with the warranties funnel and low-traffic countries, the narrowest surface that still exercised the full path end to end.

The router is also why incident mitigation dropped from hours to minutes. Under the legacy coupling a correctness bug meant a code fix under pressure, and a duplicated price sitting in undocumented legacy code was one such case. With a router in front, the first move is to route traffic away and diagnose afterwards. We later added proactive alerts for invalid products entering the cart, so that class of problem reports itself before a customer does.

Result

  • Add-to-cart ~5s → ~400ms and end-to-end checkout ~10–15s → under 2s, from collapsing per-surface integrations into one orchestration layer.
  • One stable checkout contract for every funnel and market, because the REST / GraphQL / gRPC adapters all sit in front of the same domain core.
  • Multi-country and multi-currency growth unblocked without a rewrite, by modeling country, currency, and sales channel explicitly.
  • Incident mitigation cut from hours to minutes, because the flag router turns 'route back to legacy' into a config change instead of a deploy.
  • Sustained ~4K orders/week (~200K+/yr) at ~120 order placements/min peak.

Tech

  • Kotlin
  • Spring WebFlux
  • hexagonal / ports-and-adapters
  • GraphQL
  • Netflix DGS
  • Commercetools
  • Redis
  • SQS / Step Functions
  • AWS
  • EKS
  • Terraform
  • Argo CD
  • Datadog
  • OpenTelemetry