Skip to content

Bindings

A Binding connects a Resource to a Worker, Lambda Function, Container, or Server — any Runtime. Inside the Runtime’s constructor, you yield the resource and get back a typed client:

import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export const Jobs = AWS.DynamoDB.Table("Jobs", { partitionKey: "pk" });
export default AWS.Lambda.Function(
"Api",
{ main: import.meta.url, functionUrl: true },
Effect.gen(function* () {
const getItem = yield* AWS.DynamoDB.GetItem(Jobs);
return {
fetch: Effect.gen(function* () {
const job = yield* getItem({ Key: { pk: { S: "job-1" } } });
return HttpServerResponse.json(job.Item);
}),
};
}).pipe(Effect.provide(AWS.DynamoDB.GetItemHttp)),
);

yield* GetItem(Jobs) is the binding. getItem is the client. GetItemHttp is the implementation.

A Function that reads a table needs three things: permission to call the API, the table’s name at runtime, and a client to call it with. In traditional IaC those live in three places. A policy in the infrastructure code, an environment variable wired between the two, and an SDK call in the handler that reads it back:

// infrastructure: grant access and pass the name
policy: { Action: ["dynamodb:GetItem"], Resource: [table.arn] },
environment: { JOBS_TABLE: table.name },
// handler: hope the two above agree
const client = new DynamoDBClient();
await client.send(
new GetItemCommand({ TableName: process.env.JOBS_TABLE, Key }),
);

Nothing checks that they agree. Rename the table, widen the policy, forget the env var, and you find out at runtime. The binding is all three. Let’s follow what that one line produces.

At deploy time the binding attaches an IAM statement to the Function’s role. It grants the one action the client can make, on the one table it was bound to:

{
"Effect": "Allow",
"Action": ["dynamodb:GetItem"],
"Resource": ["arn:aws:dynamodb:us-east-1:123456789012:table/Jobs-a1b2c3"]
}

Least privilege falls out of the shape. Bind PutItem as well and the role gains a second statement for dynamodb:PutItem, nothing more:

const getItem = yield* AWS.DynamoDB.GetItem(Jobs);
const putItem = yield* AWS.DynamoDB.PutItem(Jobs);

Bind several tables at once and the statement lists each of their ARNs:

const batchGet = yield* AWS.DynamoDB.BatchGetItem(Jobs, Audit);

The client needs the table’s physical name. The binding serializes the Jobs.tableName Output into the Function’s environment:

Terminal window
Jobs_tableName=Jobs-a1b2c3

getItem reads it back and fills in TableName on every request. You never touch process.env, and the name can’t drift from the policy. Both come from the same Jobs declaration.

getItem is the DynamoDB operation with the table already filled in. It returns an Effect with a typed error channel, so retries, timeouts, and error handling compose onto it:

const job = yield* getItem({ Key: { pk: { S: "job-1" } } }).pipe(
Effect.retry({ times: 3, schedule: Schedule.exponential("100 millis") }),
Effect.timeout("5 seconds"),
Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
);

GetItem names the capability. GetItemHttp decides how it’s satisfied. Cloudflare shows why that split matters. A Worker binds an R2 bucket with the same shape:

import * as Cloudflare from "alchemy/Cloudflare";
export const Uploads = Cloudflare.R2.Bucket("Uploads");
export default Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const bucket = yield* Cloudflare.R2.ReadWriteBucket(Uploads);
return {
fetch: Effect.gen(function* () {
yield* bucket.put("hello.txt", "world");
return HttpServerResponse.text("ok");
}),
};
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
);

There is no IAM on Cloudflare. ReadWriteBucketBinding registers a native r2_bucket binding on the Worker instead, and the access level is in the name: ReadBucket, WriteBucket, or ReadWriteBucket.

The contract is a Binding.Service. It names the capability and the client it returns, and nothing about the platform:

export interface ReadWriteBucket extends Binding.Service<
ReadWriteBucket,
"Cloudflare.R2.ReadWriteBucket",
(bucket: Bucket) => Effect.Effect<ReadWriteBucketClient>
> {}

So the same contract can be satisfied a different way. Swap the Layer and bucket calls R2 over HTTP with a scoped API token minted at deploy time:

}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)),

The handler doesn’t change. And a Layer that needs a platform the host can’t supply is a compile error:

export default AWS.Lambda.Function(
"Api",
{ main: import.meta.url },
handler.pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
// ✗ Type error: the Layer requires `WorkerEnvironment` and `Worker`,
// which a Lambda Function can't provide
);

Layers takes this split one level up, so your own services can carry their bindings with them.

A binding can also run before anything is deployed. Terraform calls this a data source, Pulumi an invoke. Call it directly and you get back an Output to pass into resource props:

// Output<ec2.Image | undefined>
const image = AWS.EC2.getAmi({ owners: ["amazon"], name: ["al2023-ami-2023.*"] });
// a helper built on it. Output<string>, fails when nothing matches
imageId: AWS.EC2.amazonLinux2023(),

The same capability bound inside a Function with yield* AWS.EC2.GetAmi(...) still grants its IAM and runs at runtime. One contract, both phases.

An Event Source is a Binding that runs an Effect or Stream in the background, whenever something happens on the resource:

yield* AWS.SQS.consumeQueueMessages(Inbound, (records) =>
Stream.runForEach(records, (record) => Effect.log(record.body)),
);

A Sink is the converse, a Binding you sink a Stream into:

const sink = yield* AWS.SQS.QueueSink(Outbound);

Both generate their permissions and configuration the same way.

A binding declaration says nothing about how it is implemented. The Layer you provide decides that:

Effect.provide(Cloudflare.R2.ReadWriteBucketHttp)

Inside that Layer, the deploy-time work sits behind a guard. The Construction phase runs twice, and the guard is what tells the two runs apart:

if (!globalThis.__ALCHEMY_RUNTIME__) {
// deploy time only: register IAM / native bindings / env on the host
yield* host.bind`${resource}`(/* … */);
}
// always: return the typed runtime client

When Alchemy bundles the Runtime it defines that global as true, so the bundler drops the whole branch. Only the client ships:

// folded into every bundle as a rolldown define
"globalThis.__ALCHEMY_RUNTIME__": "true"

The Resource Provider, the code that actually creates the bucket or the table, is never imported by the binding at all. Declaring a resource only records a requirement for its Provider, and that requirement is satisfied once, at the Stack:

export default Alchemy.Stack(
"MyApp",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
yield* Api;
}),
);

This is what lets you declare resources and bindings inside runtime code with little to no effect on bundle size. The heavy infrastructure code stays at the Stack, and the guard erases the rest. Phases covers the guard in depth.

The granularity pays off twice. Every binding has its own Layer, and each Layer carries exactly one client and registers exactly one grant. Provide the two you use and you get two clients in the bundle and two statements on the role, nothing more:

Effect.provide(
Layer.mergeAll(
AWS.DynamoDB.GetItemHttp, // ships getItem, grants dynamodb:GetItem
AWS.DynamoDB.PutItemHttp, // ships putItem, grants dynamodb:PutItem
),
)

The same choice that keeps the bundle small keeps the permissions and environment minimal.

  • Layers — hide bindings behind a service interface. Next page.
  • Event Sources — bindings that trigger your Function.
  • Sinks — bindings you write Streams into.
  • Circular Bindings — two Functions that bind each other.