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 namepolicy: { Action: ["dynamodb:GetItem"], Resource: [table.arn] },environment: { JOBS_TABLE: table.name },// handler: hope the two above agreeconst 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.
Permissions
Section titled “Permissions”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);Configuration
Section titled “Configuration”The client needs the table’s physical name. The binding serializes the
Jobs.tableName Output into the
Function’s environment:
Jobs_tableName=Jobs-a1b2c3getItem 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.
The client
Section titled “The client”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)),);A contract and a Layer
Section titled “A contract and a Layer”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.
Plan-time invokes
Section titled “Plan-time invokes”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 matchesimageId: 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.
Event Sources and Sinks
Section titled “Event Sources and Sinks”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.
How it works under the hood
Section titled “How it works under the hood”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 clientWhen 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.
Where next
Section titled “Where next”- 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.