23 — Service-driven development in Effect v4
V4 uses Context.Service for application capabilities. A service class is both the requirement identifier and the service shape. Implementations remain explicit Layers:
class Greeter extends Context.Service<Greeter>()("app/Greeter", { make }) { static readonly layer = Layer.effect(this, this.make)}Compose dependent layers with Layer.provide, and co-locate descriptive layer, layerTest, or layerNoop values with the contract. The exercises keep orchestration dependent only on service types and swap implementations without globals.
The full test suite
The index.test.ts for this lesson, verbatim from steps/23-service-driven/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Context, Layer, Data, Exit } from "effect"
// =============================================================================// 13 - Service-Driven Development with Context.Service// =============================================================================//// The pattern:// 1. Declare service contracts (Tags) with Context.Service or Context.Service// 2. Write orchestration code against the contracts — type-checks immediately// 3. Add real implementations as Layers// 4. Swap Layers between environments without changing business logic
class ValidationError extends Data.TaggedError("ValidationError")<{ readonly message: string}> {}
class PaymentError extends Data.TaggedError("PaymentError")<{ readonly reason: string}> {}
// --- Service contracts (Tags) ---
class Validation extends Context.Service<Validation, { readonly validateOrder: ( data: unknown ) => Effect.Effect<{ amount: number; userId: string }, ValidationError> }>()("app/Validation") {}
class Payments extends Context.Service<Payments, { readonly charge: ( userId: string, amount: number ) => Effect.Effect<{ txId: string }, PaymentError> }>()("app/Payments") {}
class Notifications extends Context.Service<Notifications, { readonly sendReceipt: (userId: string, txId: string) => Effect.Effect<void> }>()("app/Notifications") {}
// --- Orchestration code: compiles before any implementation exists ---
const processOrder = ( data: unknown): Effect.Effect< { txId: string }, ValidationError | PaymentError, Validation | Payments | Notifications> => Effect.gen(function* () { const validation = yield* Validation const payments = yield* Payments const notifications = yield* Notifications
const order = yield* validation.validateOrder(data) const payment = yield* payments.charge(order.userId, order.amount) yield* notifications.sendReceipt(order.userId, payment.txId)
return { txId: payment.txId } })
// --- Reusable test Layers ---
const ValidationTest = Layer.succeed(Validation, { validateOrder: () => Effect.succeed({ amount: 100, userId: "user-1" }),})
const PaymentsTest = Layer.succeed(Payments, { charge: () => Effect.succeed({ txId: "tx-test" }),})
const NotificationsTest = Layer.succeed(Notifications, { sendReceipt: () => Effect.void,})
describe("13 - Service-Driven Development", () => { // @beat // id: happy-path // shape: one-todo // title: Run orchestration against test Layers // teaches: Compose Layers with Layer.mergeAll and provide the bundle to exercise the happy path. // hint: Layer.mergeAll combines all three test layers into one AppLayer describe("the happy path", () => { it("runs end-to-end against test implementations", async () => { const TestLayer = /* @stub */ Layer.mergeAll(ValidationTest, PaymentsTest, NotificationsTest) /* @end */
const result = await Effect.runPromise( processOrder({}).pipe(Effect.provide(TestLayer)) )
expect(result.txId).toBe("tx-test") })
it("invokes the notification side effect", async () => { const sent: Array<{ userId: string; txId: string }> = []
const NotificationsCapture = /* @stub */ Layer.succeed(Notifications, { sendReceipt: (userId, txId) => Effect.sync(() => { sent.push({ userId, txId }) }), }) /* @end */
const TestLayer = Layer.mergeAll(ValidationTest, PaymentsTest, NotificationsCapture)
await Effect.runPromise(processOrder({}).pipe(Effect.provide(TestLayer)))
expect(sent).toEqual([{ userId: "user-1", txId: "tx-test" }]) }) }) // @end-beat
// @beat // id: swap-for-failure // shape: multi-todo // title: Swap a Layer to exercise failure paths // teaches: Replacing a single Layer with a failing implementation reproduces real-world failures without touching the orchestration code. // hint: Layer.succeed(Payments, { charge: () => Effect.fail(new PaymentError(...)) }) describe("swapping a single Layer exposes failure paths", () => { it("PaymentsFail reproduces a card-declined error without touching processOrder", async () => { const PaymentsFail = /* @stub */ Layer.succeed(Payments, { charge: () => Effect.fail(new PaymentError({ reason: "Card declined" })), }) /* @end */
const TestLayer = Layer.mergeAll(ValidationTest, PaymentsFail, NotificationsTest)
const exit = await Effect.runPromiseExit( processOrder({}).pipe(Effect.provide(TestLayer)) )
expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("PaymentError") expect((exit.cause.reasons[0].error as PaymentError).reason).toBe("Card declined") } })
it("ValidationFail short-circuits before payments and notifications run", async () => { const notified: Array<string> = []
const ValidationFail = /* @stub */ Layer.succeed(Validation, { validateOrder: () => Effect.fail(new ValidationError({ message: "amount must be positive" })), }) /* @end */
const PaymentsTrack = Layer.succeed(Payments, { charge: () => { throw new Error("payments must not run") }, })
const NotificationsTrack = Layer.succeed(Notifications, { sendReceipt: (userId) => Effect.sync(() => { notified.push(userId) }), })
const TestLayer = Layer.mergeAll(ValidationFail, PaymentsTrack, NotificationsTrack)
const exit = await Effect.runPromiseExit( processOrder({}).pipe(Effect.provide(TestLayer)) )
expect(Exit.isFailure(exit)).toBe(true) expect(notified).toEqual([]) }) }) // @end-beat
// @beat // id: realistic-doubles // shape: multi-todo // title: Realistic test doubles stitch an audit trail // teaches: Test Layers can hold logic and observable state; the orchestration code stays unchanged. // hint: txId follows the `tx-${userId}-${amount}` template; audit captures charge then receipt describe("realistic test doubles", () => { it("exercises a full end-to-end audit trail", async () => { const audit: Array<string> = []
const ValidationRealistic = Layer.succeed(Validation, { validateOrder: (data) => { const d = data as { amount?: number; userId?: string } if (typeof d.amount !== "number" || d.amount <= 0) { return Effect.fail(new ValidationError({ message: "invalid amount" })) } return Effect.succeed({ amount: d.amount, userId: d.userId ?? "anon" }) }, })
const PaymentsRealistic = Layer.succeed(Payments, { charge: (userId, amount) => Effect.sync(() => { audit.push(`charged ${userId} $${amount}`) return { txId: `tx-${userId}-${amount}` } }), })
const NotificationsRealistic = Layer.succeed(Notifications, { sendReceipt: (userId, txId) => Effect.sync(() => { audit.push(`receipt to ${userId} for ${txId}`) }), })
const TestLayer = Layer.mergeAll( ValidationRealistic, PaymentsRealistic, NotificationsRealistic )
const result = await Effect.runPromise( processOrder({ amount: 50, userId: "alice" }).pipe(Effect.provide(TestLayer)) )
// Predict txId from `tx-${userId}-${amount}`, then the two audit entries in order expect(result.txId).toBe(/* @stub */ "tx-alice-50" /* @end */) expect(audit).toEqual(/* @stub */ [ "charged alice $50", "receipt to alice for tx-alice-50", ] /* @end */) }) }) // @end-beat
// @beat // id: effect-service // shape: one-todo // title: Declare a service with Context.Service // teaches: Context.Service combines the Tag and default Layer in one class; access the default with MyService.layer. // hint: provide Greeter.layer to satisfy the Greeter requirement describe("Context.Service — the modern declaration form", () => { it("combines Tag and default Layer in one class", async () => { class Greeter extends Context.Service<Greeter>()("app/Greeter", { make: Effect.sync(() => ({ greet: (name: string) => Effect.succeed(`Hello, ${name}!`), })), }) { static readonly layer = Layer.effect(this, this.make) }
const program = Effect.gen(function* () { const greeter = yield* Greeter return yield* greeter.greet("World") })
const result = await Effect.runPromise(program.pipe(Effect.provide(/* @stub */ Greeter.layer /* @end */))) expect(result).toBe("Hello, World!") }) }) // @end-beat
// @beat // id: production-discipline // shape: one-todo // title: Production discipline — Effect.fn methods + named layers // teaches: Two conventions worth adopting for application services. (1) Wrap every service method in Effect.fn("ServiceName.method") so every call site gets a free trace span. (2) Co-locate `layer`, `testLayer`, and `noopLayer` as static members on the service class — the production wiring lives next to the Tag. // hint: Mailer.send pushes `SEND ${to}: ${body}` into audit describe("production discipline", () => { it("wraps service methods in Effect.fn and exposes named static layers", async () => { const audit: Array<string> = []
class Mailer extends Context.Service< Mailer, { readonly send: (to: string, body: string) => Effect.Effect<void> } >()("app/Mailer") { // The production wiring — one Layer per environment, co-located on the class. static readonly testLayer = Layer.succeed(Mailer, { // Effect.fn names the span; even nullary or trivial bodies benefit. send: Effect.fn("Mailer.send")(function* (to, body) { audit.push(`SEND ${to}: ${body}`) }), })
static readonly noopLayer = Layer.succeed(Mailer, { send: Effect.fn("Mailer.send")(() => Effect.void), }) }
const sendWelcome = (userId: string) => Effect.gen(function* () { const mailer = yield* Mailer yield* mailer.send(userId, "Welcome!") })
// Tests reach for Mailer.testLayer — no Layer construction at the call site. await Effect.runPromise(sendWelcome("alice").pipe(Effect.provide(Mailer.testLayer))) // Predict what Mailer.testLayer pushed into audit when sendWelcome("alice") ran expect(audit).toEqual(/* @stub */ ["SEND alice: Welcome!"] /* @end */) }) }) // @end-beat})