Effect Курс Композиция нескольких сервисов

Композиция нескольких сервисов

Как комбинировать сервисы в Effect.

Теория

Почему композиция сервисов важна

В реальных приложениях сервисы редко работают изолированно. Типичная бизнес-операция может требовать:

  • Чтение конфигурации
  • Логирование действий
  • Доступ к базе данных
  • Отправка уведомлений
  • Запись метрик
// Типичная программа с множеством зависимостей
const processOrder = (orderId: string): Effect.Effect<
  OrderResult,
  OrderError,
  Config | Logger | Database | EmailService | Metrics | PaymentGateway
> => Effect.gen(function* () {
  // Использует все 6 сервисов
})

Effect автоматически отслеживает и объединяет все зависимости через union типов в параметре R.

Автоматическое объединение Requirements

Когда вы комбинируете эффекты, их Requirements автоматически объединяются:


class Logger extends Context.Tag("Logger")<Logger, { log: (m: string) => Effect.Effect<void> }>() {}
class Database extends Context.Tag("Database")<Database, { query: (s: string) => Effect.Effect<unknown[]> }>() {}
class Cache extends Context.Tag("Cache")<Cache, { get: (k: string) => Effect.Effect<string | null> }>() {}

// Эффект A требует Logger
const effectA: Effect.Effect<void, never, Logger> = Effect.gen(function* () {
  const logger = yield* Logger
  yield* logger.log("A")
})

// Эффект B требует Database
const effectB: Effect.Effect<unknown[], never, Database> = Effect.gen(function* () {
  const db = yield* Database
  return yield* db.query("SELECT 1")
})

// Эффект C требует Cache
const effectC: Effect.Effect<string | null, never, Cache> = Effect.gen(function* () {
  const cache = yield* Cache
  return yield* cache.get("key")
})

// Комбинация автоматически объединяет Requirements
const combined: Effect.Effect<string | null, never, Logger | Database | Cache> = 
  Effect.gen(function* () {
    yield* effectA           // Добавляет Logger
    yield* effectB           // Добавляет Database
    return yield* effectC    // Добавляет Cache
  })

📊 Визуализация объединения:

effectA: Effect<void, never, Logger>


effectB: Effect<unknown[], never, Database>


effectC: Effect<string | null, never, Cache>


combined: Effect<string | null, never, Logger | Database | Cache>

Паттерн “Service Facade”

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


class HttpError extends Data.TaggedError("HttpError")<{
  readonly status: number
  readonly body: string
}> {}

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

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

// Низкоуровневые сервисы
class HttpClient extends Context.Tag("HttpClient")<
  HttpClient,
  {
    readonly get: (url: string) => Effect.Effect<unknown, HttpError>
    readonly post: (url: string, body: unknown) => Effect.Effect<unknown, HttpError>
  }
>() {}

class JsonParser extends Context.Tag("JsonParser")<
  JsonParser,
  {
    readonly parse: <T>(json: string) => Effect.Effect<T, ParseError>
    readonly stringify: (value: unknown) => Effect.Effect<string>
  }
>() {}

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

// Фасад, скрывающий сложность
interface ApiClientService {
  readonly fetch: <T>(endpoint: string) => Effect.Effect<T, HttpError | ParseError>
  readonly send: <T>(endpoint: string, data: unknown) => Effect.Effect<T, HttpError | ParseError>
}

class ApiClient extends Context.Tag("ApiClient")<ApiClient, ApiClientService>() {}

// Реализация фасада через композицию
const makeApiClient: Effect.Effect<ApiClientService, never, HttpClient | JsonParser | Logger> =
  Effect.gen(function* () {
    const http = yield* HttpClient
    const json = yield* JsonParser
    const logger = yield* Logger

    const baseUrl = "https://api.example.com"

    return {
      fetch: <T>(endpoint: string) =>
        Effect.gen(function* () {
          yield* logger.log(`GET ${endpoint}`)
          const response = yield* http.get(`${baseUrl}${endpoint}`)
          return yield* json.parse<T>(JSON.stringify(response))
        }).pipe((h) => h),

      send: <T>(endpoint: string, data: unknown) =>
        Effect.gen(function* () {
          yield* logger.log(`POST ${endpoint}`)
          const body = yield* json.stringify(data)
          const response = yield* http.post(`${baseUrl}${endpoint}`, body)
          return yield* json.parse<T>(JSON.stringify(response))
        }),
    } satisfies ApiClientService
  })

// Layer для ApiClient
const ApiClientLive = Layer.effect(ApiClient, makeApiClient)

Паттерн “Service Aggregator”

Объединение нескольких сервисов в один для удобства:


// Отдельные сервисы
class UserRepository extends Context.Tag("UserRepository")<UserRepository, {
  readonly findById: (id: string) => Effect.Effect<User | null>
}>() {}

class OrderRepository extends Context.Tag("OrderRepository")<OrderRepository, {
  readonly findByUserId: (userId: string) => Effect.Effect<ReadonlyArray<Order>>
}>() {}

