Building a Service Stack
End-to-end walkthrough: define multiple services with dependencies, compose layers, wire them into a program, and run with Layer.launch.
Define Services with Dependencies
Config Service
import { Config, Context, Effect, Layer, Schema } from "effect"
export class AppConfig extends Context.Service<AppConfig, {
readonly dbUrl: string
readonly logLevel: string
}>()("myapp/AppConfig") {
static readonly layer = Layer.effect(
AppConfig,
Effect.gen(function*() {
const dbUrl = yield* Config.string("DATABASE_URL")
const logLevel = yield* Config.string("LOG_LEVEL").pipe(
Config.withDefault("info")
)
return AppConfig.of({ dbUrl, logLevel })
})
)
}
Logger Service (depends on Config)
import { Context, Effect, Layer } from "effect"
import { AppConfig } from "./AppConfig.ts"
export class Logger extends Context.Service<Logger, {
log(message: string): Effect.Effect<void>
error(message: string): Effect.Effect<void>
}>()("myapp/Logger") {
static readonly layerNoDeps = Layer.effect(
Logger,
Effect.gen(function*() {
const config = yield* AppConfig
const log = Effect.fn("Logger.log")((message: string) =>
Effect.logInfo(`[${config.logLevel.toUpperCase()}] ${message}`)
)
const error = Effect.fn("Logger.error")((message: string) =>
Effect.logError(message)
)
return Logger.of({ log, error })
})
)
static readonly layer = this.layerNoDeps.pipe(
Layer.provide(AppConfig.layer)
)
}
Database Service (depends on Logger)
import { Context, Effect, Layer, Schema } from "effect"
export class DatabaseError extends Schema.TaggedError<DatabaseError>()("DatabaseError", {
cause: Schema.Defect()
}) {}
export interface User {
readonly id: string
readonly name: string
readonly email: string
}
export class Database extends Context.Service<Database, {
query<T>(sql: string): Effect.Effect<Array<T>, DatabaseError>
insertUser(input: { name: string; email: string }): Effect.Effect<User, DatabaseError>
}>()("myapp/Database") {
static readonly layerNoDeps = Layer.effect(
Database,
Effect.gen(function*() {
const logger = yield* Logger
const query = Effect.fn("Database.query")(function*<T>(sql: string) {
yield* logger.log(`Executing SQL: ${sql}`)
return [] as Array<T>
})
const insertUser = Effect.fn("Database.insertUser")(function*(input: { name: string; email: string }) {
yield* logger.log(`Inserting user: ${input.name}`)
return { id: crypto.randomUUID(), ...input }
})
return Database.of({ query, insertUser })
})
)
static readonly layer = this.layerNoDeps.pipe(
Layer.provide(Logger.layer)
)
}
Compose Layers
Each service has a layerNoDeps (depends on other services) and a layer (fully wired). Compose them explicitly so the dependency graph is visible.
import { Layer } from "effect"
import { AppConfig } from "./AppConfig.ts"
import { Logger } from "./Logger.ts"
import { Database } from "./Database.ts"
// Provide AppConfig to Logger, then Logger to Database
const AppLayers = Database.layer.pipe(
Layer.provide(Logger.layer),
Layer.provide(AppConfig.layer)
)
Tip: Compose layers before providing once to the program. This makes the dependency graph explicit and lets you see the full structure in one place. v4 auto-memoizes across
Effect.providecalls, but explicit composition is still the recommended pattern.
provideMerge for Shared Services
If downstream code needs access to a shared service (not just the top-level one), use Layer.provideMerge to expose both:
const LayersWithLogger = Database.layer.pipe(
Layer.provideMerge(Logger.layer)
)
This exposes both Database and Logger to the consumer.
Wire Into a Program
import { Effect } from "effect"
import { Database } from "./services/Database.ts"
import { Logger } from "./services/Logger.ts"
const program = Effect.gen(function*() {
const database = yield* Database
const logger = yield* Logger
const user = yield* database.insertUser({ name: "Alice", email: "alice@example.com" })
yield* logger.log(`Created user: ${user.id}`)
return user
}).pipe(
Effect.provide(AppLayers)
)
Run with Layer.launch
For long-running applications (HTTP servers, workers, queue consumers), use Layer.launch as the entry point. It converts a layer into a long-running Effect<never>.
import { NodeRuntime } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { NodeHttpServer } from "@effect/platform-node"
import { Database } from "./services/Database.ts"
const HealthRoutes = HttpRouter.use(Effect.fn(function*(router) {
yield* router.add("GET", "/health", Effect.succeed(HttpServerResponse.text("ok")))
}))
const HttpServerLayer = HttpRouter.serve(HealthRoutes).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
Layer.provide(AppLayers)
)
const main = Layer.launch(HttpServerLayer)
NodeRuntime.runMain(main)
Gotcha:
Layer.launchis for long-running programs. For short-lived effects, useEffect.runPromiseorEffect.runSyncdirectly. TheNodeRuntime.runMainwrapper adds signal handling and exit code management.
Testing the Service Stack
Provide test implementations to isolate the system under test.
import { assert, it } from "@effect/vitest"
import { Effect, Layer } from "effect"
import { Database } from "./services/Database.ts"
import { Logger } from "./services/Logger.ts"
const LoggerTest = Layer.succeed(Logger, Logger.of({
log: () => Effect.void,
error: () => Effect.void
}))
const DatabaseTest = Layer.effect(
Database,
Effect.gen(function*() {
const users = new Map<string, { id: string; name: string; email: string }>()
return Database.of({
query: () => Effect.succeed([]),
insertUser: (input) => Effect.sync(() => {
const user = { id: "test-id", ...input }
users.set(user.id, user)
return user
})
})
})
)
it.effect("creates a user through the stack", () =>
Effect.gen(function*() {
const database = yield* Database
const user = yield* database.insertUser({ name: "Test", email: "test@test.com" })
assert.strictEqual(user.name, "Test")
}).pipe(
Effect.provide(DatabaseTest.pipe(Layer.provide(LoggerTest)))
)
)
Tip: Split each service into
layerNoDepsandlayer. Tests provide thelayerNoDepsversion with test stubs, while production code uses the fully-wiredlayer. This avoids coupling tests to real infrastructure.