Skip to Content
Deep DiveCommerce Core: Catalog, Cart, Checkout, and Payments

Commerce Core: Catalog, Cart, Checkout, and Payments

This is the base loop every storefront built on Mercatus Liber has to get right before anything else matters: a shopper browses products, adds SKUs to a cart, checks out, and pays. Four subsystems make that loop work — catalog, cart, checkout & orders, and payments — and each one is deliberately narrow, talking to its neighbors through typed interfaces and events rather than reaching into each other’s internals.

Products and SKUs: what exists vs. what a shopper actually buys

The catalog subsystem draws a line that a lot of commerce platforms blur: a Product is the sellable idea (“Embroidered Canvas Tote”), and a SKU is one concrete, purchasable combination of that product’s identifying attributes (color, size — the attributes that fork into a distinct SKU) as opposed to its merely descriptive attributes (material, care instructions — full attribute-map data that doesn’t fork anything). A product declares which attribute keys are identifying; the catalog service can then generate the full cartesian product of SKUs for you, or resolve a shopper’s variant picker selection straight back to the one SKU it corresponds to:

// packages/catalog/src/service.ts export interface CatalogService { createProduct(input: NewProductInput): Promise<Product>; /** draft -> active. See docs/subsystems/01-catalog.md open question 1 -- no separate "active but hidden" state yet. */ publishProduct(id: string): Promise<Product>; archiveProduct(id: string): Promise<void>; createSku(input: NewSkuInput): Promise<Sku>; generateSkus( productId: string, valuesByKey: Record<string, AttributeValue[]>, price: Money, exclude?: IdentifyingAttribute[][], ): Promise<Sku[]>; resolveVariant(productId: string, selection: IdentifyingAttribute[]): Promise<Sku | null>; }

generateSkus takes every possible value for every identifying attribute key, builds the cartesian product, and lets you exclude combinations that don’t actually exist for this product (no size-14 in the discontinued colorway, say). resolveVariant is what a PDP’s variant picker calls once a shopper has clicked through color and size — it’s a read, not a re-derivation, so the picker UI never has to reimplement the matching logic itself.

Every mutation publishes an event (catalog.product.created, catalog.sku.created, and so on) instead of calling into search or inventory directly — catalog has no idea those subsystems exist. That’s not incidental decoupling; it’s how a low-SKU shop and a search-indexed 10,000-SKU shop can both run on the exact same catalog package.

Cart: the reason this project exists

The cart subsystem’s own doc is blunt about its origin: Stripe Payment Links — a fine tool for a single-product checkout link — can’t hold a real, long-lived, multi-item cart. That gap is what justified building a commerce framework at all instead of just wiring up hosted payment links. A cart here is genuinely long-lived: it persists across a session for a guest and across devices for a signed-in shopper, and every line item snapshots its price at add-time rather than trusting a live catalog lookup at checkout:

// packages/cart/src/service.ts async addItem(cartId, skuId, quantity, customizationNote) { if (quantity <= 0) { throw new RangeError(`quantity must be > 0, got ${quantity}`); } const cart = await requireCart(cartId); const sku = await skus.getSku(skuId); if (!sku || sku.status !== "active") { throw new SkuNotAvailableError(skuId); } const note = customizationNote?.trim() || undefined; const existing = cart.items.find((item) => item.skuId === skuId && item.customizationNote === note); if (existing) { existing.quantity += quantity; } else { cart.items.push({ skuId, quantity, priceSnapshot: sku.price, ...(note ? { customizationNote: note } : {}) }); } await repository.save(cart); await events.publish("cart.item.added", { cartId, skuId, quantity }); return cart; }

Two things worth noticing in that snippet. First, two lines of the same SKU with different personalization text (customizationNote) stay separate lines rather than silently merging — useful the moment a shop sells anything monogrammed or engraved. Second, cart never imports checkout or payments at all; it only knows how to hold and mutate line items. Checkout is the one that reaches into cart, not the other way around, which is what lets cart stay useful in contexts that never reach a payment page — a “save for later” list, or a future B2B quote-request flow.

Checkout: turning a cart into an order, idempotently

checkout-orders is the busiest subsystem in the whole framework in terms of cross-subsystem interaction, and its own doc calls that out as the best real test of the decoupling rule. It reads cart (read-only), calls the payments interface to create a session, and reacts to payments.payment.succeeded — an event, not a direct callback — to flip an order from pending_payment to paid:

