Effect Курс Effect.scoped

Effect.scoped

Автоматическое управление scope.

Проблема ручного управления scope

В предыдущей статье мы видели ручное управление scope через Scope.make() и Scope.close(). Этот подход мощный, но имеет недостатки:


// ⚠️ Ручное управление: много boilerplate, легко забыть close
const manual = Effect.gen(function* () {
  const scope = yield* Scope.make()

  yield* Scope.addFinalizer(scope, Console.log("Cleanup"))

  // ... много кода ...

  // Если забыть эту строку — утечка ресурса!
  yield* Scope.close(scope, Exit.void)
})

Проблемы ручного подхода:

┌──────────────────────────────────────────────────────┐
│ Ручное управление Scope                              │
├──────────────────────────────────────────────────────┤
│ ❌ Можно забыть вызвать Scope.close()                │
│ ❌ При исключении close может не выполниться         │
│ ❌ Нужно вручную передавать Exit                     │
│ ❌ Много boilerplate кода                            │
│ ❌ Ошибки в основном коде могут обойти close         │
└──────────────────────────────────────────────────────┘

Effect.scoped решает все эти проблемы, автоматизируя жизненный цикл scope.


Effect.scoped

Что делает Effect.scoped

Effect.scoped — это оператор, который:

  1. Создаёт новый Scope
  2. Предоставляет его эффекту через Context
  3. Выполняет эффект
  4. Закрывает scope после завершения (успех, ошибка или прерывание)
  5. Удаляет Scope из Requirements типа
  Effect.scoped(effect)

  ┌────────────────────────────────────────────┐
  │ 1. scope = Scope.make()                    │
  │                                            │
  │ 2. Предоставить scope эффекту              │
  │    ┌────────────────────────────────┐      │
  │    │          effect                │      │
  │    │  (имеет доступ к scope через   │      │
  │    │   Effect.addFinalizer,         │      │
  │    │   Effect.acquireRelease, etc.) │      │
  │    └────────────────────────────────┘      │
  │                 │                          │
  │                 ▼                          │
  │ 3. result = Success(value)                 │
  │         или Failure(cause)                 │
  │         или Interrupted                    │
  │                 │                          │
  │                 ▼                          │
  │ 4. Scope.close(scope, exit)                │
  │    → Все финализаторы в LIFO-порядке       │
  │                                            │
  │ 5. Вернуть result                          │
  └────────────────────────────────────────────┘

Базовый пример


// Эффект требует Scope
//                              ┌─── Scope в Requirements
//                              ▼
const needsScope: Effect.Effect<string, never, Scope.Scope> = Effect.gen(
  function* () {
    yield* Effect.addFinalizer(() => Console.log("Cleanup!"))
    return "result"
  }
)

// Effect.scoped убирает Scope из Requirements
//                              ┌─── Scope удалён!
//                              ▼
const noScope: Effect.Effect<string, never, never> = Effect.scoped(needsScope)

// Теперь можно запустить
Effect.runPromise(noScope).then(console.log)
/*
  Cleanup!
  result
*/

Как Effect.scoped взаимодействует с системой типов

Трансформация типа

Effect.scoped выполняет точную трансформацию типа — удаляет только Scope из Requirements, оставляя остальные зависимости:


class Logger extends Context.Tag("Logger")<Logger, {
  readonly log: (msg: string) => Effect.Effect<void>
}>() {}

// Effect<string, Error, Logger | Scope>
const needsLoggerAndScope = Effect.gen(function* () {
  const logger = yield* Logger
  yield* Effect.addFinalizer(() => logger.log("Resource cleaned up"))
  return "result"
})

// Effect<string, Error, Logger>  ← Scope удалён, Logger остался
const scopeResolved = Effect.scoped(needsLoggerAndScope)