class ProductRepository extends Context.Tag("ProductRepository")<ProductRepository, {
  readonly findById: (id: string) => Effect.Effect<Product | null>
}>() {}

// Агрегатор
interface RepositoriesService {
  readonly users: Context.Tag.Service<typeof UserRepository>
  readonly orders: Context.Tag.Service<typeof OrderRepository>
  readonly products: Context.Tag.Service<typeof ProductRepository>
}

class Repositories extends Context.Tag("Repositories")<Repositories, RepositoriesService>() {}

// Создание агрегатора
const makeRepositories: Effect.Effect<
  RepositoriesService, 
  never, 
  UserRepository | OrderRepository | ProductRepository
> = Effect.gen(function* () {
  return {
    users: yield* UserRepository,
    orders: yield* OrderRepository,
    products: yield* ProductRepository
  }
})

// Использование агрегатора упрощает код
const getFullOrderDetails = (orderId: string) => Effect.gen(function* () {
  const repos = yield* Repositories
  
  const orders = yield* repos.orders.findByUserId(orderId)
  // ...
})

Паттерн “Dependency Chain”

Когда сервисы зависят друг от друга:


// Config не зависит ни от чего
class Config extends Context.Tag("Config")<Config, {
  readonly databaseUrl: string
  readonly logLevel: string
}>() {}

// Logger зависит от Config
class Logger extends Context.Tag("Logger")<Logger, {
  readonly log: (msg: string) => Effect.Effect<void>
}>() {}

const makeLogger: Effect.Effect<Context.Tag.Service<typeof Logger>, never, Config> =
  Effect.gen(function* () {
    const config = yield* Config
    return {
      log: (msg) => Effect.sync(() => {
        if (config.logLevel !== "silent") {
          console.log(`[LOG] ${msg}`)
        }
      })
    }
  })

// Database зависит от Config и Logger
class Database extends Context.Tag("Database")<Database, {
  readonly query: (sql: string) => Effect.Effect<unknown[]>
}>() {}

const makeDatabase: Effect.Effect<Context.Tag.Service<typeof Database>, never, Config | Logger> =
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    
    yield* logger.log(`Connecting to $\{config.databaseUrl}`)
    
    return {
      query: (sql) => Effect.gen(function* () {
        yield* logger.log(`Executing: ${sql}`)
        return []
      })
    }
  })

// UserService зависит от Database и Logger
class UserService extends Context.Tag("UserService")<UserService, {
  readonly findById: (id: string) => Effect.Effect<User | null>
}>() {}

const makeUserService: Effect.Effect<Context.Tag.Service<typeof UserService>, never, Database | Logger> =
  Effect.gen(function* () {
    const db = yield* Database
    const logger = yield* Logger
    
    return {
      findById: (id) => Effect.gen(function* () {
        yield* logger.log(`Finding user: ${id}`)
        const results = yield* db.query(`SELECT * FROM users WHERE id = '${id}'`)
        return results[0] as User | null
      })
    }
  })

📊 Граф зависимостей:

        Config
       /      \
      ▼        ▼
   Logger ◄── Database
      \        /
       ▼      ▼
      UserService

Параллельная композиция сервисов

Иногда сервисы можно инициализировать параллельно:


class ServiceA extends Context.Tag("ServiceA")<ServiceA, { value: string }>() {}
class ServiceB extends Context.Tag("ServiceB")<ServiceB, { value: number }>() {}
class ServiceC extends Context.Tag("ServiceC")<ServiceC, { value: boolean }>() {}

// Параллельное использование независимых сервисов
const parallelComposition = Effect.gen(function* () {
  // Получаем сервисы параллельно
  const [a, b, c] = yield* Effect.all([ServiceA, ServiceB, ServiceC] as const, {
    concurrency: "unbounded",
  })

  return { a: a.value, b: b.value, c: c.value }
})

// Или через Effect.all с операциями
const parallelOperations = Effect.gen(function* () {
  const serviceA = yield* ServiceA
  const serviceB = yield* ServiceB

  // Выполняем операции параллельно
  const [resultA, resultB] = yield* Effect.all(
    [serviceA.heavyOperation(), serviceB.heavyOperation()],
    { concurrency: 2 },
  )

  return { resultA, resultB }
})

Условная композиция

Выбор сервисов на основе условий:


class ProductionDatabase extends Context.Tag("ProductionDatabase")<
  ProductionDatabase,
  DatabaseService
>() {}
class TestDatabase extends Context.Tag("TestDatabase")<TestDatabase, DatabaseService>() {}

// Абстрактный интерфейс
class Database extends Context.Tag("Database")<Database, DatabaseService>() {}

// Условный выбор реализации
const selectDatabase = Effect.gen(function* () {
  const env = yield* Config.string("NODE_ENV").pipe(Config.withDefault("development"))

  if (env === "production") {
    return yield* ProductionDatabase
  } else {
    return yield* TestDatabase
  }
})

// Программа работает с абстракцией
const program = Effect.gen(function* () {
  const db = yield* selectDatabase
  return yield* db.query("SELECT * FROM users")
})

Композиция с трансформацией ошибок

