Skip to content

Connect Services

Each Service is its own Fly App. One Service calls another by binding it: Fly.bindService(Users) returns a typed client for the methods Users returns, and the calls travel over Fly’s private network to Users’s privateUrl.

Two things decide who can call a Service:

  • The network. Every App in the organization joins Fly’s default private network unless told otherwise. Set network: yield* Fly.stackNetwork on each Service to put a stack’s Services on a network unique to its stack and stage. Apps on any other network cannot resolve them.
  • The binding. Each Service has a caller token. Only Services that bind it receive the token, so only they can call its methods.

This guide builds the fly-microservices example: a private Users Service, a private Orders Service that calls Users, and a public Gateway that calls both.

Users returns two methods, list and get. It returns no fetch; bound callers call the methods directly.

src/users.ts
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
export interface User {
id: string;
name: string;
}
export const USERS: User[] = [
{ id: "u1", name: "Ada" },
{ id: "u2", name: "Grace" },
];
export default class Users extends Fly.Service<Users>()(
"Users",
Effect.gen(function* () {
return {
main: import.meta.url,
public: false,
};
}),
Effect.gen(function* () {
return {
list: () => Effect.succeed(USERS),
get: (id: string) => Effect.succeed(USERS.find((user) => user.id === id)),
};
}),
) {}

public: false gives the Service’s App a private Flycast address and no internet address. The props are an Effect so the next step can yield* inside them.

Add network so only this stage’s Services can reach Users:

Effect.gen(function* () {
return {
main: import.meta.url,
public: false,
network: yield* Fly.stackNetwork,
};
}),

Fly.stackNetwork yields a network name built from the stack and stage, such as flymicroservices-dev. Fly creates the network with the first App that names it.

Orders is a second private Service on the same network:

src/orders.ts
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
const ORDERS = [
{ id: "o1", userId: "u1", item: "keyboard" },
{ id: "o2", userId: "u2", item: "monitor" },
];
export default class Orders extends Fly.Service<Orders>()(
"Orders",
Effect.gen(function* () {
return {
main: import.meta.url,
public: false,
network: yield* Fly.stackNetwork,
};
}),
Effect.gen(function* () {
return {};
}),
) {}

Bind Users in the Orders program:

src/orders.ts
import Users from "./users.ts";
// ...
Effect.gen(function* () {
const users = yield* Fly.bindService(Users);
return {};
}),

users is a typed client with list and get. Binding also makes Orders deploy after Users and hands Orders the address and caller token of Users.

Return a list method that looks up each order’s user:

Effect.gen(function* () {
const users = yield* Fly.bindService(Users);
return {};
return {
list: () =>
Effect.forEach(ORDERS, (order) =>
users
.get(order.userId)
.pipe(Effect.map((user) => ({ ...order, user }))),
),
};
}),

users.get(order.userId) is a call over the private network. The result is typed as User | undefined, the return type of get in Users.

Gateway joins the same network and leaves out public: false, so it is public:

src/gateway.ts
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
export default class Gateway extends Fly.Service<Gateway>()(
"Gateway",
Effect.gen(function* () {
return {
main: import.meta.url,
network: yield* Fly.stackNetwork,
};
}),
Effect.gen(function* () {
return {};
}),
) {}

A public Service on the network still serves url, so Gateway is the stack’s single entry point from the internet.

src/gateway.ts
import Orders from "./orders.ts";
import Users from "./users.ts";
// ...
Effect.gen(function* () {
const users = yield* Fly.bindService(Users);
const orders = yield* Fly.bindService(Orders);
return {};
}),

orders exposes the list method Orders returns.

Call the bound methods from fetch:

src/gateway.ts
import { HttpServerRequest } from "effect/http/HttpServerRequest";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
// ...
Effect.gen(function* () {
const users = yield* Fly.bindService(Users);
const orders = yield* Fly.bindService(Orders);
return {};
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const path = new URL(request.url, "http://gateway").pathname;
if (path === "/users") {
return yield* HttpServerResponse.json(yield* users.list());
}
if (path === "/orders") {
return yield* HttpServerResponse.json(yield* orders.list());
}
return HttpServerResponse.text("try /users or /orders");
}).pipe(Effect.orDie),
};
}),

Yield the three Services and return their URLs:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
import Gateway from "./src/gateway.ts";
import Orders from "./src/orders.ts";
import Users from "./src/users.ts";
export default Alchemy.Stack(
"FlyMicroservices",
{ providers: Fly.providers(), state: Alchemy.localState() },
Effect.gen(function* () {
const users = yield* Users;
const orders = yield* Orders;
const gateway = yield* Gateway;
return {
url: gateway.url,
network: gateway.network,
usersPrivateUrl: users.privateUrl,
ordersPrivateUrl: orders.privateUrl,
};
}),
);
Terminal window
alchemy deploy

Alchemy deploys Users, then Orders, then Gateway, and prints the outputs:

url: "https://flymicroservices-gateway-dev-a1b2c3d4.fly.dev"
network: "flymicroservices-dev"
usersPrivateUrl: "http://flymicroservices-users-dev-e5f6a7b8.flycast"
ordersPrivateUrl: "http://flymicroservices-orders-dev-c9d0e1f2.flycast"
Terminal window
curl https://flymicroservices-gateway-dev-a1b2c3d4.fly.dev/users
# → [{"id":"u1","name":"Ada"},{"id":"u2","name":"Grace"}]
curl https://flymicroservices-gateway-dev-a1b2c3d4.fly.dev/orders
# → [{"id":"o1","userId":"u1","item":"keyboard","user":{"id":"u1","name":"Ada"}}, ...]

The second request crosses two private calls: Gateway → Orders → Users.

