Паттерны определения сервисов
Различные подходы к определению сервисов в Effect.
Теория
Что такое сервис в Effect?
Сервис — это переиспользуемый компонент, предоставляющий определённую функциональность приложению. В Effect сервисы имеют три ключевых характеристика:
- Интерфейс — контракт, описывающий доступные операции
- Реализация — конкретный код, выполняющий операции
- 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>>
}
Автоматически создаваемые свойства:
| Свойство | Тип | Описание |
|---|---|---|
Default | Layer<Self> | Layer с включёнными зависимостями |
DefaultWithoutDependencies | Layer<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
})
// Определение ошибки
class DivisionByZeroError extends Data.TaggedError("DivisionByZeroError")<{
readonly dividend: number
}>() {}
// Создание Tag
class Calculator extends Context.Tag("Calculator")<
Calculator,
{
readonly add: (a: number, b: number) => Effect.Effect<number>
readonly subtract: (a: number, b: number) => Effect.Effect<number>
readonly multiply: (a: number, b: number) => Effect.Effect<number>
readonly divide: (a: number, b: number) => Effect.Effect<number, DivisionByZeroError>
}
>() {}
// Реализация
const calculatorImpl = {
add: (a: number, b: number) => Effect.succeed(a + b),
subtract: (a: number, b: number) => Effect.succeed(a - b),
multiply: (a: number, b: number) => Effect.succeed(a * b),
divide: (a: number, b: number) =>
b === 0
? Effect.fail(new DivisionByZeroError({ dividend: a }))
: Effect.succeed(a / b)
}
// Использование
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
})
const runnable = program.pipe(
Effect.provideService(Calculator, calculatorImpl)
)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)))
class AppInfo extends Effect.Service<AppInfo>()("AppInfo", {
succeed: {
name: "MyApplication",
version: "1.0.0",
environment: "development" as const
}
}) {}
// Использование
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 = ...
interface EmailValidatorService {
readonly validate: (email: string) => Effect.Effect<boolean>
}
const EmailValidator = Context.GenericTag<EmailValidatorService>("EmailValidator")
// Реализация
const validatorImpl: EmailValidatorService = {
validate: (email: string) => Effect.succeed(email.includes("@"))
}
// Использование
const program = Effect.gen(function* () {
const validator = yield* EmailValidator
const isValid = yield* validator.validate("test@example.com")
console.log(isValid) // true
})
const runnable = program.pipe(
Effect.provideService(EmailValidator, validatorImpl)
)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>()...
class Logger extends Effect.Service<Logger>()("Logger", {
succeed: { log: (msg: string) => Effect.sync(() => console.log(msg)) }
}) {}
class CacheService extends Effect.Service<CacheService>()("CacheService", {
effect: Effect.gen(function* () {
const logger = yield* Logger
const cache = yield* Ref.make<Map<string, unknown>>(new Map())
return {
get: (key: string) => Effect.gen(function* () {
yield* logger.log(`Getting ${key}`)
const map = yield* Ref.get(cache)
return map.get(key)
}),
set: (key: string, value: unknown) => Effect.gen(function* () {
yield* logger.log(`Setting ${key}`)
yield* Ref.update(cache, (map) => {
const newMap = new Map(map)
newMap.set(key, value)
return newMap
})
}),
delete: (key: string) => Effect.gen(function* () {
yield* logger.log(`Deleting ${key}`)
yield* Ref.update(cache, (map) => {
const newMap = new Map(map)
newMap.delete(key)
return newMap
})
}),
clear: () => Effect.gen(function* () {
yield* logger.log("Clearing cache")
yield* Ref.set(cache, new Map())
})
}
}),
dependencies: [Logger.Default]
}) {}
// Использование
const program = Effect.gen(function* () {
const cache = yield* CacheService
yield* cache.set("user:1", { name: "Alice" })
const user = yield* cache.get("user:1")
console.log(user)
})
Effect.runPromise(program.pipe(Effect.provide(CacheService.Default)))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...
interface User {
readonly id: string
readonly email: string
readonly name: string
}
interface CreateUserDto { email: string; name: string }
interface UpdateUserDto { name?: string; email?: string }
type Role = "user" | "admin" | "moderator"
// Гранулярные capabilities
class CanReadUsers extends Context.Tag("CanReadUsers")<
CanReadUsers,
{ readonly findById: (id: string) => Effect.Effect<User | null> }
>() {}
class CanWriteUsers extends Context.Tag("CanWriteUsers")<
CanWriteUsers,
{
readonly create: (data: CreateUserDto) => Effect.Effect<User>
readonly update: (id: string, data: UpdateUserDto) => Effect.Effect<User>
}
>() {}
class CanDeleteUsers extends Context.Tag("CanDeleteUsers")<
CanDeleteUsers,
{ readonly delete: (id: string) => Effect.Effect<boolean> }
>() {}
class CanManagePasswords extends Context.Tag("CanManagePasswords")<
CanManagePasswords,
{ readonly changePassword: (id: string, password: string) => Effect.Effect<void> }
>() {}
class CanManageRoles extends Context.Tag("CanManageRoles")<
CanManageRoles,
{ readonly assignRole: (id: string, role: Role) => Effect.Effect<void> }
>() {}
// Функции с минимальными требованиями
const getUser = (id: string) => Effect.gen(function* () {
const reader = yield* CanReadUsers
return yield* reader.findById(id)
})
// Тип: Effect<User | null, never, CanReadUsers>
const createUser = (data: CreateUserDto) => Effect.gen(function* () {
const writer = yield* CanWriteUsers
return yield* writer.create(data)
})
// Тип: Effect<User, never, CanWriteUsers>
const adminDeleteUser = (id: string) => Effect.gen(function* () {
const deleter = yield* CanDeleteUsers
const reader = yield* CanReadUsers
const user = yield* reader.findById(id)
if (!user) return false
return yield* deleter.delete(id)
})
// Тип: Effect<boolean, never, CanReadUsers | CanDeleteUsers>Scoped сервис
Создайте FileWatcher сервис, который:
- Начинает watching директории при создании
- Останавливает watching при закрытии scope
- Предоставляет stream изменений
interface FileChange {
readonly type: "created" | "modified" | "deleted"
readonly path: string
}
// class FileWatcher extends Effect.Service<FileWatcher>()("FileWatcher", {
// scoped: ...
// }) {}
interface FileChange {
readonly type: "created" | "modified" | "deleted"
readonly path: string
}
class FileWatcher extends Effect.Service<FileWatcher>()("FileWatcher", {
scoped: Effect.gen(function* () {
// Создаём очередь для событий
const queue = yield* Queue.unbounded<FileChange>()
// Симуляция file watcher
const watcher = {
watch: (path: string) => {
console.log(`Started watching: ${path}`)
// В реальности здесь был бы fs.watch
return Effect.void
},
unwatch: () => {
console.log("Stopped watching")
return Effect.void
}
}
// Регистрируем cleanup
yield* Effect.addFinalizer(() =>
Effect.sync(() => console.log("FileWatcher cleanup"))
)
return {
changes: Stream.fromQueue(queue),
watch: (path: string) => watcher.watch(path),
unwatch: () => watcher.unwatch()
}
})
}) {}
// Использование
const program = Effect.gen(function* () {
const watcher = yield* FileWatcher
yield* watcher.watch("./src")
// В реальном коде здесь был бы pipe с Stream
yield* Effect.sleep("1 second")
yield* watcher.unwatch()
})
Effect.runPromise(program.pipe(Effect.provide(FileWatcher.Default)))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
class StorageError extends Data.TaggedError("StorageError")<{
readonly operation: string
readonly key: string
}>() {}
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>
}
class Storage extends Context.Tag("Storage")<Storage, StorageService>() {}
// Реализации
const InMemoryStorageLive = Layer.succeed(Storage, {
read: () => Effect.fail(new StorageError({ operation: "read", key: "" })),
write: () => Effect.void,
delete: () => Effect.void,
list: () => Effect.succeed([])
})
const FileSystemStorageLive = Layer.succeed(Storage, {
read: () => Effect.fail(new StorageError({ operation: "read", key: "" })),
write: () => Effect.void,
delete: () => Effect.void,
list: () => Effect.succeed([])
})
const S3StorageLive = Layer.succeed(Storage, {
read: () => Effect.fail(new StorageError({ operation: "read", key: "" })),
write: () => Effect.void,
delete: () => Effect.void,
list: () => Effect.succeed([])
})
// Выбор реализации на основе Config
const StorageLive = Layer.effect(
Storage,
Effect.gen(function* () {
const env = yield* Config.string("STORAGE_TYPE").pipe(Config.withDefault("memory"))
switch (env) {
case "s3":
return yield* Layer.build(S3StorageLive)
case "filesystem":
return yield* Layer.build(FileSystemStorageLive)
default:
return yield* Layer.build(InMemoryStorageLive)
}
})
)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 к методам
}
class Logger extends Context.Tag("Logger")<Logger, { log: (msg: string) => Effect.Effect<void> }>() {}
// Декоратор логирования
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> =>
Layer.effect(
tag,
Effect.gen(function* () {
const logger = yield* Logger
const service = yield* Layer.build(layer)
// Оборачиваем все методы в логирование
const loggedService = Object.fromEntries(
Object.entries(service).map(([key, fn]) => [
key,
(...args: any[]) => Effect.gen(function* () {
yield* logger.log(`Calling ${key}(${args.join(", ")})`)
const result = yield* (fn as any)(...args)
yield* logger.log(`${key} completed`)
return result
})
])
)
return loggedService as Context.Tag.Service<T>
})
)
// Декоратор 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>> =>
Layer.effect(
tag,
Effect.gen(function* () {
const service = yield* Layer.build(layer)
// Оборачиваем все методы в retry
const retriedService = Object.fromEntries(
Object.entries(service).map(([key, fn]) => [
key,
(...args: any[]) =>
Effect.retry(
(fn as any)(...args),
Schedule.recurs(maxRetries)
)
])
)
return retriedService as Context.Tag.Service<T>
})
)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
class ServiceNotFoundError extends Data.TaggedError("ServiceNotFoundError")<{
readonly name: string
}>() {}
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>
}
class ServiceRegistry extends Context.Tag("ServiceRegistry")<
ServiceRegistry,
ServiceRegistry
>() {}
const ServiceRegistryLive = Layer.effect(
ServiceRegistry,
Effect.gen(function* () {
const services = yield* Ref.make<Map<string, unknown>>(new Map())
const callbacks = yield* Ref.make<Array<(name: string) => void>>([])
return {
register: <T>(name: string, service: T) =>
Ref.update(services, (map) => {
const newMap = new Map(map)
newMap.set(name, service)
return newMap
}).pipe(
Effect.tap(() =>
Ref.get(callbacks).pipe(
Effect.flatMap((cbs) =>
Effect.all(cbs.map((cb) => Effect.sync(() => cb(name))))
)
)
)
),
get: <T>(name: string) =>
Ref.get(services).pipe(
Effect.flatMap((map) =>
map.has(name)
? Effect.succeed(map.get(name) as T)
: Effect.fail(new ServiceNotFoundError({ name }))
)
),
unregister: (name: string) =>
Ref.modify(services, (map) => {
const existed = map.has(name)
const newMap = new Map(map)
newMap.delete(name)
return [existed, newMap]
}),
list: () =>
Ref.get(services).pipe(
Effect.map((map) => Array.from(map.keys()))
),
onRegister: (callback: (name: string) => void) =>
Ref.update(callbacks, (cbs) => [...cbs, callback])
}
})
)Заключение
В этой статье мы изучили:
- 📖 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 систем.