При композиции важно правильно обрабатывать и трансформировать ошибки:


// Ошибки разных уровней
class NetworkError extends Data.TaggedError("NetworkError")<{ message: string }> {}
class DatabaseError extends Data.TaggedError("DatabaseError")<{ query: string; cause: unknown }> {}
class ServiceError extends Data.TaggedError("ServiceError")<{ service: string; cause: unknown }> {}

class HttpClient extends Context.Tag("HttpClient")<HttpClient, {
  readonly get: (url: string) => Effect.Effect<unknown, NetworkError>
}>() {}

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

// Композиция с унификацией ошибок
const fetchUserData = (userId: string): Effect.Effect<
  UserData,
  ServiceError,  // Унифицированная ошибка
  HttpClient | Database
> => Effect.gen(function* () {
  const http = yield* HttpClient
  const db = yield* Database
  
  // Трансформируем ошибки в единый тип
  const profile = yield* http.get(`/users/${userId}`).pipe(
    Effect.mapError((e) => new ServiceError({ service: "HttpClient", cause: e }))
  )
  
  const orders = yield* db.query(`SELECT * FROM orders WHERE user_id = '${userId}'`).pipe(
    Effect.mapError((e) => new ServiceError({ service: "Database", cause: e }))
  )
  
  return { profile, orders } as UserData
})

Концепция ФП: Monad Transformers и Service Composition

Проблема Monad Composition

В классическом ФП комбинирование монад — сложная задача. Каждая монада добавляет свой “эффект”:

// Reader для зависимостей
type Reader<R, A> = (env: R) => A

// Either для ошибок
type Either<E, A> = { _tag: "Left"; left: E } | { _tag: "Right"; right: A }

// Task для асинхронности
type Task<A> = () => Promise<A>

// Как скомбинировать все три?
// ReaderTaskEither<R, E, A> = (env: R) => () => Promise<Either<E, A>>

Monad Transformers

Традиционное решение — Monad Transformers:

// ReaderT добавляет Reader поверх другой монады
type ReaderT<R, M, A> = Reader<R, M<A>>

// EitherT добавляет Either поверх другой монады
type EitherT<E, M, A> = M<Either<E, A>>

// Проблема: нужен lift для каждого уровня
// И порядок трансформеров имеет значение!

Effect как универсальное решение

Effect избегает проблемы Monad Transformers, объединяя все эффекты в одном типе:

// Effect<A, E, R> = все эффекты в одном типе!
// - A: успешный результат
// - E: типизированные ошибки
// - R: зависимости (Context)
// + Асинхронность
// + Конкурентность (Fibers)
// + Ресурсы (Scope)
// + Прерываемость

// Композиция тривиальна:
const composed: Effect<C, E1 | E2, R1 | R2> = Effect.gen(function* () {
  const a = yield* effectA  // Effect<A, E1, R1>
  const b = yield* effectB  // Effect<B, E2, R2>
  return combine(a, b)      // C
})

Algebraic Effects интерпретация

С точки зрения algebraic effects, композиция сервисов — это композиция обработчиков эффектов:

// Каждый сервис — это обработчик для определённых операций
// Logger обрабатывает операции логирования
// Database обрабатывает операции с данными
// Config обрабатывает операции конфигурации

// Композиция = цепочка обработчиков
const handled = program.pipe(
  Effect.provideService(Logger, loggerHandler),
  Effect.provideService(Database, databaseHandler),
  Effect.provideService(Config, configHandler)
)

API Reference

Effect.all [STABLE]

Комбинирует массив эффектов в эффект массива. Можно передать как массив, и получиться Effect

declare const all: <
  const Arg extends Iterable<Effect<any, any, any>> | Record<string, Effect<any, any, any>>,
  O extends NoExcessProperties<
    {
      readonly concurrency?: Concurrency | undefined
      readonly batching?: boolean | "inherit" | undefined
      readonly discard?: boolean | undefined
      readonly mode?: "default" | "validate" | "either" | undefined
      readonly concurrentFinalizers?: boolean | undefined
    },
    O
  >
>(
  arg: Arg,
  options?: O
) => All.Return<Arg, O>

Пример:

const combined = Effect.all([effectA, effectB, effectC], { concurrency: 3 })

Effect.allWith [STABLE]

Версия all для использования в pipe.

const combined = [effectA, effectB].pipe(
  Effect.allWith({ concurrency: "unbounded" })
)

Effect.zip [STABLE]

Комбинирует два эффекта в пару.

declare const zip: {
  <A2, E2, R2>(
    that: Effect<A2, E2, R2>,
    options?:
      | {
          readonly concurrent?: boolean | undefined
          readonly batching?: boolean | "inherit" | undefined
          readonly concurrentFinalizers?: boolean | undefined
        }
      | undefined
  ): <A, E, R>(self: Effect<A, E, R>) => Effect<[A, A2], E2 | E, R2 | R>
  <A, E, R, A2, E2, R2>(
    self: Effect<A, E, R>,
    that: Effect<A2, E2, R2>,
    options?:
      | {
          readonly concurrent?: boolean | undefined
          readonly batching?: boolean | "inherit" | undefined
          readonly concurrentFinalizers?: boolean | undefined
        }
      | undefined
  ): Effect<[A, A2], E | E2, R | R2>
}

