Paul Shynin

Platform architecture

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.

Cart-checkout service boundary, as ports and adaptersEvery checkout surface calls the service through one of two inbound adapters, REST or GraphQL on Netflix DGS. Both sit over one domain core that handles orchestration, validation and typed payment outcomes. Through ports, the core calls Commercetools for cart and order operations, payments-service for tax and payment, and Redis for cart and session state. When Commercetools creates an order, its OrderCreated event starts an asynchronous legacy copy through EventBridge and Step Functions, owned by another team.Every checkout surfaceCart-checkoutserviceRESTGraphQLNetflix DGSDomain coreorchestration, validation,typed payment outcomesCommercetoolscart and orderpayments-servicetax and paymentRediscart and sessionOrderCreated, asynchronousLegacy copy of each orderEventBridge → Step Functions, owned by another team
Simplified reconstruction. Two inbound adapters over one domain core; every outside system sits behind a port. The dashed line is what the service owned: orchestration, not payment processing, the order system of record or the catalog.

How funnels moved

Cart-checkout rollout: a flag per funnel and a kill switchEvery checkout funnel reach feature-flag / a-b router. Behind it, each funnel has its own flag, drawn as a switch: thrown one way it sends that funnel to cart-checkout, the other way it leaves it on legacy checkout. A kill switch linked to every flag throws every funnel back to legacy in minutes.Every checkout funnelFeature-flag / A-B routerA flag per funnelKill switchevery funnel back tolegacy, in minutesCart-checkouton CommercetoolsLegacy checkoutthe monolith
Simplified reconstruction of the design, drawn with this platform's names. Warranties and low-traffic countries moved first.

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.

Choose a failure
  1. Funnel flagRouter, new service or legacy, per funnel
  2. Step 1: Recalculate the cart409Checkout, with its version
  3. Step 2: Validate cart and discount codes (not reached)Checkout
  4. Step 3: Pay, one call for all tenders (not reached)payments-service, the only step that moves money
  5. Step 4: Record the charge reference (not reached)Commercetools, ids only, never card data
  6. Step 5: Create the order (not reached)Commercetools
  7. 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
}
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, 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.

  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, 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

Next case study
Warranty Checkout Migration