Визуально:

  Before Effect.scoped:     After Effect.scoped:
  ┌───────────────────┐     ┌───────────────────┐
  │ Effect<           │     │ Effect<           │
  │   string,         │     │   string,         │
  │   Error,          │     │   Error,          │
  │   Logger | Scope  │────▶│   Logger          │
  │ >                 │     │ >                 │
  └───────────────────┘     └───────────────────┘

                            Scope автоматически
                            управляется

Вложенный scoped

Effect.scoped можно применять на разных уровнях, создавая вложенные scope:


const inner = Effect.scoped(
  Effect.gen(function* () {
    yield* Effect.addFinalizer(() => Console.log("Inner cleanup"))
    yield* Console.log("Inner scope active")
    return "inner result"
  })
)

const outer = Effect.scoped(
  Effect.gen(function* () {
    yield* Effect.addFinalizer(() => Console.log("Outer cleanup"))
    const result = yield* inner  // Inner scope создаётся и закрывается здесь
    yield* Console.log(`Got: ${result}`)
  })
)

Effect.runPromise(outer)
/*
  Inner scope active
  Inner cleanup          ← inner scope закрывается первым
  Got: inner result
  Outer cleanup          ← outer scope закрывается после
*/

Слияние scope

Автоматическое слияние

Когда несколько scoped-ресурсов используются внутри одного Effect.scoped, их scope сливаются в один:


const task1 = Effect.gen(function* () {
  yield* Console.log("Task 1: started")
  yield* Effect.addFinalizer(() => Console.log("Task 1: finalizer"))
})

const task2 = Effect.gen(function* () {
  yield* Console.log("Task 2: started")
  yield* Effect.addFinalizer(() => Console.log("Task 2: finalizer"))
})

const program = Effect.scoped(
  Effect.gen(function* () {
    // task1 и task2 делят один scope
    yield* task1
    yield* task2
  })
)

Effect.runPromise(program)
/*
  Task 1: started
  Task 2: started
  Task 2: finalizer     ← LIFO: task2 добавлен позже
  Task 1: finalizer
*/

Визуализация слияния

  Effect.scoped(...)
  ┌──────────────────────────────────────────────┐
  │                SHARED SCOPE                  │
  │                                              │
  │  task1  ─── addFinalizer("Task 1") ──┐       │
  │                                      │       │
  │  task2  ─── addFinalizer("Task 2") ──┤       │
  │                                      │       │
  │                                      ▼       │
  │                              Finalizer Stack │
  │                              ┌──────────┐    │
  │                              │ Task 2   │    │
  │                              │ Task 1   │    │
  │                              └──────────┘    │
  │                                              │
  └───────────────── close ──────────────────────┘


               Task 2: finalizer
               Task 1: finalizer

Это поведение по умолчанию удобно в большинстве случаев. Когда нужен более тонкий контроль — используйте ручное создание scope (описано в статье 05).


Scoped Layers

Проблема: сервис с жизненным циклом

Многие сервисы в production нуждаются в инициализации и очистке:

  Database Pool       HTTP Server        Message Queue
  ┌────────────┐     ┌────────────┐     ┌────────────┐
  │ init pool  │     │ start      │     │ connect    │
  │ use pool   │     │ serve      │     │ consume    │
  │ close pool │     │ shutdown   │     │ disconnect │
  └────────────┘     └────────────┘     └────────────┘

Обычный Layer не имеет понятия о жизненном цикле — он создаёт сервис, но не управляет его закрытием. Layer.scoped решает эту проблему.

Что такое Scoped Layer

Scoped Layer — это Layer, который:

  1. Создаёт сервис через scoped-эффект (с acquireRelease)
  2. Автоматически закрывает ресурсы сервиса при завершении программы
  3. Не требует от потребителя знать о внутреннем scope

Layer.scoped

Создание scoped layer


// Определяем сервис
class DatabasePool extends Context.Tag("DatabasePool")<
  DatabasePool,
  {
    readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>
    readonly poolSize: number
  }
>() {}