Effect.zipWith [STABLE]

Комбинирует два эффекта последовательно и применяет функцию к их результатам, чтобы получить одно значение

declare const zipWith: {
  <A2, E2, R2, A, B>(
    that: Effect<A2, E2, R2>,
    f: (a: A, b: A2) => B,
    options?: {
      readonly concurrent?: boolean | undefined
      readonly batching?: boolean | "inherit" | undefined
      readonly concurrentFinalizers?: boolean | undefined
    }
  ): <E, R>(self: Effect<A, E, R>) => Effect<B, E2 | E, R2 | R>
  <A, E, R, A2, E2, R2, B>(
    self: Effect<A, E, R>,
    that: Effect<A2, E2, R2>,
    f: (a: A, b: A2) => B,
    options?: {
      readonly concurrent?: boolean | undefined
      readonly batching?: boolean | "inherit" | undefined
      readonly concurrentFinalizers?: boolean | undefined
    }
  ): Effect<B, E2 | E, R2 | R>
}

Effect.zipLeft [STABLE]

Выполняет два эффекта последовательно, возвращая результат первого эффекта и игнорируя результат второго.

declare const zipLeft: {
  <A2, E2, R2>(
    that: Effect<A2, E2, R2>,
    options?:
      | {
          readonly concurrent?: boolean | undefined
          readonly batching?: boolean | "inherit" | undefined
          readonly concurrentFinalizers?: boolean | undefined
        }
      | undefined
  ): <A, E, R>(self: Effect<A, E, R>) => Effect<A, E2 | E, R2 | R>
  <A, E, R, A2, E2, R2>(
    self: Effect<A, E, R>,
    that: Effect<A2, E2, R2>,
    options?:
      | {
          readonly concurrent?: boolean | undefined
          readonly batching?: boolean | "inherit" | undefined
          readonly concurrentFinalizers?: boolean | undefined
        }
      | undefined
  ): Effect<A, E | E2, R | R2>
}

Effect.zipRight [STABLE]

Выполняет два эффекта подряд, возвращая результат второго эффекта, игнорируя результат первого.

declare const zipRight: {
  <A2, E2, R2>(
    that: Effect<A2, E2, R2>,
    options?: {
      readonly concurrent?: boolean | undefined
      readonly batching?: boolean | "inherit" | undefined
      readonly concurrentFinalizers?: boolean | undefined
    }
  ): <A, E, R>(self: Effect<A, E, R>) => Effect<A2, E2 | E, R2 | R>
  <A, E, R, A2, E2, R2>(
    self: Effect<A, E, R>,
    that: Effect<A2, E2, R2>,
    options?: {
      readonly concurrent?: boolean | undefined
      readonly batching?: boolean | "inherit" | undefined
      readonly concurrentFinalizers?: boolean | undefined
    }
  ): Effect<A2, E2 | E, R2 | R>
}

Примеры

Пример 1: E-commerce Order Processing


// === Ошибки ===
class ProductNotFound extends Data.TaggedError("ProductNotFound")<{ productId: string }> {}
class InsufficientStock extends Data.TaggedError("InsufficientStock")<{ productId: string; available: number }> {}
class PaymentFailed extends Data.TaggedError("PaymentFailed")<{ reason: string }> {}
class ShippingUnavailable extends Data.TaggedError("ShippingUnavailable")<{ address: string }> {}

// === Сервисы ===
class ProductCatalog extends Context.Tag("ProductCatalog")<ProductCatalog, {
  readonly getProduct: (id: string) => Effect.Effect<Product, ProductNotFound>
  readonly checkStock: (id: string, quantity: number) => Effect.Effect<boolean, ProductNotFound>
}>() {}

class Inventory extends Context.Tag("Inventory")<Inventory, {
  readonly reserve: (productId: string, quantity: number) => Effect.Effect<string, InsufficientStock>
  readonly release: (reservationId: string) => Effect.Effect<void>
}>() {}

class PaymentService extends Context.Tag("PaymentService")<PaymentService, {
  readonly charge: (amount: number, paymentMethod: PaymentMethod) => Effect.Effect<PaymentResult, PaymentFailed>
  readonly refund: (transactionId: string) => Effect.Effect<void>
}>() {}

class ShippingService extends Context.Tag("ShippingService")<ShippingService, {
  readonly calculateCost: (address: Address, weight: number) => Effect.Effect<number, ShippingUnavailable>
  readonly createShipment: (orderId: string, address: Address) => Effect.Effect<TrackingInfo>
}>() {}

class NotificationService extends Context.Tag("NotificationService")<NotificationService, {
  readonly sendOrderConfirmation: (orderId: string, email: string) => Effect.Effect<void>
  readonly sendShippingUpdate: (orderId: string, tracking: TrackingInfo) => Effect.Effect<void>
}>() {}

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

