Platform Layers
Master Effect platform layer provision for cross-platform applications. Use this skill when structuring applications that use Effect platform abstractions to ensure portability across Node.js and Bun environments.
The Golden Rule
Application code uses abstract interfaces. Platform-specific layers are provided either at the program entry point or inside a runtime-facing adapter module's defaultLayer.
// Application code - platform agnostic
import { Effect, FileSystem, Path, pipe } from 'effect';
const readConfig = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configPath = path.join('config', 'app.json');
return yield* fs.readFileString(configPath);
});
// Entry point - platform specific
import { NodeServices, NodeRuntime } from '@effect/platform-node';
declare const program: Effect.Effect<void, never, never>;
pipe(program, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
// WRONG - platform-specific imports in application code
import { readFileSync } from 'fs'; // Ties code to Node.js
import { FileSystem } from '@effect/platform-node'; // Platform-specific
Runtime-facing adapter modules may own their platform wiring directly:
export const defaultLayer = layer.pipe(
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer)
);
The important boundary is that downstream callers still depend on the abstract service, not on Node/Bun modules.
For HTTP provider adapters, the abstract dependency is HttpClient.HttpClient. Keep it visible on the adapter's raw layer and name the adapter after the upstream it owns:
import { Context, Effect, Layer } from 'effect';
import { FetchHttpClient, HttpClient } from 'effect/unstable/http';
export class ProviderGateway extends Context.Service<ProviderGateway, {
readonly health: Effect.Effect<void>;
}>()('app/ProviderGateway') {}
const makeProviderGateway = Effect.gen(function* () {
yield* HttpClient.HttpClient;
return ProviderGateway.of({ health: Effect.void });
});
export const layer: Layer.Layer<ProviderGateway, never, HttpClient.HttpClient> =
Layer.effect(ProviderGateway, makeProviderGateway);
export const defaultLayer: Layer.Layer<ProviderGateway> = layer.pipe(
Layer.provide(FetchHttpClient.layer)
);
Use layer when the application or test owns transport selection. Use defaultLayer only when this runtime-facing adapter intentionally owns the default transport. Do not provide the transport inside layer, because that erases the dependency graph and prevents straightforward substitution.
Platform Import Patterns
Node.js
import { NodeServices, NodeRuntime } from '@effect/platform-node';
import { Effect, pipe } from 'effect';
declare const program: Effect.Effect<void, never, never>;
pipe(program, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
Bun
import { BunServices, BunRuntime } from '@effect/platform-bun';
import { Effect, pipe } from 'effect';
declare const program: Effect.Effect<void, never, never>;
pipe(program, Effect.provide(BunServices.layer), BunRuntime.runMain);
Browser
import { BrowserRuntime } from '@effect/platform-browser';
import { Effect, pipe } from 'effect';
declare const program: Effect.Effect<void, never, never>;
pipe(program, BrowserRuntime.runMain);
BrowserRuntime.runMain keeps the main fiber alive when a pagehide event is persisted for the browser back/forward cache. It interrupts the fiber on non-persisted pagehide, when the document is actually being discarded. Browser teardown is best-effort, so asynchronous finalizers are not guaranteed to finish before the page disappears.
Context Layer Services
Each platform context (NodeServices.layer, BunServices.layer) provides these services:
| Service | Tag | Description | Import from |
|---|---|---|---|
| FileSystem | FileSystem.FileSystem |
File I/O operations (read, write, stat, etc.) | effect |
| Path | Path.Path |
Path manipulation (join, normalize, relative, etc.) | effect |
| Stdio | Stdio.Stdio |
Standard I/O streams (stdin, stdout, stderr) | effect |
| Terminal | Terminal.Terminal |
Terminal/console I/O with ANSI support | effect |
| Crypto | Crypto.Crypto |
Cryptographic random bytes, UUIDs, and digests | effect |
| ChildProcessSpawner | ChildProcessSpawner.ChildProcessSpawner |
Spawn and manage child processes | effect/unstable/process |
Crypto.Crypto is included in the Node/Bun aggregate layers; browser applications can provide BrowserCrypto.layer when they need the crypto service. These aggregate layers are core service bundles: they do not provide specialized integrations such as HTTP clients/servers, sockets, workers, or Redis. For sockets, import Socket.Socket / SocketServer.SocketServer from effect/unstable/socket and provide socket-specific layers such as NodeSocket.layerWebSocket(...), NodeSocket.layerNet(...), BunSocket.layerWebSocket(...), BrowserSocket.layerWebSocket(...), or Node/Bun socket-server layers as appropriate.
Runtime application/provider HTTP belongs behind Effect HttpClient, not raw fetch. Only a named low-level platform transport adapter may use fetch directly, with a documented justification and full ownership of interruption, status-before-decode, schema decoding, and typed errors. Provider adapters also own redacted diagnostic evidence and retry exhaustion; provider calls run outside database transactions, and retries apply only to proven-idempotent operations. In particular, do not decorate a shared client with automatic retry when it can execute ordinary non-idempotent POST/PATCH requests.
Migrator.fromFileSystem now requires both FileSystem.FileSystem and Path.Path. NodeServices.layer and BunServices.layer already satisfy both. If a migration runtime provides only an individual file-system layer, add the matching host path layer too; on Windows, core Path.layer is not a substitute for a platform-aware path implementation because it uses POSIX semantics.
Redis Layers
Redis is deliberately outside the aggregate platform layers. NodeRedis.layer and NodeRedis.layerConfig use redis (node-redis), with a supported peer range of redis >=5.0.0 <7.0.0, and accept node-redis RedisClientOptions. When migrating from ioredis:
- Move host, port, TLS, and reconnect settings under
socket. - Rename
dbtodatabase. - Use node-redis camel-cased commands such as
hLenandlRangeonNodeRedis.NodeRedis.client. - Use
sendCommandfor arbitrary raw commands. - Do not force a RESP protocol unless required; protocol selection follows the installed node-redis default.
The Node layer connects while it is built and therefore can fail with Redis.RedisError. The initial connection fails fast by default; supplying socket.reconnectStrategy opts into caller-defined initial retry behavior. After the client first becomes ready, the built-in strategy uses node-redis exponential backoff and stops on socket timeouts. Scope finalization calls close(), which waits for in-flight and blocking commands and can delay layer shutdown.
Usage Example
import { Console, Crypto, Effect, FileSystem, Path, Stream, Terminal } from 'effect';
import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
const buildProject = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const terminal = yield* Terminal.Terminal;
const crypto = yield* Crypto.Crypto;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
// Use Path for cross-platform paths
const outDir = path.join('dist', 'bundle');
// Use FileSystem for I/O
yield* fs.makeDirectory(outDir, { recursive: true });
// Use Terminal dimensions and Crypto for output metadata
const columns = yield* terminal.columns;
const rows = yield* terminal.rows;
const buildId = yield* crypto.randomUUIDv7;
yield* terminal.display(`Building project ${buildId} (${columns}x${rows})...\n`);
// Use ChildProcessSpawner for processes
const handle = yield* spawner.spawn(
ChildProcess.make('npm', ['run', 'build'])
);
yield* handle.all.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Console.log(`[build] ${line}`))
);
return yield* handle.exitCode;
});
Layer Composition Patterns
Basic Provision
import { NodeServices, NodeRuntime } from '@effect/platform-node';
import { Effect, pipe } from 'effect';
declare const program: Effect.Effect<void, never, never>;
// Single platform context provides all services
pipe(program, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
Adding Custom Services
import { NodeServices, NodeRuntime } from '@effect/platform-node';
import { Effect, Layer, pipe } from 'effect';
declare const DatabaseLive: Layer.Layer<never, never, never>;
declare const ConfigServiceLive: Layer.Layer<never, never, never>;
declare const LoggerLive: Layer.Layer<never, never, never>;
declare const program: Effect.Effect<void, never, never>;
const AppLayer = Layer.mergeAll(DatabaseLive, ConfigServiceLive, LoggerLive);
pipe(
program,
Effect.provide(AppLayer),
Effect.provide(NodeServices.layer), // Platform services last
NodeRuntime.runMain
);
Overriding Platform Services
import { NodeServices, NodeRuntime } from '@effect/platform-node';
import { Effect, FileSystem, Layer, pipe } from 'effect';
declare const program: Effect.Effect<void, never, never>;
// Custom FileSystem implementation
const CustomFS = Layer.succeed(FileSystem.FileSystem, {
/* custom implementation */
} as FileSystem.FileSystem);
pipe(
program,
Effect.provide(NodeServices.layer),
Effect.provide(CustomFS), // Override after platform layer
NodeRuntime.runMain
);
Testing with Mock Layers
CRITICAL: Prefer mock abstract services for unit tests. For runtime-adapter or layer-composition tests, it is acceptable to provide the real Node/Bun layers directly when that is the behavior under review.
Mocking FileSystem
import { Effect, FileSystem, Layer } from 'effect';
import { expect, test } from 'vitest';
declare const readConfig: Effect.Effect<string, never, FileSystem.FileSystem>;
// Use FileSystem.makeNoop for testing — provides default "NotFound" stubs
// for all methods, then override only the ones you need
const MockFileSystem = Layer.succeed(
FileSystem.FileSystem,
FileSystem.makeNoop({
readFileString: (path) => Effect.succeed(`mock content for ${path}`),
exists: (path) => Effect.succeed(true)
})
);
test('should read config', () =>
Effect.gen(function* () {
const result = yield* readConfig;
expect(result).toContain('mock content');
}).pipe(Effect.provide(MockFileSystem), Effect.runPromise));
Mocking Multiple Services
import { Effect, FileSystem, Layer, Path, Terminal } from 'effect';
import { test } from 'vitest';
declare const program: Effect.Effect<
void,
never,
FileSystem.FileSystem | Path.Path | Terminal.Terminal
>;
const TestContext = Layer.mergeAll(
Layer.succeed(FileSystem.FileSystem, {
readFileString: () => Effect.succeed('test')
// ...
} as FileSystem.FileSystem),
Layer.succeed(Path.Path, {
join: (...parts) => parts.join('/'),
normalize: (path) => path
// ...
} as Path.Path),
Layer.succeed(
Terminal.Terminal,
Terminal.make({
columns: Effect.succeed(80),
rows: Effect.succeed(24),
readInput: Effect.die('readInput not used in this test'),
readLine: Effect.succeed('test input'),
display: () => Effect.void
})
)
);
test('integration test', () =>
program.pipe(Effect.provide(TestContext), Effect.runPromise));
Using layerNoop for Convenient Test Layers
import { Effect, FileSystem } from 'effect';
import { test } from 'vitest';
// FileSystem.layerNoop wraps makeNoop in a Layer for convenience
const TestFS = FileSystem.layerNoop({
readFileString: () => Effect.succeed('test content'),
writeFileString: () => Effect.void,
exists: () => Effect.succeed(true)
});
test('with layerNoop', () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString('test.txt', 'content');
}).pipe(Effect.provide(TestFS), Effect.runPromise));
Architecture Patterns
Layered Application Structure
src/
├── domain/ # Pure domain logic (no platform deps)
├── services/ # Business services (uses abstract platform)
├── infrastructure/ # Platform adapters (if needed)
└── main/
├── main.ts # Entry point with NodeServices
└── main.test.ts # Tests with mock contexts
Service Implementation
// services/ConfigService.ts
import { Effect, FileSystem, Layer, Path, Schema, Context } from 'effect';
interface Config {
readonly name: string;
readonly version: string;
}
declare const ConfigSchema: Schema.Schema<Config>;
class ConfigError extends Schema.TaggedError<ConfigError>()(
'ConfigError',
{
message: Schema.String
}
) {}
export class ConfigService extends Context.Service<
ConfigService,
{
readonly load: Effect.Effect<Config, ConfigError>;
save(config: Config): Effect.Effect<void, ConfigError>;
}
>()('ConfigService') {}
export const ConfigServiceLive = Layer.effect(
ConfigService,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const load = Effect.gen(function* () {
const configPath = path.join('config', 'app.json');
const content = yield* fs.readFileString(configPath);
return yield* Schema.decode(ConfigSchema)(JSON.parse(content));
});
const save = (config: Config) =>
Effect.gen(function* () {
const configPath = path.join('config', 'app.json');
const content = JSON.stringify(config, null, 2);
yield* fs.writeFileString(configPath, content);
});
return { load, save };
})
);
Entry Point
// main/main.ts
import { NodeServices, NodeRuntime } from '@effect/platform-node';
import { Effect, Layer, pipe } from 'effect';
import { ConfigService, ConfigServiceLive } from '../services/ConfigService.js';
const MainLayer = Layer.mergeAll(
ConfigServiceLive
// ... other services
);
const program = Effect.gen(function* () {
const config = yield* ConfigService;
yield* config.load;
// ... application logic
});
pipe(
program,
Effect.provide(MainLayer),
Effect.provide(NodeServices.layer),
NodeRuntime.runMain
);
Common Patterns
Conditional Platform Loading
import { NodeServices, NodeRuntime } from '@effect/platform-node';
import { BunServices } from '@effect/platform-bun';
import { Effect, pipe } from 'effect';
declare const program: Effect.Effect<void, never, never>;
const PlatformContext =
process.env.RUNTIME === 'bun' ? BunServices.layer : NodeServices.layer;
pipe(
program,
Effect.provide(PlatformContext),
NodeRuntime.runMain // Runtime matches context
);
Scoped Platform Resources
import { Effect, FileSystem, Path } from 'effect';
const withTempDirectory = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* Effect.acquireRelease(
Effect.gen(function* () {
const dir = path.join('temp', `${Date.now()}`);
yield* fs.makeDirectory(dir, { recursive: true });
return dir;
}),
(dir) => fs.remove(dir, { recursive: true })
);
return tempDir;
});
Anti-Patterns
Platform-Specific Imports in Application Code
// WRONG - ties application to Node.js
import * as fs from 'fs';
import * as path from 'path';
const readConfig = () => {
const content = fs.readFileSync(path.join('config', 'app.json'), 'utf8');
return JSON.parse(content);
};
Direct Platform Module Usage
// WRONG - bypasses Effect abstractions
import { FileSystem } from '@effect/platform-node';
import { Effect } from 'effect';
const program = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
// ...
});
Providing Platform Layers in Application Code
// WRONG - application code should not know about platform
import { NodeServices } from '@effect/platform-node';
import { Effect } from 'effect';
declare const program: Effect.Effect<void, never, never>;
export const myService = program.pipe(
Effect.provide(NodeServices.layer) // Should be at entry point only
);
Key Principles
- Import abstractions, provide implementations: Application code imports from
effect(e.g.FileSystem,Path,Terminal), entry points provide platform-specific contexts - One platform layer per runtime: Use exactly one of
NodeServices.layerorBunServices.layer - Platform layer last: Provide custom services first, platform context last
- Mock in tests: Use
Layer.succeedwith mock implementations, never import platform-specific modules in tests - Entry point decides platform: Only
main.ts(or equivalent entry) should import platform-specific modules - Keep HTTP transport requirements visible: Provider adapter
layerrequiresHttpClient.HttpClient; an optionaldefaultLayermay provide the chosen transport - Make adapters own the boundary: Named adapters classify status before schema decoding, map typed failures, retain redacted evidence, and expose retry exhaustion
- Do not retry by accident: Restrict retrying/rate-limited clients to proven-idempotent operations; non-idempotent calls need an explicit provider guarantee or idempotency key
- Do not hold transactions across providers: Complete network calls before opening the database transaction used to persist their result