// Scoped layer: ресурс с жизненным циклом
const DatabasePoolLive: Layer.Layer<DatabasePool> = Layer.scoped(
  DatabasePool,
  Effect.gen(function* () {
    // Acquire pool
    yield* Console.log("[DB Pool] Initializing connection pool...")

    const pool = {
      query: (sql: string) => Effect.succeed([{ sql, result: "data" }]),
      poolSize: 10
    }

    // Регистрируем финализатор
    yield* Effect.addFinalizer(() =>
      Console.log("[DB Pool] Closing all connections...")
    )

    yield* Console.log(`[DB Pool] Ready with ${pool.poolSize} connections`)
    return pool
  })
)

Использование scoped layer


const program = Effect.gen(function* () {
  const pool = yield* DatabasePool
  yield* Console.log(`Pool size: ${pool.poolSize}`)
  const result = yield* pool.query("SELECT * FROM users")
  yield* Console.log(`Query result: ${JSON.stringify(result)}`)
})

// provide автоматически управляет scope layer
Effect.runPromise(
  program.pipe(Effect.provide(DatabasePoolLive))
)
/*
  [DB Pool] Initializing connection pool...
  [DB Pool] Ready with 10 connections
  Pool size: 10
  Query result: [{"sql":"SELECT * FROM users","result":"data"}]
  [DB Pool] Closing all connections...
*/

💡 Обратите внимание: потребитель (program) вообще не знает о scope и финализаторах. Вся логика жизненного цикла инкапсулирована в Layer.

Layer.scoped с acquireRelease

Более идиоматичный способ — использовать acquireRelease внутри layer:


class HttpServer extends Context.Tag("HttpServer")<
  HttpServer,
  {
    readonly port: number
    readonly handleRequest: (path: string) => Effect.Effect<string>
  }
>() {}

const HttpServerLive = Layer.scoped(
  HttpServer,
  Effect.acquireRelease(
    // Acquire: запуск сервера
    Effect.gen(function* () {
      const port = 8080
      yield* Console.log(`[Server] Starting on port ${port}...`)
      return {
        port,
        handleRequest: (path: string) =>
          Effect.succeed(`200 OK: ${path}`)
      }
    }),
    // Release: остановка сервера
    (server) => Console.log(`[Server] Shutting down port ${server.port}...`)
  )
)

Effect.Service с scoped

Более современный способ определения сервисов с жизненным циклом — Effect.Service с параметром scoped:


class CacheService extends Effect.Service<CacheService>()("CacheService", {
  scoped: Effect.gen(function* () {
    // Acquire
    const cache = new Map<string, string>()
    yield* Console.log("[Cache] Initialized")

    // Финализатор
    yield* Effect.addFinalizer(() =>
      Console.log(`[Cache] Cleared ${cache.size} entries`)
    )

    return {
      get: (key: string) =>
        Effect.sync(() => cache.get(key) ?? null),
      set: (key: string, value: string) =>
        Effect.sync(() => { cache.set(key, value) }),
      size: Effect.sync(() => cache.size)
    }
  })
}) {}

Использование:

const program = Effect.gen(function* () {
  const cache = yield* CacheService
  yield* cache.set("user:1", "Alice")
  yield* cache.set("user:2", "Bob")
  const user = yield* cache.get("user:1")
  const size = yield* cache.size
  yield* Console.log(`User: ${user}, Cache size: ${size}`)
})

Effect.runPromise(
  program.pipe(Effect.provide(CacheService.Default))
)
/*
  [Cache] Initialized
  User: Alice, Cache size: 2
  [Cache] Cleared 2 entries
*/

CacheService.Default — автоматически сгенерированный Layer<CacheService>, который управляет scope. Потребителю не нужно знать о Scope.

acquireRelease внутри Effect.Service