// === Композиция: Order Processing ===
type OrderError = ProductNotFound | InsufficientStock | PaymentFailed | ShippingUnavailable

const processOrder = (order: OrderRequest): Effect.Effect<
  OrderResult,
  OrderError,
  ProductCatalog | Inventory | PaymentService | ShippingService | NotificationService | Logger
> => Effect.gen(function* () {
  const catalog = yield* ProductCatalog
  const inventory = yield* Inventory
  const payment = yield* PaymentService
  const shipping = yield* ShippingService
  const notifications = yield* NotificationService
  const logger = yield* Logger
  
  yield* logger.info("Processing order", { orderId: order.id })
  
  // 1. Validate products and calculate total
  let total = 0
  const reservations: string[] = []
  
  for (const item of order.items) {
    const product = yield* catalog.getProduct(item.productId)
    total += product.price * item.quantity
    
    // Reserve inventory
    const reservationId = yield* inventory.reserve(item.productId, item.quantity)
    reservations.push(reservationId)
  }
  
  // 2. Calculate shipping
  const shippingCost = yield* shipping.calculateCost(order.shippingAddress, order.totalWeight)
  total += shippingCost
  
  // 3. Process payment (with rollback on failure)
  const paymentResult = yield* payment.charge(total, order.paymentMethod).pipe(
    Effect.tapError(() => 
      // Release all reservations on payment failure
      Effect.all(reservations.map((r) => inventory.release(r)), { concurrency: "unbounded" })
    )
  )
  
  // 4. Create shipment
  const trackingInfo = yield* shipping.createShipment(order.id, order.shippingAddress)
  
  // 5. Send notifications (fire and forget)
  yield* Effect.all([
    notifications.sendOrderConfirmation(order.id, order.customerEmail),
    notifications.sendShippingUpdate(order.id, trackingInfo)
  ], { concurrency: "unbounded" }).pipe(
    Effect.catchAll((e) => logger.error("Notification failed", e))
  )
  
  yield* logger.info("Order processed successfully", { orderId: order.id })
  
  return {
    orderId: order.id,
    transactionId: paymentResult.transactionId,
    trackingNumber: trackingInfo.trackingNumber,
    total
  }
})

Пример 2: Service Facade для API


// === Низкоуровневые сервисы ===
class HttpClient extends Context.Tag("HttpClient")<HttpClient, {
  readonly request: (config: RequestConfig) => Effect.Effect<Response, HttpError>
}>() {}

class AuthToken extends Context.Tag("AuthToken")<AuthToken, {
  readonly getToken: () => Effect.Effect<string, AuthError>
  readonly refreshToken: () => Effect.Effect<string, AuthError>
}>() {}

class RateLimiter extends Context.Tag("RateLimiter")<RateLimiter, {
  readonly acquire: () => Effect.Effect<void>
  readonly release: () => Effect.Effect<void>
}>() {}

class ResponseCache extends Context.Tag("ResponseCache")<ResponseCache, {
  readonly get: (key: string) => Effect.Effect<unknown | null>
  readonly set: (key: string, value: unknown, ttl: number) => Effect.Effect<void>
}>() {}

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

// === Фасад ===
interface ApiClient {
  readonly get: <T>(path: string, options?: ApiOptions) => Effect.Effect<T, ApiError>
  readonly post: <T>(path: string, body: unknown, options?: ApiOptions) => Effect.Effect<T, ApiError>
  readonly put: <T>(path: string, body: unknown, options?: ApiOptions) => Effect.Effect<T, ApiError>
  readonly delete: (path: string, options?: ApiOptions) => Effect.Effect<void, ApiError>
}

interface ApiOptions {
  readonly cache?: boolean
  readonly cacheTtl?: number
  readonly skipAuth?: boolean
}

class ApiClient extends Context.Tag("ApiClient")<ApiClient, ApiClientInterface>() {}

// === Реализация фасада ===
const makeApiClient: Effect.Effect<
  ApiClientInterface,
  never,
  HttpClient | AuthToken | RateLimiter | ResponseCache | Logger
