Effect Курс Паттерны определения сервисов

Паттерны определения сервисов

Различные подходы к определению сервисов в Effect.

Теория

Что такое сервис в Effect?

Сервис — это переиспользуемый компонент, предоставляющий определённую функциональность приложению. В Effect сервисы имеют три ключевых характеристика:

  1. Интерфейс — контракт, описывающий доступные операции
  2. Реализация — конкретный код, выполняющий операции
  3. Tag — уникальный идентификатор для системы DI
// Интерфейс сервиса
interface UserService {
  readonly findById: (id: string) => Effect.Effect<User | null, DatabaseError>
  readonly create: (data: CreateUserDto) => Effect.Effect<User, ValidationError | DatabaseError>
}

// Tag (идентификатор)
class UserService extends Context.Tag("UserService")<UserService, UserServiceInterface>() {}

// Реализация
const UserServiceImpl: UserServiceInterface = {
  findById: (id) => Effect.gen(function* () { /* ... */ }),
  create: (data) => Effect.gen(function* () { /* ... */ })
}

Три основных паттерна определения сервисов

Effect предлагает несколько паттернов для определения сервисов, каждый со своими преимуществами:

📊 Сравнение паттернов:

ПаттернBoilerplateТипобезопасностьКогда использовать
Context.TagМинимальный✅ ПолнаяБиблиотеки, сервисы без default реализации
Effect.ServiceСредний✅ ПолнаяApplication services с default реализацией
Effect.TagМинимальный✅ ПолнаяКогда нужен Tag без Layer

Паттерн 1: Context.Tag (Классический)

Базовый паттерн для определения сервисов. Предоставляет только идентификатор без готовой реализации.


class ConfigError extends Data.TaggedError("app/ConfigError")<{
  readonly key: string
}> {}

// 1. Определяем интерфейс (можно inline или отдельно)
interface ConfigService {
  readonly get: (key: string) => Effect.Effect<string | undefined>
  readonly getOrFail: (key: string) => Effect.Effect<string, ConfigError>
  readonly getAll: () => Effect.Effect<Readonly<Record<string, string>>>
}

// 2. Создаём Tag
class Config extends Context.Tag("app/Config")<
  Config, // Идентификатор тега
  ConfigService // Интерфейс сервиса
>() {}

// 3. Реализация создаётся отдельно
const liveConfigService: ConfigService = {
  get: (key) => Effect.sync(() => process.env[key]),
  getOrFail: (key) =>
    Effect.gen(function* () {
      const value = process.env[key]
      if (value === undefined) {
        return yield* Effect.fail(new ConfigError({ key }))
      }
      return value
    }),
  getAll: () => Effect.sync(() => ({ ...process.env }) as Record<string, string>),
}

// 4. Предоставление сервиса
const program = Effect.gen(function* () {
  const config = yield* Config
  const apiUrl = yield* config.getOrFail("API_URL")
  return apiUrl
})

const runnable = program.pipe(Effect.provideService(Config, liveConfigService))

💡 Когда использовать Context.Tag:

  • Библиотеки, где реализация предоставляется пользователем
  • Сервисы, которые всегда требуют явной конфигурации
  • Когда нет “разумного” default значения

Паттерн 2: Effect.Service (Рекомендуемый для приложений)

Effect.Service — синтаксический сахар, объединяющий Tag, реализацию и Layer в одном определении.


// Всё в одном определении
class Logger extends Effect.Service<Logger>()("app/Logger", {
  // Одна из конструкторских функций: succeed, sync, effect, scoped
  effect: Effect.gen(function* () {
    const logLevel = yield* Config.string("LOG_LEVEL").pipe(Config.withDefault("info"))

    return {
      info: (msg: string) =>
        Effect.sync(() => {
          if (logLevel !== "error") console.log(`[INFO] ${msg}`)
        }),
      error: (msg: string, err?: unknown) =>
        Effect.sync(() => {
          console.error(`[ERROR] ${msg}`, err)
        }),
      debug: (msg: string) =>
        Effect.sync(() => {
          if (logLevel === "debug") console.log(`[DEBUG] ${msg}`)
        }),
    }
  }),
}) {}

// Автоматически создаётся:
// - Logger (Tag)
// - Logger.Default (Layer)
// - Logger.DefaultWithoutDependencies (если есть зависимости)

const program = Effect.gen(function* () {
  const logger = yield* Logger
  yield* logger.info("Application started")
}).pipe(Effect.withConfigProvider(ConfigProvider.fromEnv()))

// Использование с автоматическим Layer
Effect.runPromise(program.pipe(Effect.provide(Logger.Default)))

📊 Варианты конструкторов в Effect.Service:

КонструкторОписаниеКогда использовать
succeedСинхронное значениеСтатическая конфигурация
syncСинхронная функцияПростая инициализация
effectЭффективная функцияКогда нужны другие сервисы или эффекты
scopedС управлением ресурсамиСервисы с lifecycle (connections, pools)