class MetricsCollector extends Effect.Service<MetricsCollector>()(
  "MetricsCollector",
  {
    scoped: Effect.acquireRelease(
      // Acquire
      Effect.gen(function* () {
        yield* Console.log("[Metrics] Starting collector...")
        const metrics = new Map<string, number>()
        return {
          increment: (name: string) =>
            Effect.sync(() => {
              metrics.set(name, (metrics.get(name) ?? 0) + 1)
            }),
          report: Effect.sync(() =>
            Object.fromEntries(metrics.entries())
          )
        }
      }),
      // Release
      (collector) =>
        Effect.gen(function* () {
          const report = yield* collector.report
          yield* Console.log(`[Metrics] Final report: ${JSON.stringify(report)}`)
          yield* Console.log("[Metrics] Collector stopped")
        })
    )
  }
) {}

API Reference

Effect.scoped [STABLE]

// Удаляет Scope из Requirements, автоматически управляя жизненным циклом
Effect.scoped<A, E, R>(
  effect: Effect<A, E, R | Scope>
): Effect<A, E, Exclude<R, Scope>>

Layer.scoped [STABLE]

// Создаёт Layer из scoped-эффекта
Layer.scoped<T extends Context.Tag<any, any>>(
  tag: T,
  effect: Effect<Context.Tag.Service<T>, E, R | Scope>
): Layer<Context.Tag.Identifier<T>, E, Exclude<R, Scope>>

Effect.scope [STABLE]

// Получает текущий Scope из контекста
Effect.scope: Effect<Scope, never, Scope>

Layer.scopedDiscard [STABLE]

// Layer, который выполняет scoped-эффект, но не предоставляет сервис
Layer.scopedDiscard<E, R>(
  effect: Effect<unknown, E, R | Scope>
): Layer<never, E, Exclude<R, Scope>>

Полезен для background-задач, которые должны жить в scope layer:


// Фоновая задача: heartbeat
const HeartbeatLayer = Layer.scopedDiscard(
  Effect.gen(function* () {
    const fiber = yield* Effect.fork(
      Effect.repeat(
        Console.log("[Heartbeat] ping"),
        Schedule.spaced("5 seconds")
      )
    )
    yield* Effect.addFinalizer(() =>
      Console.log("[Heartbeat] stopped")
    )
  })
)

Примеры

Пример 1: Полноценный сервис с lifecycle


// Сервис с жизненным циклом
class EventBus extends Context.Tag("EventBus")<
  EventBus,
  {
    readonly publish: (event: string) => Effect.Effect<void>
    readonly subscribe: (
      handler: (event: string) => void
    ) => Effect.Effect<void>
  }
>() {}

const EventBusLive = Layer.scoped(
  EventBus,
  Effect.gen(function* () {
    const subscribers: Array<(event: string) => void> = []

    yield* Console.log("[EventBus] Starting...")

    yield* Effect.addFinalizer(() =>
      Effect.gen(function* () {
        yield* Console.log(
          `[EventBus] Shutting down. ${subscribers.length} subscribers notified.`
        )
        subscribers.length = 0
      })
    )

    return {
      publish: (event: string) =>
        Effect.sync(() => {
          subscribers.forEach((handler) => handler(event))
        }),
      subscribe: (handler: (event: string) => void) =>
        Effect.sync(() => {
          subscribers.push(handler)
        })
    }
  })
)

const program = Effect.gen(function* () {
  const bus = yield* EventBus
  yield* bus.subscribe((e) => console.log(`Received: ${e}`))
  yield* bus.publish("user.created")
  yield* bus.publish("order.placed")
})

Effect.runPromise(program.pipe(Effect.provide(EventBusLive)))
/*
  [EventBus] Starting...
  Received: user.created
  Received: order.placed
  [EventBus] Shutting down. 1 subscribers notified.
*/

Пример 2: Композиция scoped layers


// Два сервиса с жизненным циклом
class ConfigService extends Context.Tag("ConfigService")<
  ConfigService,
  { readonly get: (key: string) => Effect.Effect<string> }
>() {}

