09·01Payment 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 l

49 - Capstone: Payment System

Previous · Next

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

A 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.while reads the failure’s _tag on 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 → CONFIRMED
Request 2 → findByKey { id } → return cached
Request 3 → findByKey null → acquireLock false → IdempotencyConflict

The 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/.

index.test.ts
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
})
← PreviousCause & SandboxNext →E-commerce Checkout