> = Effect.gen(function* () {
  const http = yield* HttpClient
  const auth = yield* AuthToken
  const rateLimiter = yield* RateLimiter
  const cache = yield* ResponseCache
  const logger = yield* Logger
  
  const baseUrl = "https://api.example.com"
  
  const executeRequest = <T>(
    method: string,
    path: string,
    body: unknown | undefined,
    options: ApiOptions = {}
  ): Effect.Effect<T, ApiError> => Effect.gen(function* () {
    const cacheKey = `${method}:${path}`
    
    // Check cache for GET requests
    if (method === "GET" && options.cache) {
      const cached = yield* cache.get(cacheKey)
      if (cached) {
        yield* logger.debug(`Cache hit: ${cacheKey}`)
        return cached as T
      }
    }
    
    // Acquire rate limit
    yield* rateLimiter.acquire()
    
    try {
      // Get auth token if needed
      const headers: Record<string, string> = {}
      if (!options.skipAuth) {
        const token = yield* auth.getToken()
        headers["Authorization"] = `Bearer ${token}`
      }
      
      yield* logger.debug(`${method} ${path}`)
      
      // Make request
      const response = yield* http.request({
        method,
        url: `${baseUrl}${path}`,
        headers,
        body
      }).pipe(
        Effect.mapError((e) => new ApiError({ method, path, cause: e }))
      )
      
      const data = response.json as T
      
      // Cache response
      if (method === "GET" && options.cache) {
        yield* cache.set(cacheKey, data, options.cacheTtl ?? 300)
      }
      
      return data
    } finally {
      yield* rateLimiter.release()
    }
  })
  
  return {
    get: <T>(path: string, options?: ApiOptions) => 
      executeRequest<T>("GET", path, undefined, options),
    post: <T>(path: string, body: unknown, options?: ApiOptions) => 
      executeRequest<T>("POST", path, body, options),
    put: <T>(path: string, body: unknown, options?: ApiOptions) => 
      executeRequest<T>("PUT", path, body, options),
    delete: (path: string, options?: ApiOptions) => 
      executeRequest<void>("DELETE", path, undefined, options)
  }
})

const ApiClientLive = Layer.effect(ApiClient, makeApiClient)

Пример 3: Parallel Data Aggregation


// Сервисы для разных источников данных
class UserService extends Context.Tag("UserService")<UserService, {
  readonly getProfile: (userId: string) => Effect.Effect<UserProfile>
}>() {}

class OrderService extends Context.Tag("OrderService")<OrderService, {
  readonly getRecentOrders: (userId: string, limit: number) => Effect.Effect<ReadonlyArray<Order>>
}>() {}

class ReviewService extends Context.Tag("ReviewService")<ReviewService, {
  readonly getUserReviews: (userId: string) => Effect.Effect<ReadonlyArray<Review>>
}>() {}

class RecommendationService extends Context.Tag("RecommendationService")<RecommendationService, {
  readonly getRecommendations: (userId: string) => Effect.Effect<ReadonlyArray<Product>>
}>() {}

class LoyaltyService extends Context.Tag("LoyaltyService")<LoyaltyService, {
  readonly getPoints: (userId: string) => Effect.Effect<number>
  readonly getTier: (userId: string) => Effect.Effect<LoyaltyTier>
}>() {}

// Агрегированный dashboard
interface UserDashboard {
  readonly profile: UserProfile
  readonly recentOrders: ReadonlyArray<Order>
  readonly reviews: ReadonlyArray<Review>
  readonly recommendations: ReadonlyArray<Product>
  readonly loyaltyPoints: number
  readonly loyaltyTier: LoyaltyTier
}

const getUserDashboard = (userId: string): Effect.Effect<
  UserDashboard,
  never,
  UserService | OrderService | ReviewService | RecommendationService | LoyaltyService
> => Effect.gen(function* () {
  const userService = yield* UserService
  const orderService = yield* OrderService
  const reviewService = yield* ReviewService
  const recommendationService = yield* RecommendationService
  const loyaltyService = yield* LoyaltyService
  
  // Параллельный fetch всех данных
  const [profile, recentOrders, reviews, recommendations, loyaltyPoints, loyaltyTier] = 
    yield* Effect.all([
      userService.getProfile(userId),
      orderService.getRecentOrders(userId, 5),
      reviewService.getUserReviews(userId),
      recommendationService.getRecommendations(userId),
      loyaltyService.getPoints(userId),
      loyaltyService.getTier(userId)
    ], { concurrency: "unbounded" })
  
  return {
    profile,
    recentOrders,
    reviews,
    recommendations,
    loyaltyPoints,
    loyaltyTier
  }
})

// Альтернатива с Effect.struct для лучшей читаемости
const getUserDashboardAlt = (userId: string) => Effect.gen(function* () {
  const userService = yield* UserService
  const orderService = yield* OrderService
  const reviewService = yield* ReviewService
  const recommendationService = yield* RecommendationService
  const loyaltyService = yield* LoyaltyService
  
  return yield* Effect.all({
    profile: userService.getProfile(userId),
    recentOrders: orderService.getRecentOrders(userId, 5),
    reviews: reviewService.getUserReviews(userId),
    recommendations: recommendationService.getRecommendations(userId),
    loyaltyPoints: loyaltyService.getPoints(userId),
    loyaltyTier: loyaltyService.getTier(userId)
  })
})

Пример 4: Service Dependencies с Layer


// === Базовые сервисы ===
class Config extends Context.Tag("Config")<Config, {
  readonly get: (key: string) => Effect.Effect<string>
}>() {}

const ConfigLive = Layer.succeed(Config, {
  get: (key) => Effect.sync(() => process.env[key] ?? "")
})

// === Logger зависит от Config ===
class Logger extends Context.Tag("Logger")<Logger, {
  readonly log: (level: string, msg: string) => Effect.Effect<void>
}>() {}

const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    const logLevel = yield* config.get("LOG_LEVEL")
    
    const levels = ["debug", "info", "warn", "error"]
    const minLevel = levels.indexOf(logLevel)
    
    return {
      log: (level, msg) => Effect.sync(() => {
        if (levels.indexOf(level) >= minLevel) {
          console.log(`[${level.toUpperCase()}] ${msg}`)
        }
      })
    }
  })
)

