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.stackNetworkon 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.
Create the Users Service
Section titled “Create the Users Service”Users returns two methods, list and get. It returns no fetch;
bound callers call the methods directly.
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.
Put Users on the stack’s network
Section titled “Put Users on the stack’s network”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.
Create the Orders Service
Section titled “Create the Orders Service”Orders is a second private Service on the same network:
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 from Orders
Section titled “Bind Users from Orders”Bind Users in the Orders program:
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.
Call Users from Orders
Section titled “Call Users from Orders”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.
Create the public Gateway
Section titled “Create the public Gateway”Gateway joins the same network and leaves out public: false, so it
is public:
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.
Bind Users and Orders from the Gateway
Section titled “Bind Users and Orders from the Gateway”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.
Serve routes from the Gateway
Section titled “Serve routes from the Gateway”Call the bound methods from fetch:
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), };}),Wire the Stack
Section titled “Wire the Stack”Yield the three Services and return their URLs:
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, }; }),);Deploy
Section titled “Deploy”alchemy deployAlchemy 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"Try it out
Section titled “Try it out”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.
How deploy order works
Section titled “How deploy order works”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.
Public url and private privateUrl
Section titled “Public url and private privateUrl”| 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.
Security model
Section titled “Security model”A method call from Orders to Users is accepted only when all of
these hold:
- Same network.
Usersis 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 withFly.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
Usersgets401from its methods. - Fly-Src signature. Fly’s proxy signs every request that arrives
over Flycast with an ed25519
Fly-Srcheader naming the calling App and organization.Usersverifies 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 answer401to it, and the extrabindingPortanswers 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.
What an outside App sees
Section titled “What an outside App sees”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:
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.
Call HTTP routes with client.fetch
Section titled “Call HTTP routes with client.fetch”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.
Stream results from a method
Section titled “Stream results from a method”A method can return a Stream. The bound client returns the same
Stream, delivered element by element:
// in UsersstreamAll: () => Stream.fromIterable(USERS),
// in the callerconst all = yield* users.streamAll().pipe(Stream.runCollect);Bind one port with Fly.bindEndpoint
Section titled “Bind one port with Fly.bindEndpoint”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.
Two Services that bind each other
Section titled “Two Services that bind each other”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:
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:
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.
Errors
Section titled “Errors”| 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. |
Where next
Section titled “Where next”- Services — private Services,
bindingPort, and grouping Services in one App. - IPs & certificates — public and private Services, and addresses on your own App.
- Blue/green deployments — replace a Service without dropping calls from its callers.
- The fly-microservices example
and the
Servicereference.