09·02E-commerce Checkout

A checkout flow orchestrates four independent services, inventory, pricing, payment, order, each with its own failure mode. The useful question is not "did it work?" but "what happens when one of them does not?" This capstone composes the services with `Effect

50 - Capstone: E-Commerce Checkout

Previous · Next

A checkout flow orchestrates four independent services, inventory, pricing, payment, order, each with its own failure mode. The useful question is not “did it work?” but “what happens when one of them does not?” This capstone composes the services with Effect.gen, runs the independent checks in parallel with Effect.all, and exposes every failure in the return type.

The final exercise turns this into a service-shaped boundary slice. A Schema acts as the typed transport contract, unknown input is decoded at the HTTP-style edge, SQL stays behind an OrdersRepository, the repository owns the transaction, and publication runs only after commit. Live adapters can provide real HTTP, SQL, and telemetry integrations; tests replace them with Layers without changing the application program.

The resulting artifacts are the contract Schema, tagged boundary errors, service interfaces, live and fake Layers, transaction test, and the observed begin → insert → commit → publish ordering. Together they make the architecture reviewable without coupling the domain program to a particular HTTP or SQL package.

What you’ll work through

The second capstone scales the single-service shape of 49-payment-system up to four collaborating services. index.test.ts covers the happy path: a non-empty cart flowing through parallel inventory and pricing checks, sequential reserve / charge / create, and a returned Order.

The interesting territory here is structural rather than algorithmic — every service comes from an earlier step, and the new content is how they compose under Effect.all and Layer.mergeAll.

By the end you should be able to take any multi-service workflow and decide three things from its signature alone: which steps run in parallel, which failures are recoverable, and where the dangerous partial-failure windows are.


The Pipeline

checkout(cart)
├── validate cart not empty (CartError)
├── parallel
│ ├── inventory.check(item) for each item (InventoryError)
│ └── pricing.getPrice(item) for each item (PricingError)
├── total sum(price * quantity)
├── reserve inventory.reserve(item) for each item (InventoryError)
├── charge payment.charge(userId, total) (PaymentError)
└── create orders.create(...) (OrderError)

Effect.all short-circuits on first failure by default. If prod-1 is out of stock, the pricing branch is interrupted before its requests finish.


The Signature

// ┌─── Effect<Order, CheckoutError,
// │ InventoryService | PricingService | PaymentService | OrderService>
// ▼
declare function checkout(
cart: Cart,
): Effect.Effect<
Order,
CheckoutError,
InventoryService | PricingService | PaymentService | OrderService
>
type CheckoutError =
| CartError
| InventoryError
| PricingError
| PaymentError
| OrderError

Four dependencies, five failure modes. Every one of them is visible to the caller.


Validate before any service is resolved

Validation runs at the top of the pipeline, before any Tag is yielded. Bad input never reaches the parallel branch, the payment gateway, or the order-creation service — it short-circuits with a typed ValidationError in E.

The orchestration code is unchanged whether validation lives in a service or as a pure helper; what matters is that it runs first.

import { Effect } from "effect"
const checkout = (cart: Cart) =>
Effect.gen(function* () {
// 1. Pure validation — no service required.
yield* validateCart(cart) // Effect<void, ValidationError>
// 2. Only now do we touch services.
const inventory = yield* InventoryService
const pricing = yield* PricingService
// ...
})

Validation in front means downstream branches never need to defend against malformed shapes — they can trust their inputs. The cost of running it eagerly is tiny; the cost of letting bad input through is a debugging session in production.

Your turn add a `validateCart` step at the top of the pipeline and assert that an invalid cart fails *before* any service mock is called.


Parallel Checks with Effect.all

// ┌─── Effect<[Inventory[], Price[]], InventoryError | PricingError, ...>
// ▼
const [, prices] = yield* Effect.all(
[
Effect.all(
cart.items.map((item) => inventory.check(item.productId, item.quantity)),
{ concurrency: "unbounded" },
),
Effect.all(
cart.items.map((item) => pricing.getPrice(item.productId)),
{ concurrency: "unbounded" },
),
],
{ concurrency: "unbounded" },
)

Details

{ concurrency: "unbounded" } runs every effect at once. The default is sequential.

The default failure mode short-circuits on the first error and interrupts the rest; { mode: "validate" } collects all failures, { mode: "either" } wraps each result in Result.

Notice. Without concurrency, Effect.all runs sequentially. A 50-item cart that spends 100ms per pricing call costs five seconds in sequence and a hundred milliseconds in parallel — same code, same errors, different option.


Composing Layers

import { Layer } from "effect"
const testLayers = Layer.mergeAll(
Layer.succeed(InventoryService, { check, reserve }),
Layer.succeed(PricingService, { getPrice }),
Layer.succeed(PaymentService, { charge }),
Layer.succeed(OrderService, { create }),
)

Layer.mergeAll combines independent layers, no two may depend on each other. When layers do form a dependency chain, use Layer.provide to wire the dependency before merge.

Swapping one layer is a one-line change:

const failingPayment = Layer.succeed(PaymentService, {
charge: () => Effect.fail(new PaymentError({ reason: "card_declined" })),
})

Partial Failure

The dangerous window is between payment.charge and orders.create. The card is debited but no order exists.

Effect does not roll back automatically, compensation is a domain decision. The type forces the question by exposing OrderError.

const order = yield* orders
.create({ userId, items, total, txId: charge.txId })
.pipe(
Effect.catchTag("OrderError", (e) =>
payment.refund(charge.txId).pipe(Effect.flatMap(() => Effect.fail(e))),
),
)

The compensating refund itself returns an Effect, so its failure (a refund that did not go through) joins the error channel and surfaces to the caller for alerting.


The Full Function

import { Effect } from "effect"
// ┌─── Effect<Order, CheckoutError, ...services>
// ▼
const checkout = (cart: Cart) =>
Effect.gen(function* () {
if (cart.items.length === 0) {
return yield* Effect.fail(new CartError({ reason: "Cart is empty" }))
}
const inventory = yield* InventoryService
const pricing = yield* PricingService
const payment = yield* PaymentService
const orders = yield* OrderService
const [, prices] = yield* Effect.all(
[
Effect.all(
cart.items.map((i) => inventory.check(i.productId, i.quantity)),
{ concurrency: "unbounded" },
),
Effect.all(
cart.items.map((i) => pricing.getPrice(i.productId)),
{ concurrency: "unbounded" },
),
],
{ concurrency: "unbounded" },
)
const total = prices.reduce(
(sum, price, i) => sum + price.amount * cart.items[i].quantity,
0,
)
yield* Effect.all(
cart.items.map((i) => inventory.reserve(i.productId, i.quantity)),
{ concurrency: "unbounded" },
)
const charge = yield* payment.charge(cart.userId, total)
return yield* orders.create({
userId: cart.userId,
items: cart.items,
total,
txId: charge.txId,
})
})

Common Pitfalls

1, Sequential when you mean parallel. A bare for loop with yield* runs items one after another. Use Effect.all with { concurrency: "unbounded" } (or a number) when the items are independent.

2, Ignoring the partial-failure window. Charge succeeded, order failed: money out, order missing. Add compensation with catchTag("OrderError", ...) or a saga/outbox.

3, Circular Layer.mergeAll order. mergeAll requires that no layer depends on another in the set. Chain dependencies with Layer.provide before merging.

4, Forgetting to short-circuit validation. Validate the cart first, before resolving services. Failing fast on bad input keeps the I/O surface clean.


See Also

  • Previous, 49-payment-system: the single-service version.
  • Next, 25-config: parameterising the system on environment.
  • Foundations, 12-combining-effects for Effect.all, 22-layers for layer composition.

Run the tests: pnpm test 50-ecommerce-checkout


The full test suite

The index.test.ts for this lesson, verbatim from steps/50-ecommerce-checkout/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import { Effect, Context, Layer, Data, Schema } from "effect"
// =============================================================================
// 22 - Capstone: E-Commerce Checkout
// =============================================================================
//
// Orchestrates four services. Inventory + pricing run in parallel; reserve →
// charge → create run sequentially. Five failure modes; all visible in the type.
// ---------- Errors ----------
class CartError extends Data.TaggedError("CartError")<{ readonly reason: string }> {}
class InventoryError extends Data.TaggedError("InventoryError")<{
readonly productId: string
}> {}
class PricingError extends Data.TaggedError("PricingError")<{
readonly productId: string
}> {}
class PaymentError extends Data.TaggedError("PaymentError")<{ readonly reason: string }> {}
class OrderError extends Data.TaggedError("OrderError")<{ readonly reason: string }> {}
type CheckoutError =
| CartError
| InventoryError
| PricingError
| PaymentError
| OrderError
// ---------- Domain ----------
type Cart = {
userId: string
items: Array<{ productId: string; quantity: number }>
}
type Inventory = { productId: string; available: number }
type Price = { productId: string; amount: number }
type Order = { orderId: string; total: number; status: string }
// ---------- Services ----------
class InventoryService extends Context.Service<InventoryService, {
readonly check: (
productId: string,
quantity: number,
) => Effect.Effect<Inventory, InventoryError>
readonly reserve: (
productId: string,
quantity: number,
) => Effect.Effect<void, InventoryError>
}>()("InventoryService") {}
class PricingService extends Context.Service<PricingService, {
readonly getPrice: (productId: string) => Effect.Effect<Price, PricingError>
}>()("PricingService") {}
class PaymentService extends Context.Service<PaymentService, {
readonly charge: (
userId: string,
amount: number,
) => Effect.Effect<{ txId: string }, PaymentError>
}>()("PaymentService") {}
class OrderService extends Context.Service<OrderService, {
readonly create: (data: {
userId: string
items: Cart["items"]
total: number
txId: string
}) => Effect.Effect<Order, OrderError>
}>()("OrderService") {}
// ---------- The function ----------
// ┌─── Effect<Order, CheckoutError, ...services>
// ▼
function checkout(cart: Cart): Effect.Effect<
Order,
CheckoutError,
InventoryService | PricingService | PaymentService | OrderService
> {
return Effect.gen(function* () {
if (cart.items.length === 0) {
return yield* Effect.fail(new CartError({ reason: "Cart is empty" }))
}
const inventory = yield* InventoryService
const pricing = yield* PricingService
const payment = yield* PaymentService
const orders = yield* OrderService
const [, prices] = yield* Effect.all(
[
Effect.all(
cart.items.map((i) => inventory.check(i.productId, i.quantity)),
{ concurrency: "unbounded" },
),
Effect.all(
cart.items.map((i) => pricing.getPrice(i.productId)),
{ concurrency: "unbounded" },
),
],
{ concurrency: "unbounded" },
)
const total = prices.reduce(
(sum, price, i) => sum + price.amount * cart.items[i].quantity,
0,
)
yield* Effect.all(
cart.items.map((i) => inventory.reserve(i.productId, i.quantity)),
{ concurrency: "unbounded" },
)
const charge = yield* payment.charge(cart.userId, total)
return yield* orders.create({
userId: cart.userId,
items: cart.items,
total,
txId: charge.txId,
})
})
}
// ---------- Tests ----------
const testLayers = Layer.mergeAll(
Layer.succeed(InventoryService, {
check: (productId, _quantity) =>
productId === "out-of-stock"
? Effect.fail(new InventoryError({ productId }))
: Effect.succeed({ productId, available: 100 }),
reserve: () => Effect.void,
}),
Layer.succeed(PricingService, {
getPrice: (productId) => Effect.succeed({ productId, amount: 29.99 }),
}),
Layer.succeed(PaymentService, {
charge: (userId, amount) =>
Effect.succeed({ txId: `tx_${userId}_${amount}` }),
}),
Layer.succeed(OrderService, {
create: (data) =>
Effect.succeed({
orderId: `order_${data.txId}`,
total: data.total,
status: "confirmed",
}),
}),
)
describe("22 - E-Commerce Checkout", () => {
// @beat
// id: orchestrate-services
// shape: worked-then-your-turn
// title: Orchestrate four services
// teaches: Effect.gen sequences validation, parallel checks, reserve, charge, and create across four service tags.
// hint: Effect.provide a Layer.mergeAll of every service before runPromise
describe("happy path", () => {
it("completes a checkout with the merged test layers", async () => {
const order = await Effect.runPromise(
checkout({
userId: "user-1",
items: [
{ productId: "prod-1", quantity: 2 },
{ productId: "prod-2", quantity: 1 },
],
}).pipe(Effect.provide(testLayers)),
)
expect(order.status).toBe("confirmed")
expect(order.orderId).toBeTruthy()
expect(order.total).toBeCloseTo(89.97)
})
})
// @end-beat
// @beat
// id: validate-cart
// shape: one-todo
// title: Short-circuit an empty cart
// teaches: Validation runs before any service is resolved so bad input never reaches the parallel branch.
// hint: yield* Effect.fail(new CartError({ reason: "Cart is empty" })) when items.length === 0
it("an empty cart fails with CartError before any I/O", async () => {
const exit = await Effect.runPromiseExit(
checkout({ userId: "user-1", items: [] }).pipe(
Effect.provide(testLayers),
),
)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
expect(exit.cause.reasons[0].error._tag).toBe("CartError")
}
})
// @end-beat
// @beat
// id: parallel-checks
// shape: multi-todo
// title: Run inventory and pricing in parallel
// teaches: Effect.all with { concurrency: "unbounded" } fans out independent checks and short-circuits on the first failure.
// hint: nest Effect.all — outer pairs the two branches, inner maps each branch over cart.items
it("an out-of-stock item short-circuits the parallel branch", async () => {
const exit = await Effect.runPromiseExit(
checkout({
userId: "user-1",
items: [{ productId: "out-of-stock", quantity: 1 }],
}).pipe(Effect.provide(testLayers)),
)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
expect(exit.cause.reasons[0].error._tag).toBe("InventoryError")
}
})
// @end-beat
// @beat
// id: swap-layer
// shape: from-scratch
// title: Swap one layer to inject a failure
// teaches: Layer.mergeAll combines independent services; swap one Layer.succeed to test downstream propagation.
// hint: rebuild the merged layer with a PaymentService whose charge returns Effect.fail(new PaymentError(...))
it("a payment failure propagates through the pipeline", async () => {
const failingPayment = Layer.mergeAll(
Layer.succeed(InventoryService, {
check: () => Effect.succeed({ productId: "p1", available: 100 }),
reserve: () => Effect.void,
}),
Layer.succeed(PricingService, {
getPrice: (pid) => Effect.succeed({ productId: pid, amount: 10 }),
}),
Layer.succeed(PaymentService, {
charge: () =>
Effect.fail(new PaymentError({ reason: "Card declined" })),
}),
Layer.succeed(OrderService, {
create: () =>
Effect.succeed({ orderId: "x", total: 0, status: "x" }),
}),
)
const exit = await Effect.runPromiseExit(
checkout({
userId: "user-1",
items: [{ productId: "prod-1", quantity: 1 }],
}).pipe(Effect.provide(failingPayment)),
)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
expect(exit.cause.reasons[0].error._tag).toBe("PaymentError")
}
})
// @end-beat
// @beat
// id: partial-failure
// shape: from-scratch
// title: Surface the partial-failure window
// teaches: OrderError after a successful charge is the compensation seam — the type forces the caller to acknowledge it.
// hint: at the call site, Effect.catchTag("OrderError", e => payment.refund(charge.txId).pipe(Effect.flatMap(() => Effect.fail(e))))
it("an order failure after a successful charge is the partial-failure window", async () => {
// Card debited, order missing. OrderError in the type forces the caller
// to consider compensation (refund, alert, dead-letter queue).
const partialFail = Layer.mergeAll(
Layer.succeed(InventoryService, {
check: () => Effect.succeed({ productId: "p1", available: 100 }),
reserve: () => Effect.void,
}),
Layer.succeed(PricingService, {
getPrice: (pid) => Effect.succeed({ productId: pid, amount: 10 }),
}),
Layer.succeed(PaymentService, {
charge: (userId, amount) =>
Effect.succeed({ txId: `tx_${userId}_${amount}` }),
}),
Layer.succeed(OrderService, {
create: () =>
Effect.fail(new OrderError({ reason: "DB unavailable" })),
}),
)
const exit = await Effect.runPromiseExit(
checkout({
userId: "user-1",
items: [{ productId: "prod-1", quantity: 1 }],
}).pipe(Effect.provide(partialFail)),
)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
expect(exit.cause.reasons[0].error._tag).toBe("OrderError")
}
})
// @end-beat
})
// @beat
// id: transactional-repository-boundary
// shape: read-run
// title: Keep contracts, SQL, transactions, and post-commit work at explicit seams
// teaches: Decode at the boundary, hide SQL behind a repository, commit the transaction before publishing follow-up effects, and replace every edge with a Layer in tests.
// hint: The audit order should be begin, insert, commit, publish; publishing inside transaction would make the boundary incorrect.
describe("service-shaped boundary slice", () => {
const CreateOrder = Schema.Struct({ customerId: Schema.String, total: Schema.Number })
class OrdersRepository extends Context.Service<OrdersRepository, {
readonly transaction: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
readonly insert: (order: typeof CreateOrder.Type) => Effect.Effect<string>
}>()("app/OrdersRepository") {}
class OrderEvents extends Context.Service<OrderEvents, {
readonly created: (id: string) => Effect.Effect<void>
}>()("app/OrderEvents") {}
const createOrder = (input: unknown) => Effect.gen(function*() {
const order = yield* Schema.decodeUnknownEffect(CreateOrder)(input)
const repository = yield* OrdersRepository
const events = yield* OrderEvents
const id = yield* repository.transaction(repository.insert(order))
yield* events.created(id)
return { id }
})
it("keeps SQL behind a repository transaction and effects after commit", async () => {
const audit: Array<string> = []
const layer = Layer.mergeAll(
Layer.succeed(OrdersRepository, {
transaction: (effect) => Effect.sync(() => audit.push("begin")).pipe(
Effect.andThen(effect),
Effect.tap(() => Effect.sync(() => audit.push("commit"))),
),
insert: () => Effect.sync(() => (audit.push("insert"), "o-1")),
}),
Layer.succeed(OrderEvents, {
created: () => Effect.sync(() => { audit.push("publish") }),
}),
)
const result = await Effect.runPromise(
createOrder({ customerId: "c-1", total: 42 }).pipe(Effect.provide(layer)),
)
expect(result).toEqual({ id: "o-1" })
expect(audit).toEqual(["begin", "insert", "commit", "publish"])
})
})
// @end-beat
← PreviousPayment SystemNext →SWAPI API Client