10·05Use Pattern

Some clients (`pg`, `fs`, `s3`) expose dozens of methods. Wrapping each one in a service method is tedious and rigid.

55 - The use Pattern: Wrapping Wide Clients

Previous · Next

Some clients (pg, fs, s3) expose dozens of methods. Wrapping each one in a service method is tedious and rigid.

The use pattern exposes a single entry point, service.use, that accepts a callback over the raw client. The service still owns the resource, error mapping, timeouts, and cancellation; callers borrow the client for one operation at a time.

What you’ll work through

index.test.ts contains three beats: declaring a service Tag whose only method is use, implementing the Layer that wires in the underlying client, and consuming service.use(client => …) from business code.

By the end you should reach for use whenever the underlying client has more methods than your service interface should expose — every Postgres, filesystem, or S3 wrapper is a candidate.


The Pattern

Explicit wrapping (every method enumerated):
UserService
├── findById, findByName, create, update, delete, ...
use pattern (one entry point):
FileClient
└── use(fn): Effect<A, FileClientError>
│
└── service injects the raw client and an AbortSignal
service maps thrown errors into FileClientError

The raw client never leaves the service. Errors converge on a single tagged type.

Adding a timeout or a logger means changing one place.


Defining a use-style Service

import { Context, Data, Effect, Layer } from "effect"
class FileClientError extends Data.TaggedError("FileClientError")<{
readonly cause: unknown
}> {}
type FileModule = {
readonly readFile: (path: string, signal: AbortSignal) => Promise<string>
readonly writeFile: (path: string, contents: string, signal: AbortSignal) => Promise<void>
}
// ┌─── Context.Service for the service
// ▼
class FileClient extends Context.Service<FileClient, {
readonly use: <A>(
fn: (fileModule: FileModule, signal: AbortSignal) => Promise<A>,
) => Effect.Effect<A, FileClientError>
}>()("FileClient") {}

Details

The signature is the contract. use takes a function that receives the raw module and an AbortSignal, returns a Promise<A>, and surfaces failures through FileClientError.


Implementing use

const makeFileClient = (fileModule: FileModule) =>
Layer.succeed(FileClient, {
use: <A>(fn: (fileModule: FileModule, signal: AbortSignal) => Promise<A>) =>
Effect.tryPromise({
try: (signal) => fn(fileModule, signal),
catch: (cause) => new FileClientError({ cause }),
}),
})

Details

Effect.tryPromise provides the AbortSignal linked to fiber interruption. When the fiber is interrupted, the signal aborts, and the underlying client (any client that respects AbortSignal) cancels its work.

When to Use

When the client has 10+ methods you might reach for, or when the surface evolves over time. For narrow services with two or three methods, prefer explicit wrappers.


Consuming use

// ┌─── Effect<number, FileClientError, FileClient>
// ▼
const copyFile = (from: string, to: string) =>
Effect.gen(function* () {
const fileClient = yield* FileClient
const contents = yield* fileClient.use((fs, signal) => fs.readFile(from, signal))
yield* fileClient.use((fs, signal) => fs.writeFile(to, contents, signal))
return contents.length
})

Callers never see the raw FileModule. They see only FileClient, and every error converges on FileClientError.


Adding Cross-Cutting Concerns

const makeFileClient = (fileModule: FileModule) =>
Layer.succeed(FileClient, {
use: <A>(fn: (fileModule: FileModule, signal: AbortSignal) => Promise<A>) =>
Effect.tryPromise({
try: (signal) => fn(fileModule, signal),
catch: (cause) => new FileClientError({ cause }),
}).pipe(
Effect.timeout("5 seconds"),
Effect.mapError((cause) => new FileClientError({ cause })),
),
})

Every call gets the timeout. Add logging, retries, or rate limits the same way.


Common Pitfalls

1, Using use for a tiny surface. If you have three methods, write them. The use pattern earns its keep on broad clients, not narrow ones.

2, Mapping errors inside the callback. Let the service do it. The callback returns the raw Promise; the service catches and tags.

Duplicating that work in every callback defeats the point.

3, Ignoring the AbortSignal. Many client libraries respect { signal }. Pass it through, or interruption stops at the Effect boundary and the underlying request keeps running.

