Композиция нескольких сервисов
Как комбинировать сервисы в 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
> => // Ваш кодconst getUserInfo = (userId: string): Effect.Effect<
UserInfo,
never,
UserService | AvatarService | StatusService
> =>
Effect.gen(function* () {
const userService = yield* UserService
const avatarService = yield* AvatarService
const statusService = yield* StatusService
const [name, avatarUrl, isOnline] = yield* Effect.all([
userService.getName(userId),
avatarService.getUrl(userId),
statusService.isOnline(userId)
])
return { name, avatarUrl, isOnline }
})Параллельная композиция с 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
> => // Ваш кодconst getProductDetails = (productId: string): Effect.Effect<
ProductDetails,
never,
PriceService | StockService | ReviewService
> =>
Effect.gen(function* () {
const priceService = yield* PriceService
const stockService = yield* StockService
const reviewService = yield* ReviewService
return yield* Effect.all({
price: priceService.getPrice(productId),
stock: stockService.getStock(productId),
rating: reviewService.getAverageRating(productId)
})
})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
> = // Ваш кодconst makeNotificationService: Effect.Effect<
NotificationServiceInterface,
never,
EmailSender | SmsSender | PushSender | UserPreferences
> =
Effect.gen(function* () {
const emailSender = yield* EmailSender
const smsSender = yield* SmsSender
const pushSender = yield* PushSender
const preferencesService = yield* UserPreferences
return {
notify: (userId: string, title: string, message: string) =>
Effect.gen(function* () {
const prefs = yield* preferencesService.getPreferences(userId)
const notifications: Array<Effect.Effect<void, any, never>> = []
if (prefs.channels.includes("email") && prefs.email) {
notifications.push(
emailSender.send(prefs.email, title, message).pipe(Effect.option)
)
}
if (prefs.channels.includes("sms") && prefs.phone) {
notifications.push(
smsSender.send(prefs.phone, message).pipe(Effect.option)
)
}
if (prefs.channels.includes("push") && prefs.pushToken) {
notifications.push(
pushSender.send(prefs.pushToken, title, message).pipe(Effect.option)
)
}
if (notifications.length === 0) {
return yield* Effect.fail(new NotificationError("No channels available"))
}
const results = yield* Effect.all(notifications, { concurrency: "unbounded" })
const successCount = results.filter((r) => Option.isSome(r)).length
if (successCount === 0) {
return yield* Effect.fail(new NotificationError("All channels failed"))
}
})
}
})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> = // ...const ConfigLive: Layer.Layer<Config> = Layer.succeed(Config, {
apiUrl: "https://api.example.com",
timeout: 5000
})
const LoggerLive: Layer.Layer<Logger, never, Config> = Layer.effect(
Logger,
Effect.gen(function* () {
const config = yield* Config
return {
log: (msg: string) => Effect.sync(() =>
console.log(`[${config.apiUrl}] ${msg}`)
)
}
})
)
const HttpClientLive: Layer.Layer<HttpClient, never, Config | Logger> = Layer.effect(
HttpClient,
Effect.gen(function* () {
const config = yield* Config
const logger = yield* Logger
return {
get: (url: string) => Effect.gen(function* () {
yield* logger.log(`GET ${url}`)
return { data: "response" }
})
}
})
)
const ApiClientLive: Layer.Layer<ApiClient, never, HttpClient | Logger> = Layer.effect(
ApiClient,
Effect.gen(function* () {
const http = yield* HttpClient
const logger = yield* Logger
return {
fetch: (endpoint: string) => Effect.gen(function* () {
yield* logger.log(`Fetching ${endpoint}`)
return yield* http.get(endpoint)
})
}
})
)
const AppLayer: Layer.Layer<Config | Logger | HttpClient | ApiClient> = Layer.mergeAll(
ConfigLive,
LoggerLive,
HttpClientLive,
ApiClientLive
)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)
}
])const runSaga = <A, E>(
steps: ReadonlyArray<SagaStep<A, E>>
): Effect.Effect<ReadonlyArray<A>, E, any> =>
Effect.gen(function* () {
const results: A[] = []
for (let i = 0; i < steps.length; i++) {
const step = steps[i]
try {
const result = yield* step.execute
results.push(result)
} catch (error) {
// Compensate all previous steps in reverse order
for (let j = i - 1; j >= 0; j--) {
yield* steps[j].compensate(results[j]).pipe(Effect.catchAll(() => Effect.void))
}
return yield* Effect.fail(error as E)
}
}
return results
})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>() {}interface ServiceMeshInterface {
readonly call: <A, E, R>(
serviceCall: Effect.Effect<A, E, R>,
options: {
readonly timeout: Duration.Duration
readonly retries: number
readonly circuitBreaker: { threshold: number; resetTimeout: Duration.Duration }
}
) => Effect.Effect<A, E | TimeoutError | CircuitOpenError, R>
}
class ServiceMesh extends Context.Tag("ServiceMesh")<ServiceMesh, ServiceMeshInterface>() {}
const ServiceMeshLive = Layer.succeed(ServiceMesh, {
call: (serviceCall, options) =>
serviceCall.pipe(
Effect.timeout(options.timeout),
Effect.retry(Schedule.recurs(options.retries)),
Effect.catchAll((error) => Effect.fail(error))
)
})Заключение
В этой статье мы изучили:
- 📖 Автоматическое объединение Requirements через union типы
- 📖 Service Facade — скрытие сложности множества сервисов
- 📖 Service Aggregator — объединение связанных сервисов
- 📖 Dependency Chain — сервисы, зависящие друг от друга
- 📖 Параллельная композиция с
Effect.all,Effect.struct - 📖 Транзакционная композиция с proper rollback
🔗 Следующая тема: Продвинутые паттерны сервисов
💡 Ключевой takeaway: Effect автоматически отслеживает и объединяет зависимости. Используйте паттерны Facade и Aggregator для упрощения сложных систем.