class ApiClient extends Context.Tag("ApiClient")<
  ApiClient,
  { readonly fetch: (url: string) => Effect.Effect<string> }
>() {}

const ConfigLive = Layer.scoped(
  ConfigService,
  Effect.gen(function* () {
    yield* Console.log("[Config] Loading configuration...")
    yield* Effect.addFinalizer(() => Console.log("[Config] Cleanup"))
    return {
      get: (key: string) => Effect.succeed(`value-of-${key}`)
    }
  })
)

// ApiClient зависит от ConfigService
const ApiClientLive = Layer.scoped(
  ApiClient,
  Effect.gen(function* () {
    const config = yield* ConfigService
    const baseUrl = yield* config.get("api.baseUrl")
    yield* Console.log(`[ApiClient] Initialized with baseUrl: ${baseUrl}`)
    yield* Effect.addFinalizer(() =>
      Console.log("[ApiClient] Closing connections...")
    )
    return {
      fetch: (url: string) =>
        Effect.succeed(`Response from ${baseUrl}/${url}`)
    }
  })
)

// Композиция: ApiClient → ConfigService
const AppLayer = ApiClientLive.pipe(Layer.provide(ConfigLive))

const program = Effect.gen(function* () {
  const api = yield* ApiClient
  const result = yield* api.fetch("users")
  yield* Console.log(`Result: ${result}`)
})

Effect.runPromise(program.pipe(Effect.provide(AppLayer)))
/*
  [Config] Loading configuration...
  [ApiClient] Initialized with baseUrl: value-of-api.baseUrl
  Result: Response from value-of-api.baseUrl/users
  [ApiClient] Closing connections...
  [Config] Cleanup
*/

Пример 3: Scoped layer с acquireRelease и зависимостями


class DbConfig extends Context.Tag("DbConfig")<
  DbConfig,
  { readonly connectionString: string; readonly poolSize: number }
>() {}

class DbPool extends Context.Tag("DbPool")<
  DbPool,
  {
    readonly query: (sql: string) => Effect.Effect<string>
    readonly activeConnections: Effect.Effect<number>
  }
>() {}

class DbPoolError extends Data.TaggedError("DbPoolError")<{
  readonly message: string
}> {}

const DbConfigLive = Layer.succeed(DbConfig, {
  connectionString: "postgres://localhost:5432/mydb",
  poolSize: 20
})

const DbPoolLive = Layer.scoped(
  DbPool,
  Effect.gen(function* () {
    const config = yield* DbConfig

    // Acquire pool
    yield* Console.log(
      `[Pool] Connecting to ${config.connectionString}, ` +
      `pool size: ${config.poolSize}`
    )

    let activeCount = 0

    yield* Effect.addFinalizer(() =>
      Console.log(`[Pool] Draining ${activeCount} active connections...`)
    )

    return {
      query: (sql: string) =>
        Effect.gen(function* () {
          activeCount++
          yield* Effect.sleep("10 millis") // Simulate query
          activeCount--
          return `Result of: ${sql}`
        }),
      activeConnections: Effect.sync(() => activeCount)
    }
  })
)

const AppLayer = DbPoolLive.pipe(Layer.provide(DbConfigLive))

const program = Effect.gen(function* () {
  const pool = yield* DbPool
  const r1 = yield* pool.query("SELECT 1")
  const r2 = yield* pool.query("SELECT 2")
  yield* Console.log(`Results: ${r1}, ${r2}`)
})

Effect.runPromise(program.pipe(Effect.provide(AppLayer)))

Распространённые ошибки

Ошибка 1: Двойной Effect.scoped


const resource = Effect.acquireRelease(
  Effect.succeed("data"),
  () => Console.log("released")
)

// ❌ Лишний scoped — ресурс освобождается сразу после acquire
const bad = Effect.scoped(
  Effect.gen(function* () {
    // Вложенный scoped создаёт внутренний scope
    const data = yield* Effect.scoped(resource)
    // Здесь ресурс уже освобождён!
    yield* Console.log(`Using: ${data}`) // Потенциально опасно
  })
)

