Workers calling Workers
Sooner or later one Worker isn’t enough. You want a users Worker that
owns the user table, and an api Worker in front that calls it. In
forge-effect that call looks like a function call, and it’s checked like
one:
const user = yield* (yield* Users).get({ id }); // a User, or UserNotFound, or CallErrorNo URLs, no fetch, no parsing JSON by hand. It goes over Cloudflare’s
native RPC (a method call on a service binding), and both ends check what
crosses. You’ll build a small shop in example/my-shop: an api Worker
calling a users Worker. The contract and the users Worker are the
shop example’s,
word for word; your api is a simpler one than the shop’s.
Step 1: Write the contract
Section titled “Step 1: Write the contract”The shape of the call lives in a file of its own, schemas only. Both Workers import it; neither imports the other.
import { Schema } from 'effect';
import { Rpc, Worker } from '@forge-effect/infra';
export class User extends Schema.Class<User>('User')({ id: Schema.String, name: Schema.String, joined: Schema.Date }) {}
export class UserNotFound extends Schema.TaggedError<UserNotFound>()('UserNotFound', { id: Schema.String }) {}
export class Users extends Worker.Contract<Users>()('users', { get: { payload: { id: Schema.String }, success: User, error: UserNotFound }, add: { payload: { name: Schema.String }, success: User }, // Every user's name, a line each, as a byte stream workerd carries natively. names: { success: Rpc.Stream },}) {}Each method has up to three schemas:
payload: what the caller sends. A struct’s fields, as here, or any schema. Leave it out for a method that takes nothing (users.names()).success: what comes back. Leave it out and the method returns nothing.error: how it can fail, as tagged errors. Several are a union:Schema.Union([NotFound, Gone]). Leave it out and it never fails.
Rpc.Stream is a ReadableStream of bytes. It isn’t encoded at all:
workerd passes it through as it is, at any depth in a reply.
The contract’s name, users, matters: the Worker that implements it must
have the same name.
Step 2: Implement it
Section titled “Step 2: Implement it”The users Worker implements the contract over a D1 database:
import { Effect, Option } from 'effect';
import { D1, Worker } from '@forge-effect/infra';
import { User, UserNotFound, Users } from './users.contract.ts';
export class Db extends D1.Database<Db>()('users-db', { migrations: ['CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, joined INTEGER)'] }) {}
// … Row and toUser
export default Worker.make('users', { implements: Users.implement({ get: ({ id }) => Effect.gen(function* () { const db = yield* Db; const row = yield* db.first<Row>('SELECT id, name, joined FROM users WHERE id = ?', id); // Not there: the contract's own error. return yield* Option.match(row, { onNone: () => Effect.fail(new UserNotFound({ id })), onSome: (r) => Effect.succeed(toUser(r)) }); }).pipe(Effect.catchTag('D1Error', Effect.die)), // … add, and names, which returns a ReadableStream }),}).bind(Db);implement takes a handler per method, and the compiler holds each one to
the contract: the payload it gets is typed, it must return a User, and
it may fail only with UserNotFound. A database failure isn’t something
the contract promises, so Effect.catchTag('D1Error', Effect.die) turns
it into a defect.
Notice that users has no fetch. It serves only other Workers, so it
gets no public URL at all.
Step 3: Call it
Section titled “Step 3: Call it”The caller binds the contract, the same as it would bind a database, and
yield* Users gives it a client:
import { Effect } from 'effect';
import { Worker } from '@forge-effect/infra';
import { Users } from './users.contract.ts';
const fetch = Effect.gen(function* () { const request = yield* Worker.Request; const id = new URL(request.url).pathname.slice(1); const users = yield* Users; const user = yield* users.get({ id }); // a User, its `joined` a Date return Response.json(user);}).pipe( // The contract's own error, as its class: Effect.catchTag('UserNotFound', (e) => Effect.succeed(new Response(`no user ${e.id}\n`, { status: 404 }))), // Anything that went wrong on the way: Effect.catchTag('CallError', (e) => Effect.succeed(new Response(`${e.message}\n`, { status: 502 }))),);
export default Worker.make('api', { fetch }).bind(Users);users.get({ id }) is an Effect<User, UserNotFound | CallError>. The
User arrives as a real User, so joined is a Date again, and
UserNotFound arrives as its class, so catchTag works.
CallError is the one error every call can have. Its reason says what
happened:
reason |
What happened |
|---|---|
unreachable |
the callee couldn’t be reached, or threw |
payload |
what you sent didn’t encode with the contract |
rejected |
the callee wouldn’t take the payload (a newer caller, an older callee) |
reply |
the reply didn’t decode (an older caller, a newer callee) |
interrupted |
the callee was interrupted |
(Durable Objects add two more, unavailable and outdated; see
Durable Objects.)
A handler that dies arrives as a defect, so the api Worker answers 500
and logs it, as for any defect of its own.
Step 4: Put them in one app
Section titled “Step 4: Put them in one app”// Two Workers, each deployed from its own file: `api` calls `users`.
import { App } from '@forge-effect/infra';
import api from './api.ts';import users from './users.ts';
export default App.make('shop', { workers: [api, users] });Run it locally. infra dev runs every Worker of an app, wired with real
service bindings between them, as they’ll be deployed. Here’s the full
shop example running:
$ pnpm infra dev example/shop/app.tsexample/shop/app.ts, locally (state in .infra/dev, beside it): api http://api.localhost:8787/ users (no fetch handler: reached through its bindings) and http://127.0.0.1:8787/ goes to apiThe shop’s own api is an Effect HttpApi (more on that
below), so its routes are a little richer than
yours:
$ curl -X POST localhost:8787/users -H 'content-type: application/json' -d '{"name":"Ada"}'{"id":"42f2a801","name":"Ada","joined":"2026-10-17T02:08:46.168Z"}$ curl localhost:8787/users/nobody{"_tag":"UserNotFound","id":"nobody"}$ curl localhost:8787/usersAdaThat last one is names, a byte stream from users, passed through
api to your terminal without being read in between.
What doesn’t compile
Section titled “What doesn’t compile”Here’s where contracts earn their keep. Each of these is a type error, in your editor, before anything runs:
implementleaves out a method, or adds one the contract hasn’t.- A handler returns the wrong type, or fails with an error the contract doesn’t declare.
- An implementation in a Worker of another name.
- A caller that doesn’t bind the contract, or sends the wrong payload.
- An app with a Worker that calls a contract no Worker of the app implements.
Forget the binding in api.ts, and infra check says so:
$ pnpm infra check example/my-shop/app.tserror used-undeclared example/my-shop/api.ts:20:16 the Worker uses Users, which it doesn't bind fix: bind it: .bind(…, Users)Leave users out of the app:
error contract-unimplemented example/my-shop/app.ts:8:35 a Worker calls Users, which no Worker of the app implements fix: add the Worker that implements Users to the app's workersA Worker in the middle
Section titled “A Worker in the middle”A Worker can implement one contract and call another. Say orders places
an order after inventory reserves the stock, and both declare
OutOfStock:
export class OutOfStock extends Schema.TaggedError<OutOfStock>()('OutOfStock', { sku: Schema.String }) {}
export class Inventory extends Worker.Contract<Inventory>()('inventory', { reserve: { payload: { sku: Schema.String, qty: Schema.Int }, error: OutOfStock },}) {}
export class Order extends Schema.Class<Order>('Order')({ id: Schema.String, sku: Schema.String, qty: Schema.Int, placedAt: Schema.Date }) {}
export class Orders extends Worker.Contract<Orders>()('orders', { place: { payload: { sku: Schema.String, qty: Schema.Int }, success: Order, error: OutOfStock },}) {}export default Worker.make('orders', { implements: Orders.implement({ place: ({ sku, qty }) => Effect.gen(function* () { // Fails with OutOfStock, which `place` declares too, or a // CallError, which it doesn't: decide what that is here. yield* (yield* Inventory).reserve({ sku, qty }); return new Order({ id: crypto.randomUUID(), sku, qty, placedAt: new Date(yield* Clock.currentTimeMillis) }); }).pipe(Effect.catchTag('CallError', Effect.die)), }),}).bind(Inventory);OutOfStock passes straight through, because place declares it. But
CallError can’t leak out of a handler: place doesn’t declare it.
Leave out the catchTag and the compiler stops you (trimmed):
error type-error orders.ts:10:7 Type 'Effect<Order, CallError | OutOfStock, Inventory>' is not assignable to type 'Effect<Order, OutOfStock, any>' …That’s the compiler asking a real question: when inventory can’t be
reached, what should orders say? A defect, as here, or an error of its
own that you add to the contract.
One module per Worker
Section titled “One module per Worker”Each Worker deploys from the module that exports it, and its bundle holds
its own code and the contracts it imports. That’s why api.ts imports
users.contract.ts and never users.ts. Import a Worker’s module from
another Worker’s, even for a class like Db, and the app refuses to
load:
error worker-imports-worker example/my-shop/api.ts the Worker api's module api.ts imports users.ts, the Worker users's, so users's code would deploy with api; import its contract insteadThe same goes for a Worker defined in app.ts beside Workers in modules
of their own. Give every Worker its own file, put what’s shared in a
module of its own, and you’re fine.
Deploy order
Section titled “Deploy order”A service binding needs its Worker to exist, so each Worker deploys after
the Workers it calls, and teardown goes the other way. infra graph
lists the Workers in the order they deploy:
$ pnpm infra graph example/shop/app.tsshop-users (no handlers) serves users: get, add, names USERS_DB: d1 shop-users-dbshop-api (fetch) calls usersWorkers that call each other in a cycle can’t be ordered, so an app with
one is refused, naming the cycle (a → b → a).
Changing a contract without breaking anyone
Section titled “Changing a contract without breaking anyone”Here’s a subtle thing about deploys. users ships before api, so for a
little while the new users answers the old api. If the new
contract removed a method the old api calls, those calls fail until
api catches up.
forge-effect watches for this. Each ship records the contracts it
deployed, and infra plan compares yours with them. It flags what breaks
the callers already running:
- a method removed (“removed, and the deployed callers call it”),
- a payload field newly required (they don’t send it),
- a result field removed, or of another type (they can’t decode it),
- a new error (they can’t decode that either).
Adding a method, an optional payload field or a result field is fine.
A change it can’t judge, such as a new check on a string, plan says so
rather than guessing.
infra ship stops on a breaking change. The kind fix is to do it in two
ships: widen first (add the new method or optional field, keep the old),
then narrow once the callers have moved. If you’ve thought it through and
the callers can fail for a minute, --allow-breaking ships anyway.
An HttpApi in front
Section titled “An HttpApi in front”The real shop’s api is an Effect HttpApi, served with Http.app, and
its routes call users. Its add endpoint even reuses the contract’s own
payload schema, so the HTTP API takes exactly what users takes:
HttpApiEndpoint.post('add', '/users', { payload: Users.schemas.add.payload, success: User.pipe(HttpApiSchema.status(201)) }),Users.schemas.get.payload and friends are there for any untyped input
you want to check before a call. See
the shop’s api.ts
for the whole thing, and Websites for HTTP routes.
Calling another app
Section titled “Calling another app”Not everything lives in one app. Maybe accounts is its own app, with
its own team, and shop calls it. Bind the contract with from, naming
the app:
// shop's api.ts: Users is implemented by the app `accounts`export default Worker.make('api', { fetch }).bind(Users.from('accounts'));// its previews call accounts' production insteadUsers.from('accounts', { env: 'production' });// a Worker the tool didn't make, by its name in the accountUsers.at('legacy-users');The contract is the same class both apps import (from a shared package, or a shared folder), so the calls are typed and checked exactly as within one app. The app doesn’t need a Worker implementing it.
The media example
is three apps that do this for real. Its site app lists media’s
library through media’s Library contract:
const fetch = Effect.gen(function* () { const request = yield* Worker.Request; const library = yield* Library; // … one file's details, or the whole list: const files = yield* library.list(); // … a page of them}).pipe(Effect.catchTag('CallError', (e) => Effect.succeed(new Response(`the library didn't answer: ${e.message}\n`, { status: 502 }))));
export default Worker.make('web', { fetch }).bind(Library.from('media'));Across apps, nothing orders the deploys for you, so the checks do:
- Before
siteships, itsplanandshipcheck thatmediahas deployed the contract in the same environment. If not, it stops withexternal-missingand says what to do: shipmediafirst (with the same--env), or use its production. They also check that the deployed contract takessite’s calls (external-contract-mismatch). A Worker bound withatis checked to exist; its contract is taken on trust. mediaknows who calls it. Each ship records what it calls in other apps, somedia’splanlists its callers (marked←), a breaking contract change names the apps it breaks, and adestroy,pruneor ship that would take the Worker away stops while another app calls it (entrypoint-still-bound), unless--allow-breaking.infra devdoesn’t run the other app. It says which bindings point outside, and a call through one fails.
Sharing resources across apps
Section titled “Sharing resources across apps”Calls aren’t the only thing apps share. In the media example, media
owns an R2 bucket and a queue, and the catalog app reads that bucket
and consumes that queue. The classes are declared once, in a module each
app imports:
/** The uploaded files, by key. */export class Uploads extends R2.Bucket<Uploads>()('uploads') {}media binds Uploads as its own. catalog binds it with from:
const index = Uploaded.from('media').consume(({ key, size, type, at }) => Effect.gen(function* () { const file = yield* (yield* Uploads).get(key); // … fingerprint it, and keep it in catalog's own index }),);
export default Worker.make('indexer', { fetch, queues: [index] }).bind(Index, Uploads.from('media'));media makes the bucket, and deletes it; catalog only binds it, in the
same environment unless { env } names another. The same checks apply:
catalog can’t ship before media has made it (external-missing), and
media can’t destroy or prune it while catalog uses it
(resource-still-bound), unless --allow-breaking. A queue’s message
schema is checked across the two apps too.
A resource made outside forge-effect entirely is bound by its name in the account, in every environment, and is never made or deleted:
Files.external('old-files');KV namespaces, D1 databases, R2 buckets and queues all work this way. In
infra dev, a shared resource gets a local one of its own, apart from the
owner’s. And if nothing is the natural owner, make an app of resources
only (declarations, no Worker code) and let it own them.
Handing out a stub
Section titled “Handing out a stub”Sometimes a Worker should be able to call something without binding it.
Rpc.Stub(Contract) in a contract is a key to one Worker, or one
Durable Object, that crosses the call as
Cloudflare’s own stub. Here a lobby hands out chat rooms:
export class Lobby extends Worker.Contract<Lobby>()('lobby', { room: { payload: { name: Schema.String }, success: Rpc.Stub(Room) }, relay: { payload: { room: Rpc.Stub(Room), text: Schema.String }, success: Message },}) {}export default Worker.make('lobby', { implements: Lobby.implement({ room: ({ name }) => Effect.map(Room, (rooms) => rooms.byName(name)), // A stub it was given, called as any client of the room. relay: ({ room, text }) => Effect.orDie(room.post({ user: 'relay', text })), }),}).bind(Room);The front Worker binds only Lobby, and still talks to a room, typed:
const room = yield* lobby.room({ name: name ?? 'lobby' });yield* room.post({ user: 'front', text: url.searchParams.get('text') ?? '' });Cloudflare’s rules come with it: a stub lasts as long as the request it came with, so store an address, never a stub; and a stub can’t open a WebSocket, so a client connects through the Worker that binds the object.
Testing
Section titled “Testing”You don’t need two Workers running to test one. Users.memory stands in
for the callee with handlers typed by the contract:
import assert from 'node:assert/strict';import { test } from 'node:test';
import { Effect, Layer } from 'effect';
import { Worker } from '@forge-effect/infra';
import api from '../example/my-shop/api.ts';import { User, UserNotFound, Users } from '../example/my-shop/users.contract.ts';
const get = (path: string, layer: Layer.Layer<Users>) => Effect.runPromise(Worker.handle(api, new Request(`https://shop.dev${path}`)).pipe(Effect.provide(layer)));
test('api answers with the user, or a 404', async () => { // A stand-in for `users`: handlers typed by the contract. const layer = Users.memory({ get: ({ id }) => (id === 'ada' ? Effect.succeed(new User({ id, name: 'Ada', joined: new Date(0) })) : Effect.fail(new UserNotFound({ id }))), add: () => Effect.die('not here'), names: () => Effect.die('not here'), }); assert.equal((await (await get('/ada', layer)).json()).name, 'Ada'); assert.equal((await get('/bob', layer)).status, 404);});Or wire in the real callee with Worker.connect, and its database on
SQLite:
const layer = Worker.connect(Users, users).pipe(Layer.provide(Layer.succeed(Db, D1.client(sqliteBinding(Db)))));(D1 comes from @forge-effect/infra, sqliteBinding from
@forge-effect/infra/testing.) Either way, payloads and replies are
cloned as workerd clones them, so a test passes only with what a real
call can carry. A Users.memory stands in for a contract from another
app too, Users.from('accounts') included.
How it works, briefly
Section titled “How it works, briefly”- The callee deploys as a
WorkerEntrypointclass with a real method for each contract method, which is what workerd calls through the service binding. With nofetchhandler, it answers HTTP with a 404, as Cloudflare won’t take a Worker whose only handlers are RPC methods. - On the wire, each value is its schema’s JSON form, which workerd’s
structured clone always carries, so an
Option, aMapor aBigIntarrives intact. The callee answers with a tagged reply (a success, a declared failure, or “I won’t take that payload”), and the caller decodes it. - A contract is bound, never provided by a Layer. Only a binding
deploys the service binding, so a Layer that provides
Usersdoesn’t count forbind.
Good to know
Section titled “Good to know”- One Worker per contract: two Workers implementing the same one are refused when the app loads.
- Calls are checked in workerd (the shop’s own bundles,
apicallingusersover a service binding), and live on a real account across apps (the media apps, end to end). - Promise pipelining (calling a method on a result before it arrives) isn’t done yet.
- A method can’t be named like something every object has (
toString) or like a Worker’s own handlers (fetch).
- Durable Objects and WebSockets: the same contracts, with an address, and a socket.
- Plan and ship: what a deploy of several Workers looks like.