// packages/checkout-orders/src/service.ts async startCheckout(input: StartCheckoutInput): Promise<CheckoutResult> { // Idempotency: a retried checkout with the same key returns the existing // order + redirect rather than creating a duplicate order or a second // Stripe session (no double-charge). const existing = await repository.getByIdempotencyKey(input.idempotencyKey); if (existing && existing.paymentRedirectUrl) { return { order: existing, redirectUrl: existing.paymentRedirectUrl }; } const shopperCart = await cart.getCart(input.cartId); if (!shopperCart) throw new CartNotFoundForCheckoutError(input.cartId); if (shopperCart.items.length === 0) throw new EmptyCartError(input.cartId); const adjustment = await computeAdjustment(shopperCart.items, input.couponCode); // ... order created as "pending_payment", then: const session = await payments.createPaymentSession({ orderRef: orderId, lineItems: shopperCart.items.map((item) => ({ name: `SKU ${item.skuId}`, unitAmount: unitAmountBySku.get(item.skuId) ?? item.priceSnapshot, quantity: item.quantity, })), successUrl: input.successUrl, cancelUrl: input.cancelUrl, }); return { order: withSession, redirectUrl: session.redirectUrl }; }

That idempotencyKey check matters more than it might look: a shopper who double-clicks “place order,” or whose network hiccups and retries the same request, gets back the same order and the same payment redirect instead of a second charge. The PricingAdjuster dependency is optional — if a deployment hasn’t adopted the promotions subsystem, checkout falls back to a pass-through calculation that’s byte-identical to pre-promotions behavior, so adding promotions later is additive, never a breaking change to checkout itself.

When a payment actually succeeds, checkout doesn’t find out by polling or by a direct function call from the payments package — it subscribes to an event:

// packages/checkout-orders/src/service.ts events.subscribe<{ sessionId: string; orderRef: string | null }>( "payments.payment.succeeded", async ({ orderRef }) => { if (!orderRef) return; const order = await repository.get(orderRef); // Idempotent: a redelivered webhook event that already transitioned this // order is a no-op, not a re-publish. if (!order || order.status !== "pending_payment") return; const paid: Order = { ...order, status: "paid" }; await repository.save(paid); await events.publish("checkout.order.paid", { orderId: paid.id }); }, );

An order placement fans out from there to inventory (decrement stock), account (order shows up in a shopper’s dashboard), and potentially a plugin — none of which checkout-orders needs to import or know about. Adding a new reaction to “order placed” later is a new subscriber somewhere else in the system, never a change to this file.

Payments: an adapter, not a payment processor

The payments subsystem is deliberately the thinnest layer in commerce core. It never touches card data, never computes tax, and never decides when to charge — it exposes one interface, PaymentAdapter, and Stripe is the first (and so far only) concrete implementation of it:

// packages/payments/src/types.ts export interface PaymentAdapter { createPaymentSession(input: CreatePaymentSessionInput): Promise<PaymentSession>; confirmPayment(sessionId: string): Promise<PaymentConfirmation>; /** * Verifies and processes a provider webhook payload. Implementations MUST * verify the signature before trusting the payload (never trust an * unverified webhook body). Publishes payments.payment.succeeded / * payments.payment.failed on the injected EventBus -- callers don't need to * inspect the raw provider event shape. */ handleWebhookEvent(rawBody: string | Buffer, signature: string): Promise<void>; }

The reference Stripe adapter wraps Stripe Checkout Sessions — hosted checkout UI, automatic Stripe Tax, and webhook-driven confirmation — so this framework never hand-builds payment forms, PCI handling, or tax logic itself:

// packages/payments/src/stripe-adapter.ts async createPaymentSession(input: CreatePaymentSessionInput): Promise<PaymentSession> { const session = await stripe.checkout.sessions.create({ mode: "payment", line_items: input.lineItems.map((item) => ({ price_data: { currency: item.unitAmount.currency.toLowerCase(), product_data: { name: item.name }, unit_amount: item.unitAmount.amount, }, quantity: item.quantity, })), success_url: input.successUrl, cancel_url: input.cancelUrl, automatic_tax: { enabled: true }, metadata: { orderRef: input.orderRef }, }); if (!session.url) { throw new Error(`Stripe did not return a redirect URL for session ${session.id}`); } return { sessionId: session.id, redirectUrl: session.url }; }

Notice metadata: { orderRef: input.orderRef } — that’s the thread that lets a Stripe webhook, arriving asynchronously and with no other context, tell checkout-orders exactly which order to mark paid. And because PaymentAdapter is a real interface rather than a Stripe-shaped assumption baked into checkout, a second provider — PayPal, a regional processor, whatever “or other things manually” ends up meaning per the framework’s own founding spec — is a new package implementing the same three methods, with zero changes to checkout-orders or anything else that depends on it.

Further reading