// ✅ Правильно: один scoped на верхнем уровне
const good = Effect.scoped(
  Effect.gen(function* () {
    const data = yield* resource
    yield* Console.log(`Using: ${data}`)
  })
)

Ошибка 2: Забыть, что Layer.scoped управляет scope автоматически


class MyService extends Context.Tag("MyService")<
  MyService,
  { readonly doWork: Effect.Effect<void> }
>() {}

const MyServiceLive = Layer.scoped(
  MyService,
  Effect.gen(function* () {
    yield* Effect.addFinalizer(() => Effect.log("cleanup"))
    return { doWork: Effect.log("working") }
  })
)

// ❌ Не нужно оборачивать в Effect.scoped — Layer уже управляет scope
// Effect.runPromise(
//   Effect.scoped(program.pipe(Effect.provide(MyServiceLive)))
// )

// ✅ Просто provide — scope управляется внутри Layer
Effect.runPromise(
  Effect.gen(function* () {
    const svc = yield* MyService
    yield* svc.doWork
  }).pipe(Effect.provide(MyServiceLive))
)

Ошибка 3: Возвращать scoped-эффект из use


// ❌ acquireRelease вернёт Effect<..., Scope>, а use ожидает использование
const bad = Effect.acquireUseRelease(
  Effect.succeed("resource"),
  // Use возвращает Effect, требующий Scope — но scope уже закрыт
  (res) => Effect.addFinalizer(() => Console.log("nested finalizer")),
  () => Console.log("release")
)

Упражнения

Упражнение

Упражнение 1: Простой Effect.scoped

Легко

Создайте эффект, который добавляет два финализатора и возвращает строку. Оберните в Effect.scoped и убедитесь, что финализаторы выполняются в LIFO-порядке.

import { Effect, Console } from "effect"

const exercise1 = Effect.scoped(
  Effect.gen(function* () {
    yield* Effect.addFinalizer(() => Console.log("First added"))
    yield* Effect.addFinalizer(() => Console.log("Second added"))
    return "done"
  })
)

Effect.runPromise(exercise1).then(console.log)
// Second added
// First added
// done
Упражнение

Упражнение 2: Scoped layer для сервиса

Легко

Создайте scoped layer для HealthChecker — сервиса, который при создании логирует “Health checker started”, а при закрытии — “Health checker stopped”.

import { Effect, Layer, Console, Context } from "effect"

class HealthChecker extends Context.Tag("HealthChecker")<
  HealthChecker,
  { readonly check: Effect.Effect<boolean> }
>() {}

const HealthCheckerLive = Layer.scoped(
  HealthChecker,
  Effect.gen(function* () {
    yield* Console.log("Health checker started")
    yield* Effect.addFinalizer(() => Console.log("Health checker stopped"))
    return { check: Effect.succeed(true) }
  })
)

const program = Effect.gen(function* () {
  const hc = yield* HealthChecker
  const healthy = yield* hc.check
  yield* Console.log(`System healthy: ${healthy}`)
})

Effect.runPromise(program.pipe(Effect.provide(HealthCheckerLive)))
// Health checker started
// System healthy: true
// Health checker stopped
Упражнение

Упражнение 3: Два scoped-сервиса с зависимостями

Средне

Создайте Logger (scoped) и UserService (scoped, зависит от Logger). Убедитесь в правильном порядке инициализации и очистки.

import { Effect, Layer, Console, Context } from "effect"

class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly info: (msg: string) => Effect.Effect<void> }
>() {}

class UserService extends Context.Tag("UserService")<
  UserService,
  { readonly getUser: (id: string) => Effect.Effect<string> }
>() {}