Пример с succeed:

class MagicNumber extends Effect.Service<MagicNumber>()("MagicNumber", {
  succeed: { value: 42 }
}) {}

Пример с sync:

class Random extends Effect.Service<Random>()("Random", {
  sync: () => ({
    nextInt: () => Effect.sync(() => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
    nextFloat: () => Effect.sync(() => Math.random())
  })
}) {}

Пример с effect:

class UserRepository extends Effect.Service<UserRepository>()("UserRepository", {
  effect: Effect.gen(function* () {
    const database = yield* Database  // Зависимость
    const logger = yield* Logger      // Другая зависимость
    
    return {
      findById: (id: string) => Effect.gen(function* () {
        yield* logger.debug(`Finding user: ${id}`)
        return yield* database.query(`SELECT * FROM users WHERE id = $1`, [id])
      }),
      create: (user: CreateUserDto) => Effect.gen(function* () {
        yield* logger.info(`Creating user: ${user.email}`)
        return yield* database.query(`INSERT INTO users ...`, [user])
      })
    }
  }),
  // Указываем зависимости для автоматического включения в Layer
  dependencies: [Database.Default, Logger.Default]
}) {}

Пример с scoped:

class DatabaseConnection extends Effect.Service<DatabaseConnection>()("DatabaseConnection", {
  scoped: Effect.gen(function* () {
    const config = yield* Config
    
    // Acquire: создаём соединение
    const connection = yield* Effect.acquireRelease(
      Effect.promise(() => createConnection(config.databaseUrl)),
      (conn) => Effect.promise(() => conn.close())
    )
    
    yield* Effect.addFinalizer(() => 
      Effect.sync(() => console.log("Database connection closing..."))
    )
    
    return {
      query: (sql: string) => Effect.promise(() => connection.execute(sql)),
      transaction: <A>(effect: Effect.Effect<A>) => 
        Effect.scoped(/* transaction logic */)
    }
  })
}) {}

Паттерн 3: Effect.Tag (Упрощённый Tag)

Effect.Tag — это упрощённая версия Context.Tag, где поля сервиса становятся статическими свойствами класса.


class Notifications extends Effect.Tag("Notifications")<
  Notifications,
  {
    readonly send: (userId: string, message: string) => Effect.Effect<void>
    readonly sendBulk: (userIds: ReadonlyArray<string>, message: string) => Effect.Effect<void>
  }
>() {}

// Методы сервиса доступны как статические свойства
const sendWelcome = (userId: string) => 
  Notifications.send(userId, "Welcome!")  // Возвращает Effect, требующий Notifications

// Эквивалентно:
const sendWelcomeExplicit = Effect.gen(function* () {
  const notifications = yield* Notifications
  yield* notifications.send(userId, "Welcome!")
})

💡 Разница между Context.Tag и Effect.Tag:

// Context.Tag — нужно извлекать сервис
const program1 = Effect.gen(function* () {
  const notifications = yield* Notifications
  yield* notifications.send("user-1", "Hello")
})

// Effect.Tag — можно вызывать напрямую через статические свойства
const program2 = Notifications.send("user-1", "Hello")

Direct Method Access в Effect.Service

Effect.Service поддерживает опцию accessors: true для прямого доступа к методам:

class Cache extends Effect.Service<Cache>()("Cache", {
  effect: Effect.succeed({
    get: (key: string) => Effect.succeed(null as string | null),
    set: (key: string, value: string) => Effect.void,
    delete: (key: string) => Effect.succeed(true)
  }),
  accessors: true  // ← Включаем прямой доступ
}) {}

// Теперь можно так:
const program = Cache.get("myKey")  // Effect<string | null, never, Cache>

// Вместо:
const programExplicit = Effect.gen(function* () {
  const cache = yield* Cache
  return yield* cache.get("myKey")
})

⚠️ Ограничение: Direct method access не работает с generic методами.


Паттерн “Interface + Implementation” (Production-ready)

Для production-кода рекомендуется разделять интерфейс и реализацию:

// 1. types.ts — только типы и Tag

// Ошибки домена
export class OrderNotFoundError extends Data.TaggedError("OrderNotFoundError")<{
  readonly orderId: string
}> {}

export class InsufficientStockError extends Data.TaggedError("InsufficientStockError")<{
  readonly productId: string
  readonly requested: number
  readonly available: number
}> {}

// Интерфейс сервиса
export interface OrderService {
  readonly create: (items: ReadonlyArray<OrderItem>) => Effect.Effect<Order, InsufficientStockError>
  readonly findById: (id: string) => Effect.Effect<Order, OrderNotFoundError>
  readonly cancel: (id: string) => Effect.Effect<void, OrderNotFoundError>
  readonly listByUser: (userId: string) => Effect.Effect<ReadonlyArray<Order>>
}

// Tag
export class OrderService extends Context.Tag("OrderService")<
  OrderService,
  OrderServiceInterface
>() {}
// 2. implementation.ts — реализация

const make: Effect.Effect<OrderServiceInterface, never, Database | Logger | Inventory> = 
  Effect.gen(function* () {
    const db = yield* Database
    const logger = yield* Logger
    const inventory = yield* Inventory
    
    return {
      create: (items) => Effect.gen(function* () {
        yield* logger.info("Creating order", { itemCount: items.length })
        
        // Проверяем наличие на складе
        for (const item of items) {
          const stock = yield* inventory.getStock(item.productId)
          if (stock < item.quantity) {
            return yield* Effect.fail(new InsufficientStockError({
              productId: item.productId,
              requested: item.quantity,
              available: stock
            }))
          }
        }
        
        // Создаём заказ
        const order = yield* db.orders.create({ items, status: "pending" })
        yield* logger.info("Order created", { orderId: order.id })
        return order
      }),
      
      findById: (id) => Effect.gen(function* () {
        const order = yield* db.orders.findById(id)
        if (!order) {
          return yield* Effect.fail(new OrderNotFoundError({ orderId: id }))
        }
        return order
      }),
      
      cancel: (id) => Effect.gen(function* () {
        const order = yield* db.orders.findById(id)
        if (!order) {
          return yield* Effect.fail(new OrderNotFoundError({ orderId: id }))
        }
        yield* db.orders.update(id, { status: "cancelled" })
        yield* logger.info("Order cancelled", { orderId: id })
      }),
      
      listByUser: (userId) => db.orders.findByUserId(userId)
    }
  })

// Layer для production
export const OrderServiceLive = Layer.effect(OrderService, make)

// Layer для тестов
export const OrderServiceTest = Layer.succeed(OrderService, {
  create: () => Effect.succeed({ id: "test-order", items: [], status: "pending" }),
  findById: () => Effect.succeed({ id: "test-order", items: [], status: "pending" }),
  cancel: () => Effect.void,
  listByUser: () => Effect.succeed([])
})
// 3. index.ts — публичный API модуля
export { OrderService, OrderNotFoundError, InsufficientStockError } from "./types"
export { OrderServiceLive, OrderServiceTest } from "./implementation"

Паттерн “Capability” (Гранулярные сервисы)

Вместо одного большого сервиса создаём набор гранулярных “capabilities”:


// Гранулярные capabilities
class CanReadUsers extends Context.Tag("CanReadUsers")<
  CanReadUsers,
  { readonly findById: (id: string) => Effect.Effect<User | null> }
>() {}

class CanWriteUsers extends Context.Tag("CanWriteUsers")<
  CanWriteUsers,
  { readonly save: (user: User) => Effect.Effect<User> }
>() {}

class CanDeleteUsers extends Context.Tag("CanDeleteUsers")<
  CanDeleteUsers,
  { readonly delete: (id: string) => Effect.Effect<boolean> }
>() {}

// Функция, требующая только чтение
const findUser = (id: string): Effect.Effect<User | null, never, CanReadUsers> =>
  Effect.gen(function* () {
    const reader = yield* CanReadUsers
    return yield* reader.findById(id)
  })

// Функция, требующая чтение и запись
const updateUser = (id: string, data: Partial<User>): Effect.Effect<User, UserNotFound, CanReadUsers | CanWriteUsers> =>
  Effect.gen(function* () {
    const reader = yield* CanReadUsers
    const writer = yield* CanWriteUsers
    
    const user = yield* reader.findById(id)
    if (!user) return yield* Effect.fail(new UserNotFound({ id }))
    
    const updated = { ...user, ...data }
    return yield* writer.save(updated)
  })

// Admin-only функция
const purgeUser = (id: string): Effect.Effect<void, UserNotFound, CanReadUsers | CanWriteUsers | CanDeleteUsers> =>
  Effect.gen(function* () {
    const reader = yield* CanReadUsers
    const deleter = yield* CanDeleteUsers
    
    const user = yield* reader.findById(id)
    if (!user) return yield* Effect.fail(new UserNotFound({ id }))
    
    yield* deleter.delete(id)
  })

💡 Преимущества Capability pattern:

  • Принцип минимальных привилегий
  • Лёгкое тестирование (мокаем только нужные capabilities)
  • Гранулярный контроль доступа
  • Чёткие границы ответственности

Паттерн “Service with State” (Stateful сервисы)

Для сервисов с внутренним состоянием используйте Ref:


interface Session {
  readonly id: string
  readonly userId: string
  readonly createdAt: Date
}

interface SessionServiceInterface {
  readonly get: (sessionId: string) => Effect.Effect<Session | null>
  readonly set: (sessionId: string, session: Session) => Effect.Effect<void>
  readonly delete: (sessionId: string) => Effect.Effect<boolean>
  readonly getStats: () => Effect.Effect<{ activeSessions: number }>
}

class SessionService extends Context.Tag("SessionService")<
  SessionService,
  SessionServiceInterface
>() {}

// Реализация с Ref для состояния
const makeInMemorySessionService: Effect.Effect<SessionServiceInterface> = Effect.gen(function* () {
  // Внутреннее состояние
  const sessionsRef = yield* Ref.make<ReadonlyMap<string, Session>>(new Map())

  return {
    get: (sessionId) =>
      Ref.get(sessionsRef).pipe(Effect.map((sessions) => sessions.get(sessionId) ?? null)),

    set: (sessionId, session) =>
      Ref.update(sessionsRef, (sessions) => {
        const newSessions = new Map(sessions)
        newSessions.set(sessionId, session)
        return newSessions
      }),

    delete: (sessionId) =>
      Ref.modify(sessionsRef, (sessions) => {
        const existed = sessions.has(sessionId)
        const newSessions = new Map(sessions)
        newSessions.delete(sessionId)
        return [existed, newSessions]
      }),

    getStats: () =>
      Ref.get(sessionsRef).pipe(Effect.map((sessions) => ({ activeSessions: sessions.size }))),
  }
})

const SessionServiceLive = Layer.effect(SessionService, makeInMemorySessionService)

Концепция ФП: Type Classes и Интерфейсы

Type Classes в функциональном программировании

В Haskell и Scala type class — это механизм полиморфизма, позволяющий добавлять поведение к типам без изменения их определения.

-- Haskell type class
class Monoid a where
  mempty :: a
  mappend :: a -> a -> a

-- Инстанс для списка
instance Monoid [a] where
  mempty = []
  mappend = (++)

В TypeScript мы эмулируем type classes через интерфейсы и теги:

// TypeScript "type class" через Effect
interface Monoid<A> {
  readonly empty: A
  readonly combine: (x: A, y: A) => A
}

class MonoidService extends Context.Tag("Monoid")<
  MonoidService,
  Monoid<unknown>
>() {}

// "Instance" для string
const stringMonoid: Monoid<string> = {
  empty: "",
  combine: (x, y) => x + y
}

// "Instance" для number (сложение)
const sumMonoid: Monoid<number> = {
  empty: 0,
  combine: (x, y) => x + y
}

Связь с паттерном Service

Сервисы в Effect — это по сути type class instances, предоставляемые через DI:

// "Type class" для логирования
interface Logger {
  readonly log: (msg: string) => Effect.Effect<void>
}

// Программа, полиморфная по Logger
const program = <R extends Logger>(logger: R) => 
  logger.log("Hello")

// В Effect это выглядит так:
class Logger extends Context.Tag("Logger")<Logger, LoggerInterface>() {}

const programEffect = Effect.gen(function* () {
  const logger = yield* Logger  // "instance" будет предоставлен позже
  yield* logger.log("Hello")
})

// Разные "instances" (реализации)
const consoleLoggerInstance: LoggerInterface = { log: (m) => Effect.sync(() => console.log(m)) }
const noopLoggerInstance: LoggerInterface = { log: () => Effect.void }

Coherence и глобальность тегов

В теории type classes важен принцип coherence — для каждого типа должен быть только один instance type class. Effect обеспечивает это через уникальные идентификаторы тегов:

// ⚠️ Два тега с одинаковым идентификатором будут конфликтовать
class Logger1 extends Context.Tag("Logger")<Logger1, { log: () => void }>() {}
class Logger2 extends Context.Tag("Logger")<Logger2, { log: () => void }>() {}

// Logger1 и Logger2 будут указывать на один и тот же сервис в Context!
// Это поведение by design для поддержки hot reload

// ✅ Используйте уникальные идентификаторы
class Logger extends Context.Tag("@myapp/services/Logger")<Logger, LoggerInterface>() {}

API Reference

Effect.Service [STABLE]

Создаёт сервис с Tag и автоматическим Layer.

declare class Service<Self>() {
  (id: string, options: ServiceOptions): ServiceClass<Self>
}

interface ServiceOptions {
  // Одна из конструкторских функций (обязательно одна):
  readonly succeed?: ServiceInterface
  readonly sync?: () => ServiceInterface
  readonly effect?: Effect.Effect<ServiceInterface, any, any>
  readonly scoped?: Effect.Effect<ServiceInterface, any, any>
  
  // Опции:
  readonly accessors?: boolean  // Прямой доступ к методам
  readonly dependencies?: ReadonlyArray<Layer.Layer<any, any, any>>
}

Автоматически создаваемые свойства:

СвойствоТипОписание
DefaultLayer<Self>Layer с включёнными зависимостями
DefaultWithoutDependenciesLayer<Self, E, R>Layer без зависимостей

Effect.Tag [STABLE]

Создаёт Tag с прямым доступом к методам сервиса.

declare class Tag<Id, Service>(id: string): TagClass<Id, Service>

// Методы сервиса становятся статическими свойствами
class MyService extends Effect.Tag("MyService")<
  MyService,
  { readonly method: (x: number) => Effect.Effect<string> }
>() {}

// MyService.method доступен как статическое свойство
const result: Effect.Effect<string, never, MyService> = MyService.method(42)

Context.Tag [STABLE]

Базовый механизм создания тегов.

declare class Tag<Id, Service>(id: string): {
  readonly _tag: Id
  readonly [Symbol.iterator]: () => Generator<Tag<Id, Service>, Service, unknown>
}

Layer.effect [STABLE]

Создаёт Layer из эффекта.

declare const effect: <T extends Tag<any, any>, E, R>(
  tag: T,
  effect: Effect.Effect<Tag.Service<T>, E, R>
) => Layer<Tag.Identifier<T>, E, R>

Layer.succeed [STABLE]

Создаёт Layer из синхронного значения.

declare const succeed: <T extends Tag<any, any>>(
  tag: T,
  service: Tag.Service<T>
) => Layer<Tag.Identifier<T>>

Layer.sync [STABLE]

Создаёт Layer из синхронной функции.

declare const sync: <T extends Tag<any, any>>(
  tag: T,
  evaluate: () => Tag.Service<T>
) => Layer<Tag.Identifier<T>>

Layer.scoped [STABLE]

Создаёт Layer с управлением ресурсами.

declare const scoped: <T extends Tag<any, any>, E, R>(
  tag: T,
  effect: Effect.Effect<Tag.Service<T>, E, R>
) => Layer<Tag.Identifier<T>, E, Exclude<R, Scope>>

Примеры

Пример 1: CRUD сервис с Context.Tag


// Ошибки
class EntityNotFoundError extends Data.TaggedError("EntityNotFoundError")<{
  readonly entity: string
  readonly id: string
}> {}

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

// Интерфейс
interface CrudService<T extends { id: string }> {
  readonly findById: (id: string) => Effect.Effect<T, EntityNotFoundError>
  readonly findAll: () => Effect.Effect<ReadonlyArray<T>>
  readonly create: (data: Omit<T, "id">) => Effect.Effect<T, ValidationError>
  readonly update: (id: string, data: Partial<Omit<T, "id">>) => Effect.Effect<T, EntityNotFoundError | ValidationError>
  readonly delete: (id: string) => Effect.Effect<void, EntityNotFoundError>
}

// Модель
interface Product {
  readonly id: string
  readonly name: string
  readonly price: number
  readonly stock: number
}

// Tag
class ProductService extends Context.Tag("ProductService")<
  ProductService,
  CrudService<Product>
>() {}

// In-memory реализация
const makeInMemoryProductService = Effect.gen(function* () {
  const products = yield* Ref.make<ReadonlyMap<string, Product>>(new Map())
  let nextId = 1
  
  const generateId = () => `product-${nextId++}`
  
  const service: CrudService<Product> = {
    findById: (id) => Effect.gen(function* () {
      const map = yield* Ref.get(products)
      const product = map.get(id)
      if (!product) {
        return yield* Effect.fail(new EntityNotFoundError({ entity: "Product", id }))
      }
      return product
    }),
    
    findAll: () => Ref.get(products).pipe(Effect.map((m) => Array.from(m.values()))),
    
    create: (data) => Effect.gen(function* () {
      if (data.price < 0) {
        return yield* Effect.fail(new ValidationError({ 
          field: "price", 
          message: "Price must be non-negative" 
        }))
      }
      
      const product: Product = { id: generateId(), ...data }
      yield* Ref.update(products, (m) => new Map(m).set(product.id, product))
      return product
    }),
    
    update: (id, data) => Effect.gen(function* () {
      const existing = yield* service.findById(id)
      
      if (data.price !== undefined && data.price < 0) {
        return yield* Effect.fail(new ValidationError({ 
          field: "price", 
          message: "Price must be non-negative" 
        }))
      }
      
      const updated: Product = { ...existing, ...data }
      yield* Ref.update(products, (m) => new Map(m).set(id, updated))
      return updated
    }),
    
    delete: (id) => Effect.gen(function* () {
      yield* service.findById(id)  // Проверяем существование
      yield* Ref.update(products, (m) => {
        const newMap = new Map(m)
        newMap.delete(id)
        return newMap
      })
    })
  }
  
  return service
})

// Layer
const ProductServiceLive = Layer.effect(ProductService, makeInMemoryProductService)

Пример 2: Effect.Service с зависимостями


// Зависимости
class HttpClient extends Effect.Service<HttpClient>()("HttpClient", {
  succeed: {
    get: (url: string) => Effect.tryPromise(() => fetch(url).then((r) => r.json())),
    post: (url: string, body: unknown) => Effect.tryPromise(() => 
      fetch(url, { method: "POST", body: JSON.stringify(body) }).then((r) => r.json())
    )
  }
}) {}

class Logger extends Effect.Service<Logger>()("Logger", {
  succeed: {
    info: (msg: string) => Effect.sync(() => console.log(`[INFO] ${msg}`)),
    error: (msg: string) => Effect.sync(() => console.error(`[ERROR] ${msg}`))
  }
}) {}

// Сервис с зависимостями
class WeatherService extends Effect.Service<WeatherService>()("WeatherService", {
  effect: Effect.gen(function* () {
    const http = yield* HttpClient
    const logger = yield* Logger
    const apiKey = yield* Config.string("WEATHER_API_KEY")
    
    return {
      getCurrentWeather: (city: string) => Effect.gen(function* () {
        yield* logger.info(`Fetching weather for ${city}`)
        
        const url = `https://api.weather.com/v1/current?city=${city}&key=${apiKey}`
        const data = yield* http.get(url)
        
        yield* logger.info(`Weather fetched for ${city}: ${data.temperature}°C`)
        return data as WeatherData
      }),
      
      getForecast: (city: string, days: number) => Effect.gen(function* () {
        yield* logger.info(`Fetching ${days}-day forecast for ${city}`)
        
        const url = `https://api.weather.com/v1/forecast?city=${city}&days=${days}&key=${apiKey}`
        return yield* http.get(url) as Effect.Effect<ReadonlyArray<ForecastDay>>
      })
    }
  }),
  dependencies: [HttpClient.Default, Logger.Default]
}) {}

// Использование
const program = Effect.gen(function* () {
  const weather = yield* WeatherService
  const currentWeather = yield* weather.getCurrentWeather("London")
  console.log(`Temperature: ${currentWeather.temperature}°C`)
})

// WeatherService.Default включает все зависимости
Effect.runPromise(program.pipe(Effect.provide(WeatherService.Default)))

Пример 3: Scoped сервис с lifecycle


interface DatabaseConnection {
  readonly query: <T>(sql: string, params?: ReadonlyArray<unknown>) => Effect.Effect<T>
  readonly transaction: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}

class Database extends Effect.Service<Database>()("Database", {
  scoped: Effect.gen(function* () {
    // Acquire: создаём пул соединений
    console.log("Creating database connection pool...")
    const pool = yield* Effect.acquireRelease(
      Effect.sync(() => ({ connections: [] as any[], maxSize: 10 })),
      (pool) => Effect.sync(() => {
        console.log("Closing database connection pool...")
        pool.connections.forEach((c) => c.close?.())
      })
    )
    
    // Финализатор для логирования
    yield* Effect.addFinalizer(() => 
      Console.log("Database service shutting down...")
    )
    
    return {
      query: <T>(sql: string, params?: ReadonlyArray<unknown>) =>
        Effect.gen(function* () {
          console.log(`Executing: ${sql}`)
          // Симуляция запроса
          return { rows: [] } as T
        }),
      
      transaction: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
        Effect.gen(function* () {
          console.log("BEGIN TRANSACTION")
          const result = yield* effect.pipe(
            Effect.onError(() => Effect.sync(() => console.log("ROLLBACK")))
          )
          console.log("COMMIT")
          return result
        })
    }
  })
}) {}

// Использование с автоматическим cleanup
const program = Effect.gen(function* () {
  const db = yield* Database
  
  const users = yield* db.query("SELECT * FROM users")
  console.log("Users:", users)
  
  yield* db.transaction(
    Effect.gen(function* () {
      yield* db.query("INSERT INTO users (name) VALUES (?)", ["Alice"])
      yield* db.query("INSERT INTO users (name) VALUES (?)", ["Bob"])
    })
  )
})

// Database.Default автоматически управляет lifecycle
Effect.runPromise(program.pipe(Effect.provide(Database.Default)))
// Output:
// Creating database connection pool...
// Executing: SELECT * FROM users
// Users: { rows: [] }
// BEGIN TRANSACTION
// Executing: INSERT INTO users (name) VALUES (?)
// Executing: INSERT INTO users (name) VALUES (?)
// COMMIT
// Database service shutting down...
// Closing database connection pool...

Пример 4: Capability-based сервисы


// Гранулярные capabilities
class CanSendEmail extends Context.Tag("CanSendEmail")<
  CanSendEmail,
  { 
    readonly send: (to: string, subject: string, body: string) => Effect.Effect<void, EmailError> 
  }
>() {}

class CanSendSms extends Context.Tag("CanSendSms")<
  CanSendSms,
  { 
    readonly send: (phone: string, message: string) => Effect.Effect<void, SmsError> 
  }
>() {}

class CanSendPush extends Context.Tag("CanSendPush")<
  CanSendPush,
  { 
    readonly send: (deviceToken: string, title: string, body: string) => Effect.Effect<void, PushError> 
  }
>() {}

// Функции с минимальными требованиями
const notifyByEmail = (
  userId: string, 
  message: string
): Effect.Effect<void, EmailError, CanSendEmail | UserRepository> =>
  Effect.gen(function* () {
    const userRepo = yield* UserRepository
    const emailSender = yield* CanSendEmail
    
    const user = yield* userRepo.findById(userId)
    yield* emailSender.send(user.email, "Notification", message)
  })

const notifyBySms = (
  userId: string, 
  message: string
): Effect.Effect<void, SmsError, CanSendSms | UserRepository> =>
  Effect.gen(function* () {
    const userRepo = yield* UserRepository
    const smsSender = yield* CanSendSms
    
    const user = yield* userRepo.findById(userId)
    yield* smsSender.send(user.phone, message)
  })

// Комбинированный notifier требует все capabilities
const notifyAllChannels = (
  userId: string,
  message: string
): Effect.Effect<void, EmailError | SmsError | PushError, CanSendEmail | CanSendSms | CanSendPush | UserRepository> =>
  Effect.gen(function* () {
    yield* Effect.all([
      notifyByEmail(userId, message),
      notifyBySms(userId, message),
      // Push notification
      Effect.gen(function* () {
        const userRepo = yield* UserRepository
        const pushSender = yield* CanSendPush
        const user = yield* userRepo.findById(userId)
        if (user.deviceToken) {
          yield* pushSender.send(user.deviceToken, "Notification", message)
        }
      })
    ], { concurrency: "unbounded" })
  })

Пример 5: Фабрика сервисов для multi-tenancy


// Tenant-specific сервис
interface TenantConfig {
  readonly tenantId: string
  readonly databaseUrl: string
  readonly features: ReadonlySet<string>
}

class TenantConfig extends Context.Tag("TenantConfig")<
  TenantConfig,
  TenantConfigInterface
>() {}

// Фабрика для создания tenant-specific слоёв
const makeTenantLayer = (config: TenantConfigInterface): Layer.Layer<TenantConfig> =>
  Layer.succeed(TenantConfig, config)

// Сервис, зависящий от конфигурации tenant
class TenantDatabase extends Effect.Service<TenantDatabase>()("TenantDatabase", {
  effect: Effect.gen(function* () {
    const config = yield* TenantConfig
    
    console.log(`Connecting to tenant database: ${config.tenantId}`)
    
    return {
      query: (sql: string) => Effect.gen(function* () {
        // Используем tenant-specific connection
        console.log(`[${config.tenantId}] Executing: ${sql}`)
        return []
      }),
      
      hasFeature: (feature: string) => 
        Effect.succeed(config.features.has(feature))
    }
  })
}) {}

// Request handler для конкретного tenant
const handleRequest = (tenantId: string, request: Request) => {
  const tenantConfig: TenantConfigInterface = {
    tenantId,
    databaseUrl: `postgres://tenant-${tenantId}.db.example.com/main`,
    features: new Set(["feature-a", "feature-b"])
  }
  
  const tenantLayer = makeTenantLayer(tenantConfig)
  
  return Effect.gen(function* () {
    const db = yield* TenantDatabase
    
    if (yield* db.hasFeature("feature-a")) {
      return yield* db.query("SELECT * FROM data WHERE feature = 'a'")
    }
    return []
  }).pipe(
    Effect.provide(TenantDatabase.Default),
    Effect.provide(tenantLayer)
  )
}

Упражнения

Упражнение

Создание простого сервиса с Context.Tag

Легко

Создайте сервис Calculator с методами add, subtract, multiply, divide:


// Определите тип ошибки для деления на ноль
// Создайте Tag Calculator
// Реализуйте сервис

// Проверка:
const program = Effect.gen(function* () {
  const calc = yield* Calculator
  const sum = yield* calc.add(10, 5)      // 15
  const diff = yield* calc.subtract(10, 5) // 5
  const product = yield* calc.multiply(10, 5) // 50
  const quotient = yield* calc.divide(10, 5)  // 2
  const error = yield* calc.divide(10, 0)     // DivisionByZeroError
})
Упражнение

Effect.Service с succeed

Легко

Создайте сервис AppInfo используя Effect.Service с succeed:


// Сервис должен предоставлять:
// - name: string
// - version: string  
// - environment: "development" | "staging" | "production"

// class AppInfo extends Effect.Service<AppInfo>()...

// Использование:
const program = Effect.gen(function* () {
  const app = yield* AppInfo
  console.log(`${app.name} v${app.version} (${app.environment})`)
})

Effect.runPromise(program.pipe(Effect.provide(AppInfo.Default)))
Упражнение

GenericTag

Легко

Перепишите следующий сервис с GenericTag:

class EmailValidator extends Context.Tag("EmailValidator")<
  EmailValidator,
  { readonly validate: (email: string) => Effect.Effect<boolean> }
>() {}

// Ваша версия с GenericTag:
// const EmailValidator = ...
Упражнение

Effect.Service с effect

Средне

Создайте CacheService используя Effect.Service с effect, который зависит от Logger:


// CacheService должен:
// 1. Логировать все операции через Logger
// 2. Хранить данные в Ref<Map<string, unknown>>
// 3. Предоставлять методы: get, set, delete, clear

class Logger extends Effect.Service<Logger>()("Logger", {
  succeed: { log: (msg: string) => Effect.sync(() => console.log(msg)) }
}) {}

// class CacheService extends Effect.Service<CacheService>()...
Упражнение

Capability pattern

Средне

Разбейте следующий монолитный сервис на гранулярные capabilities:

// Было:
interface UserService {
  readonly findById: (id: string) => Effect.Effect<User>
  readonly create: (data: CreateUserDto) => Effect.Effect<User>
  readonly update: (id: string, data: UpdateUserDto) => Effect.Effect<User>
  readonly delete: (id: string) => Effect.Effect<void>
  readonly changePassword: (id: string, newPassword: string) => Effect.Effect<void>
  readonly assignRole: (id: string, role: Role) => Effect.Effect<void>
}

// Должно стать:
// class CanReadUsers extends Context.Tag...
// class CanWriteUsers extends Context.Tag...
// class CanDeleteUsers extends Context.Tag...
// class CanManagePasswords extends Context.Tag...
// class CanManageRoles extends Context.Tag...
Упражнение

Scoped сервис

Средне

Создайте FileWatcher сервис, который:

  • Начинает watching директории при создании
  • Останавливает watching при закрытии scope
  • Предоставляет stream изменений

interface FileChange {
  readonly type: "created" | "modified" | "deleted"
  readonly path: string
}

// class FileWatcher extends Effect.Service<FileWatcher>()("FileWatcher", {
//   scoped: ...
// }) {}
Упражнение

Multi-implementation сервис

Сложно

Создайте систему, где один Tag может иметь разные реализации в зависимости от контекста:


// StorageService с тремя реализациями:
// 1. InMemory — для тестов
// 2. FileSystem — для локальной разработки
// 3. S3 — для production

interface StorageService {
  readonly read: (key: string) => Effect.Effect<Uint8Array, StorageError>
  readonly write: (key: string, data: Uint8Array) => Effect.Effect<void, StorageError>
  readonly delete: (key: string) => Effect.Effect<void, StorageError>
  readonly list: (prefix: string) => Effect.Effect<ReadonlyArray<string>, StorageError>
}

// Создайте Tag и три реализации
// Создайте функцию выбора реализации на основе Config
Упражнение

Decorator pattern для сервисов

Сложно

Реализуйте декораторы для сервисов:


// Декоратор логирования
const withLogging = <T extends Context.Tag<any, any>>(
  tag: T,
  layer: Layer.Layer<Context.Tag.Identifier<T>>
): Layer.Layer<Context.Tag.Identifier<T>, never, Logger> => {
  // Обернуть все методы сервиса в логирование
}

// Декоратор кэширования
const withCaching = <T extends Context.Tag<any, any>>(
  tag: T,
  layer: Layer.Layer<Context.Tag.Identifier<T>>,
  ttlMs: number
): Layer.Layer<Context.Tag.Identifier<T>, never, Clock> => {
  // Кэшировать результаты методов
}

// Декоратор retry
const withRetry = <T extends Context.Tag<any, any>>(
  tag: T,
  layer: Layer.Layer<Context.Tag.Identifier<T>>,
  maxRetries: number
): Layer.Layer<Context.Tag.Identifier<T>> => {
  // Добавить retry к методам
}
Упражнение

Service Registry

Сложно

Создайте dynamic service registry:


// Registry должен позволять:
// 1. Регистрировать сервисы динамически
// 2. Получать сервисы по имени
// 3. Отслеживать lifecycle сервисов
// 4. Поддерживать hot-reload сервисов

interface ServiceRegistry {
  readonly register: <T>(name: string, service: T) => Effect.Effect<void>
  readonly get: <T>(name: string) => Effect.Effect<T, ServiceNotFoundError>
  readonly unregister: (name: string) => Effect.Effect<boolean>
  readonly list: () => Effect.Effect<ReadonlyArray<string>>
  readonly onRegister: (callback: (name: string) => void) => Effect.Effect<void>
}

// Реализуйте ServiceRegistry

Заключение

В этой статье мы изучили:

  • 📖 Context.Tag — базовый паттерн для библиотек и сервисов без default реализации
  • 📖 Effect.Service — рекомендуемый паттерн для application сервисов
  • 📖 Effect.Tag — упрощённый доступ к методам сервиса
  • 📖 Capability pattern — гранулярные сервисы для fine-grained контроля
  • 📖 Interface + Implementation — production-ready разделение

💡 Ключевой takeaway: Выбор паттерна зависит от контекста — Context.Tag для библиотек, Effect.Service для приложений, Capability pattern для security-critical систем.