Skip to content

Layers

A Binding connects one Resource to a Worker or Lambda (Functions & Servers). A Layer is the next abstraction up: a unit of encapsulated infrastructure. It owns whatever resources and bindings it needs, returns a typed implementation of a service interface, and hides everything behind that interface.

This is where portability lives: a Worker written against an abstract JobService doesn’t know whether its underlying storage is a KV namespace, a DynamoDB table, or an in-memory map — it depends on the service, and a Layer wires up the rest.

Say you want a Worker that serves jobs from a KV namespace. The straightforward thing is to bind the KV in the Worker’s init closure and call it from fetch:

import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
return {
fetch: Effect.gen(function* () {
const job = yield* kv.get<Job>("job-1", "json");
return HttpServerResponse.json(job);
}),
};
}).pipe(Effect.provide(Cloudflare.KV.ReadWriteNamespaceBinding)),
);

This works, but the handler is welded to KV. The fetch body mentions kv.get, knows the value shape comes back as "json", and propagates NamespaceError. Moving the data to DynamoDB or swapping in an in-memory fake for tests means rewriting fetch — not just the storage wiring.

A Layer is the answer.

A service is a Context.Service — a typed Tag that names a capability without saying how it’s provided:

import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Alchemy from "alchemy";
export class JobService extends Context.Service<
JobService,
{
getJob(id: string): Effect.Effect<Job, JobError, Alchemy.RuntimeContext>;
}
>()("JobService") {}

A consumer writes yield* JobService and gets the typed object — nothing else. The signature deliberately mentions Alchemy.RuntimeContext (more on this below) but says nothing about KV, DynamoDB, or any specific cloud primitive.

A Layer is the implementation side of a service. It declares the resources it needs, wires up bindings, and returns the typed value:

import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
export const JobServiceKV = Layer.effect(
JobService,
Effect.gen(function* () {
const MyKV = yield* Cloudflare.KV.Namespace("MyKV");
const kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
return {
getJob: Effect.fn(function* (id: string) {
return yield* kv.get<Job>(id, "json");
}),
};
}),
);

Three things happen in one expression:

  1. The KV namespace is a real resource. It joins the Stack, goes through plan/create/update like anything else.
  2. The binding is wiredKV.ReadWriteNamespace(MyKV) attaches the KV namespace to whichever Worker eventually consumes this Layer.
  3. A typed JobService is returned. Callers see only getJob.

A Worker that wants jobs depends on JobService and provides a Layer to satisfy it:

export default Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const jobs = yield* JobService;
return {
fetch: Effect.gen(function* () {
return yield* jobs.getJob("job-1");
}),
};
}).pipe(
Effect.provide(
JobServiceKV.pipe(Layer.provide(Cloudflare.KV.ReadWriteNamespaceBinding)),
),
),
);

The Layer.provide satisfies JobServiceKV’s own dependency on the KV runtime binding privately, so the handler sees only JobService. Swapping the implementation is a one-line change:

JobServiceKV.pipe(Layer.provide(Cloudflare.KV.ReadWriteNamespaceBinding)),
JobServiceDynamo.pipe(Layer.provide(AWS.DynamoDB.GetItemHttp)),

The next deploy tears down the KV namespace and creates whatever JobServiceDynamo declares. The handler is untouched.

The same duality runs one level down: alchemy’s own Bindings follow the same Tag/Layer split — ReadWriteNamespace satisfied by ReadWriteNamespaceBinding or ReadWriteNamespaceHttp — the same one-line diff, enforced by the same types (a contract and a Layer). Building with Layers composes both levels in one Worker.

The handler that called kv.get directly had a fetch body typed roughly:

Effect.Effect<Response, NamespaceError, Alchemy.RuntimeContext>

The KV-specific error and the implicit dependence on a Cloudflare binding both leak into the handler’s signature. The Layer-wrapped equivalent collapses to:

Effect.Effect<Response, JobError, Alchemy.RuntimeContext>

The Cloudflare-specific surface is gone — absorbed by the Layer:

export const ReadWriteNamespaceBinding = Layer.effect(
ReadWriteNamespace,
Effect.gen(function* () {
const env = yield* WorkerEnvironment; // ← required here, once
// ...returns a client that closes over env
}),
);

The Worker that consumes ReadWriteNamespaceBinding satisfies WorkerEnvironment in one place; downstream callers see only RuntimeContext. Try the swap on a Cloudflare Worker:

.pipe(Effect.provide(JobServiceKV)) // requires WorkerEnvironment ✓
.pipe(Effect.provide(JobServiceDynamo)) // requires AWS Credentials | Region ✗

The DynamoDB-backed Layer requires AWS Credentials and Region, which a Cloudflare Worker’s constructor can’t satisfy — the program won’t type-check. Functions and Layers fit together by type, not by convention.

As Phases covered, the init closure runs at plantime and at cold start; the runtime closure runs only inside the deployed handler. Bindings live in init; the actual cloud calls live in runtime.

Alchemy.RuntimeContext is the Effect service that exists only inside the runtime closure. It is not provided at plantime, cold start init, or anywhere else. So an Effect like:

kv.get(...): Effect.Effect<Job | null, NamespaceError, Alchemy.RuntimeContext>

is one the type system guarantees can only run in the runtime phase. Move that call up into the init closure and the type checker rejects it — RuntimeContext is not satisfied there.

You can think of it as a “color” in the sense of colored functions:

runtime function getJob(id: string): Job

TypeScript doesn’t have keyword-level coloring, so Alchemy encodes the color as an Effect requirement. The compiler enforces the init/runtime boundary for you.

A typical app stack mixes several Layers, one per capability:

.pipe(
Effect.provide(Layer.mergeAll(
JobServiceKV, // provides JobService
BetterAuthD1, // provides BetterAuth
RateLimiterDurable, // provides RateLimiter
)),
)

Each Layer brings its own resources into the Stack and its own typed service into scope. Consumers stay declarative and ignorant of the underlying primitives.

CombinatorUse it for
Layer.mergeAll(a, b)Provide multiple independent services
Layer.provideMergeA Layer that supplies and exposes a service
Layer.provideSatisfy one Layer’s dependencies privately from another

Because Layers are normal Effect Layers, the patterns scale all the way out:

  • Distribute on npm. A service is just a TypeScript module exporting a Context.Service and one or more Layers. Publish @org/jobs, npm install it, Effect.provide it.
  • Substitute for tests. Provide a JobServiceMemory Layer backed by a Map; the Worker under test never knows.
  • Migrate without rewrites. Moving from KV to DynamoDB is a Layer swap, not a Worker rewrite.
  • Type-safe portability. Cloud-specific requirements are confined to Layer construction; the consumer-facing surface speaks only RuntimeContext.

Infrastructure becomes a value you can encapsulate, name, and substitute — like any other module.

  • Building with Layers — build a JobService Layer end to end, then swap KV for R2.
  • Circular Bindings — the Tag/Layer split applied to two services that reference each other.
  • Bindings — the deploy-time mechanics Layers are built on.