4, Constructing the client inside the layer body without acquireRelease. If the client owns a connection, attach a finalizer (step 46) so it closes on shutdown.


the lifecycle. - [ ] Consume `service.use(client => …)` from business code; the callback is the per-operation borrow.

See Also

  • Previous, 59-cli-patterns: composing CLIs from services.
  • Next, 39-ref: the simplest stateful primitive.
  • Related, 21-context-basics, 22-layers: the services this pattern plugs into.
  • Related, 57-http-client-patterns: a sibling pattern for HTTP.

Run the tests: pnpm test 55-use-pattern


The full test suite

The index.test.ts for this lesson, verbatim from steps/55-use-pattern/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import { Context, Data, Effect, Layer } from "effect"
// =============================================================================
// 34 - The `use` Pattern
// =============================================================================
//
// A service exposes a single `use(fn)` entry point. The callback receives the
// raw client + an AbortSignal; the service owns error mapping and lifetime.
class FileClientError extends Data.TaggedError("FileClientError")<{
readonly cause: unknown
}> {}
type FileModule = {
readonly readFile: (path: string, signal: AbortSignal) => Promise<string>
readonly writeFile: (path: string, contents: string, signal: AbortSignal) => Promise<void>
}
// @beat
// id: declare-tag
// shape: one-todo
// title: Declare a use-style service Tag
// teaches: A use-style service exposes a single use(fn) method that borrows the raw client and an AbortSignal.
// hint: use Context.Service with a single `use` method whose callback receives (fileModule, signal) and returns a Promise<A>
class FileClient extends /* @stub */ Context.Service<
FileClient,
{
readonly use: <A>(
fn: (fileModule: FileModule, signal: AbortSignal) => Promise<A>,
) => Effect.Effect<A, FileClientError>
}
>()("FileClient") /* @end */ {}
// @end-beat
// @beat
// id: implement-layer
// shape: one-todo
// title: Implement use with tryPromise
// teaches: Effect.tryPromise supplies an AbortSignal and maps thrown errors into a tagged service error.
// hint: Layer.succeed with use: (fn) => Effect.tryPromise({ try: (signal) => fn(fileModule, signal), catch: ... })
const makeFileClient = (fileModule: FileModule) =>
/* @stub */ Layer.succeed(FileClient, {
use: <A>(fn: (fileModule: FileModule, signal: AbortSignal) => Promise<A>) =>
Effect.tryPromise({
try: (signal) => fn(fileModule, signal),
catch: (cause) => new FileClientError({ cause }),
}),
}) /* @end */
// @end-beat
// @beat
// id: consume-use
// shape: one-todo
// title: Consume the borrowed client
// teaches: Callers borrow the raw client for one operation at a time through fileClient.use.
// hint: yield* FileClient then call fileClient.use((fs, signal) => fs.readFile(from, signal))
const copyFile = (from: string, to: string) =>
/* @stub */ Effect.gen(function* () {
const fileClient = yield* FileClient
const contents = yield* fileClient.use((fs, signal) => fs.readFile(from, signal))
yield* fileClient.use((fs, signal) => fs.writeFile(to, contents, signal))
return contents.length
}) /* @end */
// @end-beat
describe("34 - use pattern", () => {
describe("FileClient.use", () => {
it("borrows the underlying client without exposing it", async () => {
const writes: Record<string, string> = {}
const fileModule: FileModule = {
readFile: async () => "hello",
writeFile: async (path, contents) => {
writes[path] = contents
},
}
const bytes = await Effect.runPromise(
copyFile("a.txt", "b.txt").pipe(Effect.provide(makeFileClient(fileModule))),
)
expect(bytes).toBe(5)
expect(writes["b.txt"]).toBe("hello")
})
it("maps thrown Promise errors into the tagged service error", async () => {
const fileModule: FileModule = {
readFile: async () => {
throw new Error("permission denied")
},
writeFile: async () => undefined,
}
const exit = await Effect.runPromiseExit(
copyFile("a.txt", "b.txt").pipe(Effect.provide(makeFileClient(fileModule))),
)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
expect(exit.cause.reasons[0].error._tag).toBe("FileClientError")
}
})
})
})
← PreviousIncremental AdoptionNext →Redacted