// === Database зависит от Config и Logger ===
class Database extends Context.Tag("Database")<Database, {
  readonly query: <T>(sql: string) => Effect.Effect<T>
}>() {}

const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    const logger = yield* Logger
    
    const connectionString = yield* config.get("DATABASE_URL")
    yield* logger.log("info", `Connecting to database: ${connectionString}`)
    
    return {
      query: <T>(sql: string) => Effect.gen(function* () {
        yield* logger.log("debug", `Executing: ${sql}`)
        return {} as T
      })
    }
  })
)

// === Композиция слоёв ===
// Вариант 1: Ручная композиция
const AppLayerManual = ConfigLive.pipe(
  Layer.provideMerge(LoggerLive),
  Layer.provideMerge(DatabaseLive)
)

// Вариант 2: Layer.provide для зависимостей
const LoggerWithDeps = Layer.provide(LoggerLive, ConfigLive)
const DatabaseWithDeps = Layer.provide(
  DatabaseLive,
  Layer.merge(ConfigLive, LoggerWithDeps)
)

const AppLayer = Layer.merge(
  ConfigLive,
  Layer.merge(LoggerWithDeps, DatabaseWithDeps)
)

// === Использование ===
const program = Effect.gen(function* () {
  const db = yield* Database
  const logger = yield* Logger
  
  yield* logger.log("info", "Starting application")
  const users = yield* db.query<User[]>("SELECT * FROM users")
  yield* logger.log("info", `Found ${users.length} users`)
})

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

Пример 5: Transactional Service Composition


class Database extends Context.Tag("Database")<Database, {
  readonly beginTransaction: () => Effect.Effect<Transaction>
}>() {}

class Transaction extends Context.Tag("Transaction")<Transaction, {
  readonly query: <T>(sql: string) => Effect.Effect<T, QueryError>
  readonly commit: () => Effect.Effect<void>
  readonly rollback: () => Effect.Effect<void>
}>() {}

// Транзакционная операция
const transferFunds = (
  fromAccount: string,
  toAccount: string,
  amount: number
): Effect.Effect<void, TransferError, Database> => Effect.gen(function* () {
  const db = yield* Database
  const tx = yield* db.beginTransaction()
  
  yield* Effect.acquireUseRelease(
    Effect.succeed(tx),
    (transaction) => Effect.gen(function* () {
      // Debit from source
      yield* transaction.query(
        `UPDATE accounts SET balance = balance - ${amount} WHERE id = '${fromAccount}'`
      )
      
      // Credit to destination
      yield* transaction.query(
        `UPDATE accounts SET balance = balance + ${amount} WHERE id = '${toAccount}'`
      )
      
      yield* transaction.commit()
    }),
    (transaction, exit) => 
      exit._tag === "Failure" 
        ? transaction.rollback() 
        : Effect.void
  )
})

// Композиция нескольких транзакционных операций
const batchTransfer = (
  transfers: ReadonlyArray<{ from: string; to: string; amount: number }>
): Effect.Effect<void, TransferError, Database> => Effect.gen(function* () {
  const db = yield* Database
  const tx = yield* db.beginTransaction()
  
  yield* Effect.acquireUseRelease(
    Effect.succeed(tx),
    (transaction) => Effect.gen(function* () {
      for (const t of transfers) {
        yield* transaction.query(
          `UPDATE accounts SET balance = balance - ${t.amount} WHERE id = '${t.from}'`
        )
        yield* transaction.query(
          `UPDATE accounts SET balance = balance + ${t.amount} WHERE id = '${t.to}'`
        )
      }
      yield* transaction.commit()
    }),
    (transaction, exit) =>
      exit._tag === "Failure"
        ? transaction.rollback()
        : Effect.void
  )
})

Упражнения

Упражнение

Простая композиция

Легко

Скомпонируйте три сервиса для получения полной информации о пользователе:


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

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

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

interface UserInfo {
  readonly name: string
  readonly avatarUrl: string
  readonly isOnline: boolean
}

// Задание: реализуйте функцию
const getUserInfo = (userId: string): Effect.Effect<
  UserInfo, 
  never, 
  UserService | AvatarService | StatusService
> => // Ваш код
Упражнение

Параллельная композиция с Effect.struct

Легко

class PriceService extends Context.Tag("PriceService")<PriceService, {
  readonly getPrice: (productId: string) => Effect.Effect<number>
}>() {}

class StockService extends Context.Tag("StockService")<StockService, {
  readonly getStock: (productId: string) => Effect.Effect<number>
}>() {}

class ReviewService extends Context.Tag("ReviewService")<ReviewService, {
  readonly getAverageRating: (productId: string) => Effect.Effect<number>
}>() {}

interface ProductDetails {
  readonly price: number
  readonly stock: number
  readonly rating: number
}

// Задание: используйте Effect.struct для параллельного получения данных
const getProductDetails = (productId: string): Effect.Effect<
  ProductDetails,
  never,
  PriceService | StockService | ReviewService