Fly.bindService(Users) makes the caller depend on Users. Orders binds Users, and Gateway binds both, so Alchemy deploys Users, then Orders, then Gateway. The caller’s deploy writes the bound Service’s private address and caller token onto the caller’s Machines; the client reads them at runtime.

The order of yield* in the Stack does not matter. The bindings decide it.

Service url privateUrl
Users undefined http://{users-app}.flycast
Orders undefined http://{orders-app}.flycast
Gateway https://{gateway-app}.fly.dev http://{gateway-app}.flycast:7780

url is the internet endpoint and is set only for a public Service. privateUrl is where bound callers reach the Service. A private Service serves plain HTTP on port 80, so its privateUrl has no port. A public Service’s default ports are an HTTPS redirect (80) and HTTPS (443), so Alchemy publishes an extra plain-HTTP port for bound callers, bindingPort (default 7780).

You rarely use privateUrl directly. Fly.bindService resolves it for you.

A method call from Orders to Users is accepted only when all of these hold:

  • Same network. Users is on the stack’s network, so only Apps on that network can resolve {users-app}.flycast. Binding a Service on another network fails the caller’s deploy with Fly.ServiceUnreachable, before the caller’s App is created.
  • Caller token. Each Service has its own caller token, generated by Alchemy. A Service receives the token only for the Services it binds, and sends it with every method call. A Service on the same network that did not bind Users gets 401 from its methods.
  • Fly-Src signature. Fly’s proxy signs every request that arrives over Flycast with an ed25519 Fly-Src header naming the calling App and organization. Users verifies the signature with Fly’s public key and checks the caller is in the same organization. A request from the internet carries no valid signature, so a public Service’s methods answer 401 to it, and the extra bindingPort answers only private-network requests.

Fly issues no certificate for .flycast, so calls are plain HTTP. They travel inside Fly’s private network, which Fly encrypts with WireGuard.

An App that is not on the stack’s network cannot resolve the private names. From a Machine in an App on the org’s default network:

Terminal window
wget -qO- http://flymicroservices-users-dev-e5f6a7b8.flycast
# wget: bad address 'flymicroservices-users-dev-e5f6a7b8.flycast'
wget -qO- http://flymicroservices-users-dev-e5f6a7b8.internal
# wget: bad address 'flymicroservices-users-dev-e5f6a7b8.internal'

Other stages of the same stack get their own Fly.stackNetwork, so dev and prod Services cannot call each other either.

A bound client also forwards requests to the Service’s fetch handler. A relative URL resolves against the Service’s privateUrl:

import * as HttpClientRequest from "effect/http/HttpClientRequest";
const users = yield* Fly.bindService(Users);
const response = yield* users.fetch(HttpClientRequest.get("/hello"));
const text = yield* response.text;

Use it for a Service that serves HTTP routes, such as a framework server. These requests carry no caller token; see the caution above.

A method can return a Stream. The bound client returns the same Stream, delivered element by element:

// in Users
streamAll: () => Stream.fromIterable(USERS),
// in the caller
const all = yield* users.streamAll().pipe(Stream.runCollect);

Fly.bindEndpoint binds a single published port of a Service, for a port other than its main HTTP port. Publish the port on the target. Here both ports forward to the Service’s fetch on 3000, which reads Fly’s fly-forwarded-port header to tell them apart:

services: [
{ protocol: "tcp", internalPort: 3000, ports: [{ port: 80, handlers: ["http"] }] },
{ protocol: "tcp", internalPort: 3000, ports: [{ port: 9000, handlers: ["http"] }] },
],

Then bind it from the caller:

const admin = yield* Fly.bindEndpoint(Users, { port: 9000 });
const stats = yield* admin.client.get("/stats");

admin.client is an HttpClient whose relative URLs go to http://{users-app}.flycast:9000. For a raw TCP port, open your own connection to yield* admin.host ({users-app}.flycast) and admin.port. yield* admin.url is the full plain-HTTP URL.

bindEndpoint sends no caller token. It relies on the network and whatever the port itself checks.

A class can only bind a Service that already exists as a value. For two Services that bind each other, declare each as a tag class with the shape of its methods:

src/services.ts
interface Named {
name: () => Effect.Effect<string>;
}
export class Ping extends Fly.Service<Ping, Named>()("Ping") {}
export class Pong extends Fly.Service<Pong, Named>()("Pong") {}

Implement each in its own file with .make, binding the other. The file’s default export is the implementation the Machine runs:

src/ping.ts
import { Ping, Pong } from "./services.ts";
export default Ping.make(
{ main: import.meta.url },
Effect.gen(function* () {
const pong = yield* Fly.bindService(Pong);
return {
name: () => Effect.succeed("ping"),
fetch: Effect.gen(function* () {
return HttpServerResponse.text(`ping hears ${yield* pong.name()}`);
}).pipe(Effect.orDie),
};
}),
);

src/pong.ts is the same with the roles swapped.

Provide both implementations to the Stack’s Effect:

import PingLive from "./src/ping.ts";
import PongLive from "./src/pong.ts";
Effect.gen(function* () {
const ping = yield* Ping;
const pong = yield* Pong;
return { ping: ping.url, pong: pong.url };
}).pipe(Effect.provide(Layer.mergeAll(PingLive, PongLive)));

Alchemy creates a stub of each Service first, then wires the bindings. Only Services that own their App can be part of such a cycle. A Service placed in a shared App with app cannot.

Error When Fix
Fly.ServiceUnreachable The caller and the bound Service are on different networks. Fails before the caller’s App is created. Give both the same network, usually Fly.stackNetwork.
Fly.ServiceNotBindable The bound Service publishes no ports (services: []), so it has no private address. Publish a port, or stop binding it.
Fly.EndpointNotPublished Fly.bindEndpoint names a port the Service does not publish. Add the port to the Service’s services, or bind a published port.