Skip to content

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 CallError

No 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.

The shape of the call lives in a file of its own, schemas only. Both Workers import it; neither imports the other.

example/my-shop/users.contract.ts
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.

The users Worker implements the contract over a D1 database:

example/my-shop/users.ts
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.

The caller binds the contract, the same as it would bind a database, and yield* Users gives it a client:

example/my-shop/api.ts
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.

example/my-shop/app.ts
// 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.ts
example/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 api

The 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/users
Ada

That last one is names, a byte stream from users, passed through api to your terminal without being read in between.

Here’s where contracts earn their keep. Each of these is a type error, in your editor, before anything runs:

  • implement leaves 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.ts
error 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 workers

A Worker can implement one contract and call another. Say orders places an order after inventory reserves the stock, and both declare OutOfStock:

orders.contract.ts
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 },
}) {}
orders.ts
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.

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 instead

The 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.

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.ts
shop-users (no handlers)
serves users: get, add, names
USERS_DB: d1 shop-users-db
shop-api (fetch)
calls users

Workers 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.

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:

example/shop/shop.api.ts
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.

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 instead
Users.from('accounts', { env: 'production' });
// a Worker the tool didn't make, by its name in the account
Users.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:

example/media/site/web.ts
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 site ships, its plan and ship check that media has deployed the contract in the same environment. If not, it stops with external-missing and says what to do: ship media first (with the same --env), or use its production. They also check that the deployed contract takes site’s calls (external-contract-mismatch). A Worker bound with at is checked to exist; its contract is taken on trust.
  • media knows who calls it. Each ship records what it calls in other apps, so media’s plan lists its callers (marked ←), a breaking contract change names the apps it breaks, and a destroy, prune or ship that would take the Worker away stops while another app calls it (entrypoint-still-bound), unless --allow-breaking.
  • infra dev doesn’t run the other app. It says which bindings point outside, and a call through one fails.

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:

example/media/shared.ts
/** The uploaded files, by key. */
export class Uploads extends R2.Bucket<Uploads>()('uploads') {}

media binds Uploads as its own. catalog binds it with from:

example/media/catalog/indexer.ts
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.

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:

test/fixtures/lobby/lobby.contract.ts
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 },
}) {}
test/fixtures/lobby/lobby.ts
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:

test/fixtures/lobby/front.ts
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.

You don’t need two Workers running to test one. Users.memory stands in for the callee with handlers typed by the contract:

test/api.test.ts
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:

test/api.test.ts
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.

  • The callee deploys as a WorkerEntrypoint class with a real method for each contract method, which is what workerd calls through the service binding. With no fetch handler, 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, a Map or a BigInt arrives 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 Users doesn’t count for bind.
  • 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, api calling users over 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).