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 handled ~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; warranty checkout was ~10–15s, the full-featured web checkout ~30s, 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 the place-order flow so a payment timeout surfaces as a typed "unknown outcome" that is never retried blind; the legacy copy of each order runs asynchronously (EventBridge → Step Functions) and is owned by another team.
  • 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 across Stripe, Affirm, Avatax, and Commercetools plus 4 internal partner teams, design, localization, and legal.

Result

  • Add-to-cart ~5s → ~400ms, from collapsing per-surface integrations into one orchestration layer.
  • Warranty checkout ~10–15s → under 2s; full-featured web checkout ~30s → ~5s.
  • 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.

Technical detail

Architecture diagram
Cart-checkout hexagonal ports-and-adapters architecture
Simplified reconstruction. Hexagonal ports-and-adapters: the domain core stays stable while vendors and channels plug in through adapters.
Rollout diagram
Cart-checkout strangler rollout with feature-flag router, guardrails, and kill switch
Simplified reconstruction. 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.

The tradeoff. A rebuild gives total control and no incremental value until it lands. A composable platform gives value immediately and a permanent dependency on a vendor's roadmap. We took the second and limited the dependency with hexagonal architecture: the vendor sits behind a port, so replacing it is an adapter change.

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, and the call to payments-service for tax and payment. Once Commercetools creates the order, its OrderCreated event drives an asynchronous legacy copy that another team owns. Stripe still owns payment processing, and Commercetools is the order system of record as well as 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
    // One call for all tenders: the only step that moves money.
    suspend fun pay(cart: Cart, tenders: List<Tender>): PaymentResult
}

interface CartStorePort {
    suspend fun load(id: CartId): Cart?
    suspend fun save(cart: Cart): Cart
}
Simplified reconstruction, not production code. 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 payment timeouts

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. Recalculate the cart with its version; a stale cart fails with a 409 before any payment.
  2. Validate the cart and discount codes.
  3. Make one call to payments-service for all tenders: the only step that moves money (cards are captured; financing is approved by the provider).
  4. Record the provider's charge reference on the Commercetools payment: ids only, never card data.
  5. Convert the cart into the order in Commercetools.
  6. OrderCreated triggers the asynchronous legacy copy, at-least-once and deduplicated by order id.

A gateway timeout is the hardest case. The charge may have succeeded with the response simply lost, so the caller cannot tell a timeout from a decline, and a blind retry is how a customer gets billed twice. So a payment timeout comes back as a typed error with an unknown outcome, and checkout never retries the charge blind. A charge that succeeds without an order being created is also a typed error, and payments-service reconciles it by charge id.

suspend fun pay(cart: Cart, tenders: List<Tender>): PaymentResult =
    when (val result = payments.pay(cart, tenders)) {
        is Paid     -> result  // record the charge id, then create the order
        is Declined -> result  // terminal: surface it to the customer
        is Unknown  -> throw PaymentOutcomeUnknown(cart.id)  // typed; never retried blind
    }
Simplified reconstruction, not production code. A timeout is a typed unknown outcome that is never retried blind; reconciliation belongs to payments-service, not checkout.

Order-sync is durable: Commercetools publishes OrderCreated via EventBridge to a Step Functions run per order, so a downstream system being down delays the legacy copy 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.

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

Next: Warranty Checkout Migration