49 - Capstone: Payment System
Payments concentrate every difficulty Effect was built for: typed failures, idempotency, retries that distinguish transient from permanent errors, injected infrastructure, and a return type that names the full failure surface. This capstone wires the earlier lessons into one function whose signature tells you everything that can happen.
What you’ll work through
This is the first of two capstones: where the primitives from earlier steps — tagged errors (04, 15), Effect.gen (07), services and layers (21, 22), and retry schedules (31, 32) — combine into a single function. index.test.ts runs read-only beats over a createPayment function: happy path, validation rejection before any I/O, idempotency (cached hit, lock contention), provider error discrimination (hard vs soft fail with retry), and a check that PaymentError covers exactly five tags.
By the end you should be able to read the return type Effect<PaymentResult, PaymentError, DbService | ProviderService> and predict, without opening the body, every branch of the test file.
The Pipeline
createPayment(input) ├── validate amount > 0, idemKey length ≥ 8 (ValidationError) ├── db.findByKey already processed? return cached (IdempotencyConflict) ├── db.acquireLock concurrent request? reject (IdempotencyConflict) ├── provider.charge external API (ProviderHardFail | ProviderSoftFail) │ ├── timeout 5 seconds │ └── retry exponential, soft fails only └── db.save persist (PersistError)Each branch corresponds to a typed error in E. Nothing else can fail.
The Signature
// ┌─── Effect<PaymentResult, PaymentError, DbService | ProviderService>// ▼declare function createPayment(input: { readonly amount: number readonly currency: string readonly idemKey: string}): Effect.Effect<PaymentResult, PaymentError, DbService | ProviderService>
type PaymentError = | ValidationError | IdempotencyConflict | ProviderHardFail | ProviderSoftFail | PersistErrorA is what succeeds. E is the union of every recoverable failure.
R lists every dependency the call site must provide. Missing one is a compile error.
Errors as Data
import { Data } from "effect"
class ValidationError extends Data.TaggedError("ValidationError")<{ readonly field: string readonly message: string}> {}
class ProviderSoftFail extends Data.TaggedError("ProviderSoftFail")<{ readonly reason: string}> {}Data.TaggedError gives each error a _tag discriminator. catchTag narrows on that tag; the compiler removes the case from the remaining union.
Services as Ports
import { Context } from "effect"
class DbService extends Context.Service<DbService, DbPort>()("DbService") {}class ProviderService extends Context.Service<ProviderService, ProviderPort>()("ProviderService") {}Business logic depends on the tag, not on a concrete adapter. Tests supply a fake; production supplies the real adapter via Layer.succeed or Layer.effect.
Retry That Discriminates
The provider distinguishes 4xx (ProviderHardFail) from 5xx and timeouts (ProviderSoftFail). Only the latter is retried.
// ┌─── Effect<ChargeResult, ProviderHardFail | ProviderSoftFail, ProviderService>// ▼const charge = provider.charge(input).pipe( Effect.timeout("5 seconds"), Effect.catchTag("TimeoutError", () => Effect.fail(new ProviderSoftFail({ reason: "Timeout" })), ), Effect.retry(Schedule.recurs(2)),)Schedule.while is the load-bearing piece. Without it, retry attempts a hard failure two more times, wasting calls and risking rate limits.
Why this matters. A naive
retry(Schedule.recurs(2))will hammer a dead 4xx three times before giving up, racking up provider fees and tripping rate limiters.Schedule.whilereads the failure’s_tagon every attempt — the retry policy is part of the error model, not separate from it.
Idempotency
A payment is idempotent when repeating it with the same idemKey is safe. The function performs three checks in sequence:
Request 1 → findByKey null → acquireLock true → charge → save → CONFIRMEDRequest 2 → findByKey { id } → return cachedRequest 3 → findByKey null → acquireLock false → IdempotencyConflictThe first two prevent double charges on retries. The third prevents double charges on concurrent retries arriving before the first request has committed.
> means *safe to repeat*. Without the find-then-lock sequence, a network > timeout that triggers an outer retry will charge the customer twice.The Full Function
import { Effect } from "effect"
// ┌─── Effect<PaymentResult, PaymentError, DbService | ProviderService>// ▼const createPayment = (input: { readonly amount: number readonly currency: string readonly idemKey: string}) => Effect.gen(function* () { if (input.amount <= 0) { return yield* Effect.fail( new ValidationError({ field: "amount", message: "Must be positive" }), ) } if (input.idemKey.length < 8) { return yield* Effect.fail( new ValidationError({ field: "idemKey", message: "Too short" }), ) }
const db = yield* DbService const provider = yield* ProviderService
const existing = yield* db.findByKey(input.idemKey) if (existing) { return { paymentId: existing.id, status: "CONFIRMED" as const } }
const locked = yield* db.acquireLock(input.idemKey) if (!locked) { return yield* Effect.fail(new IdempotencyConflict({ idemKey: input.idemKey })) }
const charged = yield* provider .charge({ amount: input.amount, currency: input.currency }) .pipe( Effect.timeout("5 seconds"), Effect.catchTag("TimeoutError", () => Effect.fail(new ProviderSoftFail({ reason: "Timeout" })), ), Effect.retry(Schedule.recurs(2)), )
const result: PaymentResult = { paymentId: charged.id, status: charged.status === "CONFIRMED" ? "CONFIRMED" : "FAILED", }
yield* db .save({ ...result, idemKey: input.idemKey }) .pipe(Effect.mapError((cause) => new PersistError({ cause })))
return result })Handling Errors at the Call Site
const handled = createPayment(input).pipe( Effect.provide(layers), Effect.catchTag("ValidationError", (e) => Effect.succeed({ error: `Bad input: ${e.field}` }), ), Effect.catchTag("ProviderHardFail", (e) => Effect.succeed({ error: `Provider rejected: ${e.status}` }), ),)Each catchTag removes one variant from E. Once every variant is handled, E is never.
Common Pitfalls
1, Retrying hard failures. A 4xx response will not become a 2xx response on the next attempt. Restrict retries with Schedule.while.
2, Forgetting to provide layers. Effect.runPromise(createPayment(...)) is a compile error when R is not never. That is the type system protecting you from a missing dependency.
3, Reading config inside business logic. Retry counts, timeouts, and URLs belong in the layer, not in createPayment. Tuning resilience should not require touching domain code.
4, Charging twice on retry. Without the find/lock/charge sequence, a network-timeout retry can charge the customer twice. Idempotency is not optional.
Every beat in this capstone is read-run; confirm the suite passes. - [ ] Read the `happy path` test and follow each `yield*` in `createPayment` back to its earlier step (gen → 07, services → 21, retry → 32). - [ ] In the `idempotency` group, run the three scenarios and confirm `findByKey` short-circuits before `acquireLock` is even called. - [ ] In the `provider errors` group, observe that `ProviderHardFail` runs the charge exactly once while `ProviderSoftFail` triggers the retry schedule. - [ ] Read the final `error union` assertion. The number `5` corresponds to every recoverable failure visible in `E` — name each one.
See Also
- Previous,
48-cause-and-sandbox: defects vs typed failures, the distinction every capstone leans on. - Next,
50-ecommerce-checkout: orchestrating four services with parallel checks. - Foundations,
04-error-types,07-effect-gen,21-context-basics,22-layers,32-retry-and-timeout.
Run the tests: pnpm test 49-payment-system
The full test suite
The index.test.ts for this lesson, verbatim from steps/49-payment-system/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Context, Layer, Data, Schedule } from "effect"
// =============================================================================// 21 - Capstone: Payment System// =============================================================================//// Combines: Data.TaggedError (05), Effect.gen (06), catchTag (07),// Context.Service + Layer (11-12), retry + timeout (17), test layers (16).//// The function signature names every failure and every dependency:// Effect<PaymentResult, PaymentError, DbService | ProviderService>
// ---------- Errors ----------
class ValidationError extends Data.TaggedError("ValidationError")<{ readonly field: string readonly message: string}> {}
class IdempotencyConflict extends Data.TaggedError("IdempotencyConflict")<{ readonly idemKey: string}> {}
class ProviderHardFail extends Data.TaggedError("ProviderHardFail")<{ readonly status: number readonly body: unknown}> {}
class ProviderSoftFail extends Data.TaggedError("ProviderSoftFail")<{ readonly reason: string}> {}
class PersistError extends Data.TaggedError("PersistError")<{ readonly cause: unknown}> {}
type PaymentError = | ValidationError | IdempotencyConflict | ProviderHardFail | ProviderSoftFail | PersistError
// ---------- Services ----------
type PaymentResult = { paymentId: string; status: "CONFIRMED" | "FAILED" }
interface DbPort { readonly findByKey: (key: string) => Effect.Effect<{ id: string } | null> readonly acquireLock: (key: string) => Effect.Effect<boolean> readonly save: ( payment: PaymentResult & { idemKey: string }, ) => Effect.Effect<void>}
interface ProviderPort { readonly charge: (input: { amount: number currency: string }) => Effect.Effect< { id: string; status: string }, ProviderHardFail | ProviderSoftFail >}
class DbService extends Context.Service<DbService, DbPort>()("DbService") {}class ProviderService extends Context.Service<ProviderService, ProviderPort>()("ProviderService") {}
// ---------- The function ----------
// ┌─── Effect<PaymentResult, PaymentError, DbService | ProviderService>// ▼function createPayment(input: { amount: number currency: string idemKey: string}): Effect.Effect<PaymentResult, PaymentError, DbService | ProviderService> { return Effect.gen(function* () { if (input.amount <= 0) { return yield* Effect.fail( new ValidationError({ field: "amount", message: "Must be positive" }), ) } if (input.idemKey.length < 8) { return yield* Effect.fail( new ValidationError({ field: "idemKey", message: "Too short" }), ) }
const db = yield* DbService const provider = yield* ProviderService
const existing = yield* db.findByKey(input.idemKey) if (existing) { return { paymentId: existing.id, status: "CONFIRMED" as const } }
const locked = yield* db.acquireLock(input.idemKey) if (!locked) { return yield* Effect.fail( new IdempotencyConflict({ idemKey: input.idemKey }), ) }
const charged = yield* provider .charge({ amount: input.amount, currency: input.currency }) .pipe( Effect.timeout("5 seconds"), Effect.catchTag("TimeoutError", () => Effect.fail(new ProviderSoftFail({ reason: "Timeout" })), ), Effect.retry( Schedule.recurs(2), ), )
const result: PaymentResult = { paymentId: charged.id, status: charged.status === "CONFIRMED" ? "CONFIRMED" : "FAILED", }
yield* db .save({ ...result, idemKey: input.idemKey }) .pipe(Effect.mapError((cause) => new PersistError({ cause })))
return result })}
// ---------- Tests ----------
const makeTestDb = (options?: { existing?: { id: string } | null lockSuccess?: boolean}): DbPort => ({ findByKey: () => Effect.succeed(options?.existing ?? null), acquireLock: () => Effect.succeed(options?.lockSuccess ?? true), save: () => Effect.void,})
const makeTestProvider = (options?: { hardFail?: boolean softFailAttempts?: number}): ProviderPort => { let attempts = 0 return { charge: () => { attempts++ if (options?.hardFail) { return Effect.fail( new ProviderHardFail({ status: 400, body: { error: "bad" } }), ) } if ( options?.softFailAttempts && attempts <= options.softFailAttempts ) { return Effect.fail(new ProviderSoftFail({ reason: "Rate limited" })) } return Effect.succeed({ id: `pay_${Date.now()}`, status: "CONFIRMED", }) }, }}
const makeLayer = (db: DbPort, provider: ProviderPort) => Layer.merge( Layer.succeed(DbService, db), Layer.succeed(ProviderService, provider), )
describe("21 - Payment System", () => { // @beat // id: happy-path // shape: worked-then-your-turn // title: Wire the happy path // teaches: createPayment returns Effect<PaymentResult, PaymentError, DbService | ProviderService> and runs when both layers are provided. // hint: Effect.provide a Layer.merge of DbService and ProviderService before runPromise describe("happy path", () => { it("creates a payment when all checks pass", async () => { const result = await Effect.runPromise( createPayment({ amount: 100, currency: "USD", idemKey: "test-key-12345", }).pipe(Effect.provide(makeLayer(makeTestDb(), makeTestProvider()))), )
expect(result.status).toBe("CONFIRMED") expect(result.paymentId).toBeTruthy() }) }) // @end-beat
// @beat // id: validate-first // shape: multi-todo // title: Validate inputs before any I/O // teaches: Fail fast with Data.TaggedError variants so bad inputs never reach the database or provider. // hint: yield* Effect.fail(new ValidationError(...)) before resolving DbService or ProviderService describe("validation rejects bad input before any I/O", () => { it("rejects non-positive amount", async () => { const exit = await Effect.runPromiseExit( createPayment({ amount: -1, currency: "USD", idemKey: "test-key-12345", }).pipe(Effect.provide(makeLayer(makeTestDb(), makeTestProvider()))), )
expect(exit._tag).toBe("Failure") if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("ValidationError") } })
it("rejects an idempotency key shorter than 8 characters", async () => { const exit = await Effect.runPromiseExit( createPayment({ amount: 100, currency: "USD", idemKey: "short", }).pipe(Effect.provide(makeLayer(makeTestDb(), makeTestProvider()))), )
expect(exit._tag).toBe("Failure") if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("ValidationError") } }) }) // @end-beat
// @beat // id: idempotency // shape: multi-todo // title: Cache hits and lock contention // teaches: findByKey returns a cached PaymentResult; a contended acquireLock raises IdempotencyConflict. // hint: branch on findByKey first, then acquireLock; fail with new IdempotencyConflict when the lock is taken describe("idempotency", () => { it("returns the existing payment when the key has been seen", async () => { const result = await Effect.runPromise( createPayment({ amount: 100, currency: "USD", idemKey: "existing-key-12345", }).pipe( Effect.provide( makeLayer( makeTestDb({ existing: { id: "existing-pay-123" } }), makeTestProvider(), ), ), ), )
expect(result.paymentId).toBe("existing-pay-123") expect(result.status).toBe("CONFIRMED") })
it("fails with IdempotencyConflict when the lock is contended", async () => { const exit = await Effect.runPromiseExit( createPayment({ amount: 100, currency: "USD", idemKey: "test-key-12345", }).pipe( Effect.provide( makeLayer( makeTestDb({ lockSuccess: false }), makeTestProvider(), ), ), ), )
expect(exit._tag).toBe("Failure") if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("IdempotencyConflict") } }) }) // @end-beat
// @beat // id: retry-discriminates // shape: from-scratch // title: Retry soft fails, surface hard fails // teaches: Schedule.while narrows retries to ProviderSoftFail so 4xx hard failures fail immediately. // hint: use a bounded Schedule.recurs policy for transient provider failures describe("provider errors", () => { it("does not retry a hard failure", async () => { const exit = await Effect.runPromiseExit( createPayment({ amount: 100, currency: "USD", idemKey: "test-key-12345", }).pipe( Effect.provide( makeLayer(makeTestDb(), makeTestProvider({ hardFail: true })), ), ), )
expect(exit._tag).toBe("Failure") if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("ProviderHardFail") } })
it("only retries ProviderSoftFail via Schedule.while", () => { // whileInput discriminates on _tag. const isRetryable = (e: PaymentError | Error) => "_tag" in e && e._tag === "ProviderSoftFail"
expect(isRetryable(new ProviderSoftFail({ reason: "rate" }))).toBe(true) expect(isRetryable(new ProviderHardFail({ status: 400, body: null }))).toBe( false, ) }) }) // @end-beat
// @beat // id: error-union // shape: read-run // title: PaymentError names every failure // teaches: The E channel is a closed union of five tagged errors; nothing else can leak out of createPayment. // hint: each Data.TaggedError class contributes one _tag to the union describe("error union", () => { it("PaymentError covers exactly five tags", () => { const errors: PaymentError[] = [ new ValidationError({ field: "amount", message: "bad" }), new IdempotencyConflict({ idemKey: "key-12345" }), new ProviderHardFail({ status: 400, body: null }), new ProviderSoftFail({ reason: "rate limit" }), new PersistError({ cause: new Error("db down") }), ]
expect(errors.map((e) => e._tag)).toEqual([ "ValidationError", "IdempotencyConflict", "ProviderHardFail", "ProviderSoftFail", "PersistError", ]) }) }) // @end-beat})