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.provide calls, 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.launch is for long-running programs. For short-lived effects, use Effect.runPromise or Effect.runSync directly. The NodeRuntime.runMain wrapper 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 layerNoDeps and layer. Tests provide the layerNoDeps version with test stubs, while production code uses the fully-wired layer. This avoids coupling tests to real infrastructure.