Cart-Checkout Platform
One checkout service in place of 6-8 planned per-funnel integrations, so checkout could move off a legacy monolith one funnel at a time, each step reversible.
- Where
- Peloton, commerce platform
- Role
- Led up to six engineers; coordinated four partner teams
- Scope
- Cart, catalog, promotions, payments, tax and downstream order workflows
- Stack
- Kotlin, Spring WebFlux, GraphQL (Netflix DGS), Commercetools, Redis, AWS
Results
~5 s → ~400 ms
Add-to-cart latency
Per-surface integrations collapsed into one orchestration layer.
~10–15 s → under 2 s
Warranty checkout
One orchestration call path in place of per-surface legacy integrations.
~30 s → ~5 s
Full-featured web checkout
~4K orders/week
(~200K+/yr), ~120 order placements/min at peak
Handled behind one stable contract for every funnel and market.
The decision, and the shape it gave the system
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.
How funnels moved
Every funnel moved behind a feature-flag / A-B router with a kill switch that forces traffic back to legacy in minutes. The router was designed to be watched 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 guardrails each funnel was designed to be watched on:
- Conversion
- Payment success
- p95 latency
- Error budget
When place order fails
Placing an order spans systems that fail independently, and it is where a naive retry costs a customer real money. Pick a failure to see where the run stops and who recovers.
- Funnel flagRouter, new service or legacy, per funnel
- Step 1: Recalculate the cart409Checkout, with its version
- Step 2: Validate cart and discount codes (not reached)Checkout
- Step 3: Pay, one call for all tenders (not reached)payments-service, the only step that moves money
- Step 4: Record the charge reference (not reached)Commercetools, ids only, never card data
- Step 5: Create the order (not reached)Commercetools
- Step 6: Copy the order to legacy (not reached)Step Functions, asynchronous, owned by another team
Stale cart
- Fails at
- Step 1: Recalculate the cart
- Checkout
- Recalculates the cart against its version, finds it changed, and stops with a 409 before any payment.
- Customer
- Checkout stops before payment. Nothing is charged.
- Recovery
- Nothing to reconcile: no money moved.
Stale cart
- Fails at
- Step 1: Recalculate the cart
- Checkout
- Recalculates the cart against its version, finds it changed, and stops with a 409 before any payment.
- Customer
- Checkout stops before payment. Nothing is charged.
- Recovery
- Nothing to reconcile: no money moved.
Declined
- Fails at
- Step 3: Pay, one call for all tenders
- Checkout
- Treats the decline as terminal and creates no order.
- Customer
- Sees the decline.
- Recovery
- Nothing to reconcile: the payment didn't go through.
Gateway timeout
- Fails at
- Step 3: Pay, one call for all tenders
- Checkout
- The charge may have succeeded with the response lost, so the result is a typed Unknown outcome, and the charge is never retried blind.
- Customer
- Is not billed twice by a blind retry.
- Recovery
- payments-service. Reconciliation belongs there, not in checkout.
Charged, no order
- Fails at
- Step 5: Create the order
- Checkout
- The charge succeeded and its reference is recorded, but the order wasn't created, so checkout returns a typed error instead of a silent failure.
- Customer
- Has been charged but has no order until the charge is reconciled.
- Recovery
- payments-service reconciles it by charge id.
Legacy system down
- Fails at
- Step 6: Copy the order to legacy
- Checkout
- Nothing: the order already exists. Its OrderCreated event starts a Step Functions run, so the legacy copy waits instead of being lost.
- Customer
- The order is placed.
- Recovery
- The team that owns the legacy copy. The copy is delayed, not lost.
Funnel regression
- Fails at
- The funnel flag, before checkout
- Checkout
- A guardrail regresses for a migrated funnel. The kill switch routes that funnel back to legacy: a config change, not a deploy.
- Customer
- Checks out on the legacy path while the regression is diagnosed.
- Recovery
- The team running the rollout: route away first, diagnose afterwards. Rollback takes minutes.
Simplified reconstruction of the place-order flow. It leaves out transport and infrastructure detail.
What I did
- Led the build of the 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 and GraphQL (Netflix DGS) adapters over one domain core, as one stable contract for every checkout surface.
- 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 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.
Detail
Context and problem
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.
Frontend and partner teams needed a single, stable contract for cart and checkout. Add-to-cart was ~5s; 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.
How the platform was chosen
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. As part of the architecture evaluation group, I drove the technical evaluation comparing Commercetools, BigCommerce, Shopify, and Magento, and argued for Commercetools on Kotlin/JVM fit, data-model flexibility, and partnership terms.
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 every checkout surface share one contract across the REST and GraphQL (Netflix DGS) 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
}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, step by step, with code
Placing an order spans systems that can each fail independently, which makes it the flow where a naive retry costs a customer real money.
- Recalculate the cart with its version; a stale cart fails with a 409 before any payment.
- Validate the cart and discount codes.
- Make one call to payments-service for all tenders: the only step that moves money (cards are captured; financing is approved by the provider).
- Record the provider's charge reference on the Commercetools payment: ids only, never card data.
- Convert the cart into the order in Commercetools.
- 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
}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, and what an incident looked like after it
The router also changed what an incident looked like. 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.
Tech: Kotlin, Spring WebFlux, hexagonal / ports-and-adapters, GraphQL, Netflix DGS, Commercetools, Redis, EventBridge / Step Functions, AWS, EKS, Terraform, Argo CD, Datadog, OpenTelemetry