> => // Ваш код
Упражнение

Service Facade

Средне

Создайте фасад для нотификаций:


class EmailSender extends Context.Tag("EmailSender")<EmailSender, {
  readonly send: (to: string, subject: string, body: string) => Effect.Effect<void, EmailError>
}>() {}

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

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

class UserPreferences extends Context.Tag("UserPreferences")<UserPreferences, {
  readonly getPreferences: (userId: string) => Effect.Effect<{
    email: string | null
    phone: string | null
    pushToken: string | null
    channels: ReadonlyArray<"email" | "sms" | "push">
  }>
}>() {}

// Задание: создайте фасад NotificationService
interface NotificationService {
  readonly notify: (userId: string, title: string, message: string) => Effect.Effect<void, NotificationError>
}

class NotificationService extends Context.Tag("NotificationService")<NotificationService, NotificationServiceInterface>() {}

// Реализация должна:
// 1. Получить preferences пользователя
// 2. Отправить уведомление через все enabled каналы параллельно
// 3. Вернуть ошибку, если ВСЕ каналы failed

const makeNotificationService: Effect.Effect<
  NotificationServiceInterface,
  never,
  EmailSender | SmsSender | PushSender | UserPreferences
> = // Ваш код
Упражнение

Dependency Chain с Layer

Средне

// Создайте цепочку зависимостей:
// Config -> Logger -> HttpClient -> ApiClient

class Config extends Context.Tag("Config")<Config, { apiUrl: string; timeout: number }>() {}
class Logger extends Context.Tag("Logger")<Logger, { log: (m: string) => Effect.Effect<void> }>() {}
class HttpClient extends Context.Tag("HttpClient")<HttpClient, { get: (u: string) => Effect.Effect<unknown> }>() {}
class ApiClient extends Context.Tag("ApiClient")<ApiClient, { fetch: (e: string) => Effect.Effect<unknown> }>() {}

// Задание: создайте слои с правильными зависимостями
const ConfigLive: Layer.Layer<Config> = // ...
const LoggerLive: Layer.Layer<Logger, never, Config> = // ...
const HttpClientLive: Layer.Layer<HttpClient, never, Config | Logger> = // ...
const ApiClientLive: Layer.Layer<ApiClient, never, HttpClient | Logger> = // ...

// И полный слой приложения
const AppLayer: Layer.Layer<Config | Logger | HttpClient | ApiClient> = // ...
Упражнение

Saga Pattern

Сложно

Реализуйте паттерн Saga для распределённых транзакций:


// Каждый шаг саги имеет execute и compensate
interface SagaStep<A, E> {
  readonly execute: Effect.Effect<A, E, any>
  readonly compensate: (result: A) => Effect.Effect<void, never, any>
}

// Задание: создайте функцию runSaga, которая:
// 1. Выполняет шаги последовательно
// 2. При ошибке на любом шаге выполняет compensate для всех предыдущих шагов в обратном порядке
// 3. Возвращает результаты всех шагов или ошибку

const runSaga = <Steps extends ReadonlyArray<SagaStep<any, any>>>(
  steps: Steps
): Effect.Effect</* результаты */, /* ошибки */, /* зависимости */> => // Ваш код

// Пример использования:
const orderSaga = runSaga([
  {
    execute: reserveInventory(orderId),
    compensate: (reservationId) => releaseInventory(reservationId)
  },
  {
    execute: chargePayment(orderId, amount),
    compensate: (transactionId) => refundPayment(transactionId)
  },
  {
    execute: createShipment(orderId),
    compensate: (shipmentId) => cancelShipment(shipmentId)
  }
])
Упражнение

Service Mesh Simulation

Сложно

// Создайте систему с:
// - Circuit Breaker
// - Retry с exponential backoff
// - Timeout
// - Rate Limiting

interface ServiceMesh<S> {
  readonly call: <A, E>(
    serviceCall: Effect.Effect<A, E, S>,
    options: {
      readonly timeout: Duration.Duration
      readonly retries: number
      readonly circuitBreaker: { threshold: number; resetTimeout: Duration.Duration }
    }
  ) => Effect.Effect<A, E | TimeoutError | CircuitOpenError, S>
}

// Задание: реализуйте ServiceMesh как сервис
class ServiceMesh extends Context.Tag("ServiceMesh")<ServiceMesh, ServiceMeshInterface>() {}

Заключение

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

  • 📖 Автоматическое объединение Requirements через union типы
  • 📖 Service Facade — скрытие сложности множества сервисов
  • 📖 Service Aggregator — объединение связанных сервисов
  • 📖 Dependency Chain — сервисы, зависящие друг от друга
  • 📖 Параллельная композиция с Effect.all, Effect.struct
  • 📖 Транзакционная композиция с proper rollback

🔗 Следующая тема: Продвинутые паттерны сервисов

💡 Ключевой takeaway: Effect автоматически отслеживает и объединяет зависимости. Используйте паттерны Facade и Aggregator для упрощения сложных систем.