const LoggerLive = Layer.scoped(
  Logger,
  Effect.gen(function* () {
    yield* Console.log("[Logger] Initialized")
    yield* Effect.addFinalizer(() => Console.log("[Logger] Shutdown"))
    return { info: (msg: string) => Console.log(`[LOG] ${msg}`) }
  })
)

const UserServiceLive = Layer.scoped(
  UserService,
  Effect.gen(function* () {
    const logger = yield* Logger
    yield* logger.info("UserService starting...")
    yield* Effect.addFinalizer(() =>
      logger.info("UserService shutting down...")
    )
    return {
      getUser: (id: string) =>
        Effect.gen(function* () {
          yield* logger.info(`Fetching user ${id}`)
          return `User-${id}`
        })
    }
  })
)

const AppLayer = UserServiceLive.pipe(Layer.provide(LoggerLive))

const program = Effect.gen(function* () {
  const users = yield* UserService
  const user = yield* users.getUser("42")
  yield* Console.log(`Found: ${user}`)
})

Effect.runPromise(program.pipe(Effect.provide(AppLayer)))
/*
  [Logger] Initialized
  [LOG] UserService starting...
  [LOG] Fetching user 42
  Found: User-42
  [LOG] UserService shutting down...
  [Logger] Shutdown
*/
Упражнение

Упражнение 4: Application bootstrap с несколькими scoped layers

Сложно

Создайте полную application bootstrap с тремя scoped сервисами: Config, Database, HttpServer. Каждый должен логировать инициализацию и очистку. Database зависит от Config, HttpServer зависит от Database. Убедитесь в корректном порядке lifecycle.

import { Effect, Layer, Console, Context } from "effect"

class Config extends Context.Tag("Config")<Config, {
  readonly dbUrl: string
  readonly port: number
}>() {}

class Database extends Context.Tag("Database")<Database, {
  readonly query: (sql: string) => Effect.Effect<string>
}>() {}

class HttpServer extends Context.Tag("HttpServer")<HttpServer, {
  readonly start: Effect.Effect<void>
}>() {}

const ConfigLive = Layer.scoped(
  Config,
  Effect.gen(function* () {
    yield* Console.log("[1/3] Loading config...")
    yield* Effect.addFinalizer(() => Console.log("[3/3] Config released"))
    return { dbUrl: "postgres://localhost/db", port: 3000 }
  })
)

const DatabaseLive = Layer.scoped(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    yield* Console.log(`[2/3] Connecting to ${config.dbUrl}...`)
    yield* Effect.addFinalizer(() =>
      Console.log("[2/3] Database disconnected")
    )
    return {
      query: (sql: string) => Effect.succeed(`Result: ${sql}`)
    }
  })
)

const HttpServerLive = Layer.scoped(
  HttpServer,
  Effect.gen(function* () {
    const config = yield* Config
    const db = yield* Database
    yield* Console.log(`[3/3] Starting HTTP server on :${config.port}...`)
    yield* Effect.addFinalizer(() =>
      Console.log("[1/3] HTTP server stopped")
    )
    return {
      start: Console.log(`Server ready on :${config.port}`)
    }
  })
)

const AppLayer = HttpServerLive.pipe(
  Layer.provide(Layer.merge(DatabaseLive, ConfigLive).pipe(
    Layer.provide(ConfigLive)
  ))
)

// Более точная композиция:
const AppLayerV2 = HttpServerLive.pipe(
  Layer.provide(DatabaseLive),
  Layer.provide(ConfigLive)
)

const program = Effect.gen(function* () {
  const server = yield* HttpServer
  yield* server.start
  yield* Console.log("Application running!")
})

Effect.runPromise(program.pipe(Effect.provide(AppLayerV2)))
/*
  [1/3] Loading config...
  [2/3] Connecting to postgres://localhost/db...
  [3/3] Starting HTTP server on :3000...
  Server ready on :3000
  Application running!
  [1/3] HTTP server stopped    ← LIFO: последний инициализирован — первый остановлен
  [2/3] Database disconnected
  [3/3] Config released
*/