Runtime & entrypoint
Every deployable needs a file that starts the process and a graph that describes the app. This page answers how those are split, what runs the program, and what changes between the Node, Bun and Cloudflare Worker profiles.
Split bin.ts (the process) from main.ts (the graph)
Section titled “Split bin.ts (the process) from main.ts (the graph)”Impact: HIGH the graph can be imported without starting a server
bin.tsholds only process concerns: the platform layers, theConfigProvider, the telemetry slot,Layer.launchandrunMain. It imports no feature exceptMainLayer, and no module imports it.main.tsexportsMainLayerand has no side effects at import, so a test or a second entry can import it. No entry guard (import.meta.main,isEntrypoint): the file split is what makes importing safe.ConfigProviderLayeris defined inmain.tsand provided inbin.ts, beneath the server layer. OtherwiseConfig.Port("PORT")reads only the process environment and silently ignores.env.- Accepted cost: provide order in
bin.tsis load-bearing and nothing checks it. A wrong order still compiles.
❌ Incorrect — one file that runs at import, behind a guard:
export const MainLayer = Layer.mergeAll(HttpLayer, Outbox.dispatcherLayer)if (import.meta.main) { // undefined on some Node versions: exits 0, no output MainLayer.pipe(Layer.provide(NodeHttpServer.layer(() => createServer())), Layer.launch, NodeRuntime.runMain)}✅ Correct — the process in bin.ts, the graph in main.ts:
// apps/server/src/bin.ts — the only module with a side effectMainLayer.pipe( Layer.provide(NodeHttpServer.layerConfig(() => createServer(), { port: Config.Port("PORT"), gracefulShutdownTimeout: Config.succeed("10 seconds"), })), Layer.provide(Observability.layer), // outermost of our layers: closes after the graph Layer.provide(ConfigProviderLayer), // beneath the server and Observability, so both read .env Layer.provide(NodeServices.layer), // ConfigProviderLayer needs FileSystem Layer.launch, NodeRuntime.runMain,)Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 1, amended
Run the server as Layer.launch(MainLayer) → runMain
Section titled “Run the server as Layer.launch(MainLayer) → runMain”Impact: HIGH signals, the last-resort log and exit codes come for free
- With
HttpRouter.servethe server is a layer, soLayer.launchis the whole program. NoEffect.genmain body for the server. Startup work is aLayer.effectDiscardinmain.ts, for example theLedger.requireAppliedcheck that refuses a database that is behind. NodeRuntime.runMainwith no options: nodisableErrorReporting, no customteardown, noprocess.on(...)handlers. It is the last-resort boundary from error boundaries.- No
ManagedRuntimein a deployable we own. A short-lived command (a one-shot script, a migrate step) gets its own bin file and may useprogram.pipe(Effect.provide(MainLayer), runMain). MainLayerwraps the app inLifecycle.drainOnShutdownoutermost, so its finalizer runs first (see lifecycle and shutdown).
❌ Incorrect — a generator body whose real work is waiting:
const program = Effect.gen(function* () { yield* runMigrationsIfNeeded() // boot logic escapes the graph's ordering yield* Effect.never})program.pipe(Effect.provide(MainLayer), NodeRuntime.runMain({ disableErrorReporting: true }))✅ Correct — startup work is a layer; the program is launch:
export const ServicesLayer = Layer.mergeAll(Labeling.layer, Billing.layer).pipe( Layer.provide(Layer.effectDiscard(Ledger.requireApplied(/* manifest */))),)export const MainLayer = Lifecycle.drainOnShutdown.pipe( Layer.provideMerge(AppLayer), Layer.provide(Lifecycle.layer),)// bin.ts: … Layer.launch, NodeRuntime.runMainSource: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 2, amended
Keep platform code in the platform entry file
Section titled “Keep platform code in the platform entry file”Impact: MEDIUM the graph loads on any runtime; a switch touches one file
- Platform code means
@effect/platform-node,@effect/platform-bun,node:*,alchemy,alchemy/*andcloudflare:*. It appears only in the platform entry file:bin.ts(orbin/*.ts) for Node and Bun,isolate.tsfor a Worker, pluspackages/db/src/migrate.tsandalchemy.run.ts. main.ts,http.tsand feature folders depend on abstract services:HttpServer.HttpServer,FileSystem.FileSystem,HttpClient.HttpClient.- One exception:
node:cryptois allowed ininfra/secrets.tsandinfra/secret-box.ts, becauseeffect/Cryptohas no cipher, HMAC or constant-time compare. On a Worker it needsnodejs_compat. - The rule is lint-enforced with a
no-restricted-importsoverride (tests exempt); see linting and formatting and coupling and cohesion.
❌ Incorrect — the graph picks the platform:
const ServerLayer = typeof Bun !== "undefined" ? BunHttpServer.layerConfig({ port: Config.Port("PORT") }) : NodeHttpServer.layerConfig(() => createServer(), { port: Config.Port("PORT") })✅ Correct — the graph stays abstract; the entry supplies the server:
const AppLayer = Layer.mergeAll( HttpRouter.serve(Layer.mergeAll(RoutesLayer, Health.readinessLayer)), Outbox.dispatcherLayer,)// still requires: HttpServer.HttpServer | FileSystem.FileSystem — supplied by bin.tsSource: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 3, amended
Run background work as layers in the same process
Section titled “Run background work as layers in the same process”Impact: MEDIUM one deployable, nothing wired twice
- An outbox dispatcher, a recurring job or a queue consumer is a scoped layer merged into
MainLayer. It uses the helper-owned worker boundary from error boundaries, so one bad item never ends the loop. - A recurring job is a value exported by its slice (
Jobs.make).main.tsexportsjobsandsubscribers, and on the container profiles mergesJobs.layer(jobs)into the graph. - Every replica runs every loop. A job that must not run twice is declared
exclusive: trueand takes ajobs_leasesrow (see scheduling and retry). The outbox relay and its cleanup do not need the lease. - Accepted cost: background work cannot scale apart from requests.
❌ Incorrect — a second process and app before any job needs one:
apps/server/ ← HTTPapps/worker/ ← outbox + jobs, its own graph, pool and deploy pipeline✅ Correct — loops are layers beside the server:
const AppLayer = Layer.mergeAll( HttpRouter.serve(Layer.mergeAll(RoutesLayer, Health.readinessLayer)), Outbox.dispatcherLayer, // every background loop is a layer Jobs.layer(jobs), // one loop per recurring job, on every replica).pipe( Layer.provide(ServicesLayer), // shared with the loops → outside serve Layer.provide(SqlLayer), // infrastructure last)Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 4, amended
Pick a runtime profile: Node (default), Bun, or a Cloudflare Worker
Section titled “Pick a runtime profile: Node (default), Bun, or a Cloudflare Worker”Impact: MEDIUM container decisions stay put; Worker differences are explicit
main.tsexportsRoutesLayer,ServicesLayer,jobsandsubscribersfor every profile, andMainLayerfor the long-lived profiles (it addsserve, the loops,/readyandLifecycle.drainOnShutdown).- Node is the default for a long-lived server. Bun is supported: a Bun project changes
bin.tsand its SQLite driver only. Bun is the runtime; pnpm stays the package manager. - The Worker profile is deployed by alchemy. Its entry is
isolate.ts(notworker.ts, because “Worker” names ourmakeWorkerconsumer).
| Node | Bun | Cloudflare Worker | |
|---|---|---|---|
| entry | bin.ts |
bin.ts |
isolate.ts |
| program | Layer.launch → NodeRuntime.runMain |
Layer.launch → BunRuntime.runMain |
none: alchemy’s bridge calls fetch |
| SQL | Postgres or SQLite | Postgres or SQLite | D1 (@effect/sql-d1) |
| probes | /health + /ready |
same | /health only |
| jobs | Jobs.layer(jobs) |
same | one Cron Trigger per job |
Crypto |
in NodeServices |
in BunServices |
Crypto.make over globalThis.crypto |
❌ Incorrect — Bun-only behaviour leaks into the graph:
const port = Bun.env.PORT // the graph no longer loads on Node or a Worker✅ Correct — only the entry file differs per profile:
// bin.ts on BunMainLayer.pipe( Layer.provide(BunHttpServer.layerConfig({ port: Config.Port("PORT") })), Layer.provide(ConfigProviderLayer), Layer.provide(BunServices.layer), Layer.launch, BunRuntime.runMain,)Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 5
On a Worker, build the graph once per isolate, in init
Section titled “On a Worker, build the graph once per isolate, in init”Impact: HIGH building in fetch rebuilds every layer on every request
- alchemy evaluates the init closure once per isolate. Build
ServicesLayerthere withLayer.build, hand the context toHttpRouter.toHttpEffect(RoutesLayer)throughLayer.succeedContext, and to each cron handler withEffect.provide. - Call
toHttpEffectonce. It builds with a forked MemoMap, so handing it the whole graph and building the jobs’ services again builds every layer twice. Configreads must happen inside init: alchemy binds them as Worker secrets only there.- Nothing disposable is acquired at init. The build scope is never closed, so a finalizer added
there never runs. A resource that needs a release is acquired per request. D1 is a binding, not
a pool, so
D1Clientat init is fine.
❌ Incorrect — the graph built inside fetch:
return { fetch: Effect.gen(function* () { // a full graph build per request, and Config read after init const handler = yield* HttpRouter.toHttpEffect(RoutesLayer.pipe(Layer.provide(ServicesLayer))) return yield* handler }),}✅ Correct — one build in init, shared by fetch and cron:
Effect.gen(function* () { const d1 = yield* Cloudflare.D1.QueryDatabase(yield* Database) const services = yield* Layer.build(ServicesLayer.pipe( Layer.provideMerge(D1Client.layer({ db: yield* d1.raw })), Layer.provideMerge(WorkerCrypto), )) const handler = yield* HttpRouter.toHttpEffect(RoutesLayer.pipe(Layer.provide(Layer.succeedContext(services)))) for (const job of jobs) { yield* Cloudflare.Workers.cron(job.cron, (controller) => Jobs.run(job, controller.scheduledTime).pipe(Effect.provide(services))) } return { fetch: handler }})Source: notes/05-effect-fundamentals/runtime-and-entrypoint.md · Decision 5, amended
Deferred
Section titled “Deferred”- A second process kind (
bin/server.ts,bin/worker.ts) — trigger: a background workload is measured to raise request p95 by more than 20% while it runs. - Splitting
main.tsinto a shared service root and an HTTP root — trigger: a second bin exists. - A separate app per process kind — trigger: a second team owns the process.
- Providing observability with
Effect.provideafterLayer.launch, around a boot-failure reporter — trigger: a boot-failure alert is wired. - A Durable Object per org terminating the RPC WebSocket on the Worker — trigger: the RPC contracts trigger fires.