Recipes
Checkout Flow
A customer checkout example with validation, inventory reads, payment resilience, rate limits, and receipt jobs.
Checkout is a good Yieldless fit because it crosses several failure-heavy boundaries without needing a framework runtime: user input, inventory reads, payment APIs, rate limits, and background receipts.
This recipe keeps those boundaries explicit. The service code still reads like ordinary TypeScript, but the painful edges return tuples and accept cancellation.
The moving parts
yieldless/routerturns the tuple service into an HTTP responseyieldless/schemavalidates checkout inputyieldless/cacheandyieldless/batcherkeep inventory reads efficientyieldless/limiterprotects a payment API quotayieldless/breakerfails fast while the payment provider is unhealthyyieldless/queuestores receipt work for a local workeryieldless/fetchkeeps remote API calls in tuple form
Checkout service
import { createBatcher } from "yieldless/batcher";
import { CircuitOpenError, createCircuitBreaker } from "yieldless/breaker";
import { createCache } from "yieldless/cache";
import { safeTry } from "yieldless/error";
import { HttpStatusError, fetchJsonSafe } from "yieldless/fetch";
import { createRateLimiter } from "yieldless/limiter";
import { createQueue } from "yieldless/queue";
import { BadRequestError, honoHandler } from "yieldless/router";
import { parseSafe } from "yieldless/schema";
interface CheckoutInput {
readonly customerId: string;
readonly idempotencyKey: string;
readonly items: readonly {
readonly sku: string;
readonly quantity: number;
}[];
}
interface InventoryItem {
readonly priceCents: number;
readonly sku: string;
readonly stock: number;
}
interface PaymentIntent {
readonly id: string;
readonly status: "authorized" | "requires_action";
}
interface ReceiptJob {
readonly customerId: string;
readonly paymentId: string;
}
export function createCheckoutService(apiBaseUrl: string) {
const receiptJobs = createQueue<ReceiptJob>({ capacity: 1_000 });
const paymentQuota = createRateLimiter({ limit: 50, intervalMs: 60_000 });
const inventoryBatcher = createBatcher<string, InventoryItem>({
waitMs: 2,
maxBatchSize: 100,
loadMany: async (skus, signal) => {
const [error, inventory] = await fetchJsonSafe<InventoryItem[]>(
`${apiBaseUrl}/inventory?skus=${encodeURIComponent(skus.join(","))}`,
{ timeoutMs: 2_000, signal },
);
if (error) {
return [error, null] as const;
}
const bySku = new Map(inventory.map((item) => [item.sku, item]));
return [
null,
skus.map(
(sku) => bySku.get(sku) ?? { sku, priceCents: 0, stock: 0 },
),
] as const;
},
});
const inventory = createCache<string, InventoryItem>({
ttlMs: 15_000,
maxSize: 2_000,
load: (sku, signal) => inventoryBatcher.load(sku, { signal }),
});
const createPayment = createCircuitBreaker(
(signal, input: { amountCents: number; idempotencyKey: string }) =>
fetchJsonSafe<PaymentIntent>(`${apiBaseUrl}/payments/intents`, {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": input.idempotencyKey,
},
body: JSON.stringify({ amountCents: input.amountCents }),
timeoutMs: 5_000,
signal,
}),
{
failureThreshold: 3,
cooldownMs: 20_000,
shouldTrip: (error) =>
!(error instanceof HttpStatusError) || error.status >= 500,
},
);
return {
receiptJobs,
async checkout(input: unknown, signal: AbortSignal) {
const [inputError, checkout] = parseSafe(checkoutSchema, input);
if (inputError) {
return [inputError, null] as const;
}
const loadedItems = await Promise.all(
checkout.items.map((item) => inventory.get(item.sku, { signal })),
);
const inventoryError = loadedItems.find(([error]) => error !== null)?.[0];
if (inventoryError) {
return [inventoryError, null] as const;
}
const items = loadedItems.map(([, item]) => item as InventoryItem);
const unavailable = checkout.items.find((requested, index) => {
const available = items[index]?.stock ?? 0;
return available < requested.quantity;
});
if (unavailable !== undefined) {
return [new BadRequestError(`${unavailable.sku} is out of stock`), null] as const;
}
const amountCents = checkout.items.reduce(
(total, item, index) =>
total + item.quantity * (items[index]?.priceCents ?? 0),
0,
);
const [quotaError] = await paymentQuota.takeSafe({ signal });
if (quotaError) {
return [quotaError, null] as const;
}
const [paymentError, payment] = await createPayment({
amountCents,
idempotencyKey: checkout.idempotencyKey,
});
if (paymentError instanceof CircuitOpenError) {
return [
new Error("Payments are temporarily unavailable. Please try again soon."),
null,
] as const;
}
if (paymentError) {
return [paymentError, null] as const;
}
const [receiptError] = await receiptJobs.offer(
{
customerId: checkout.customerId,
paymentId: payment.id,
},
{ signal },
);
if (receiptError) {
return [receiptError, null] as const;
}
return [
null,
{
paymentId: payment.id,
status: payment.status,
},
] as const;
},
};
}
const checkoutService = createCheckoutService("https://api.example.com");
export const postCheckout = honoHandler(
async (c) => {
const [bodyError, body] = await safeTry(c.req.json());
if (bodyError) {
return [new BadRequestError("Invalid checkout JSON"), null] as const;
}
return await checkoutService.checkout(body, c.req.raw.signal);
},
{ successStatus: 201 },
);Receipt worker
async function runReceiptWorker(signal: AbortSignal) {
while (!signal.aborted) {
const [takeError, job] = await checkoutService.receiptJobs.take({ signal });
if (takeError) {
return;
}
const [sendError] = await fetchJsonSafe("/internal/receipts/send", {
method: "POST",
body: JSON.stringify(job),
headers: { "content-type": "application/json" },
timeoutMs: 5_000,
signal,
});
if (sendError) {
console.error("failed to send receipt", sendError);
}
}
}Why this is a good fit
- Cart validation returns a normal HTTP validation response.
- Inventory reads batch together and cache briefly, which helps busy product pages.
- Payment quota is explicit before the payment call.
- The circuit breaker stops a payment outage from becoming a retry storm.
- Receipt delivery is queued so checkout can finish without waiting on email.
Avoid: hiding business rules behind a generic pipeline
return await checkoutRuntime.run(input, {
validate: true,
cache: true,
rateLimit: true,
payment: true,
queueReceipt: true,
});That hides the parts reviewers most need to see: stock checks, idempotency, payment error handling, and what happens after the customer pays.