Продвинутые паттерны сервисов
Generic сервисы, фабрики тегов, branded types.
Теория
Ограничения простых сервисов
Базовые сервисы работают с конкретными типами:
// Конкретный сервис для User
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect<User | null>
readonly save: (user: User) => Effect.Effect<User>
}
>() {}
// Нужен отдельный сервис для каждой сущности?
class ProductRepository extends Context.Tag("ProductRepository")<...>() {}
class OrderRepository extends Context.Tag("OrderRepository")<...>() {}
class CommentRepository extends Context.Tag("CommentRepository")<...>() {}
// И так далее...
Это приводит к дублированию кода и нарушению DRY. Решение — generic сервисы.
Generic сервисы: базовый подход
Создание параметризованного интерфейса репозитория:
// Generic интерфейс репозитория
interface Repository<T extends { readonly id: string }> {
readonly findById: (id: string) => Effect.Effect<T | null, RepositoryError>
readonly findAll: () => Effect.Effect<ReadonlyArray<T>, RepositoryError>
readonly save: (entity: T) => Effect.Effect<T, RepositoryError>
readonly update: (id: string, data: Partial<Omit<T, "id">>) => Effect.Effect<T, RepositoryError | NotFoundError>
readonly delete: (id: string) => Effect.Effect<void, RepositoryError | NotFoundError>
}
// Модели
interface User {
readonly id: string
readonly email: string
readonly name: string
}
interface Product {
readonly id: string
readonly name: string
readonly price: number
}
// Теги для конкретных репозиториев
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
Repository<User>
>() {}
class ProductRepository extends Context.Tag("ProductRepository")<
ProductRepository,
Repository<Product>
>() {}
Фабрика тегов для generic сервисов
Создание тегов программно:
// Базовая сущность
interface Entity {
readonly id: string
}
// Generic интерфейс
interface CrudService<T extends Entity> {
readonly findById: (id: string) => Effect.Effect<T | null>
readonly findAll: () => Effect.Effect<ReadonlyArray<T>>
readonly create: (data: Omit<T, "id">) => Effect.Effect<T>
readonly update: (id: string, data: Partial<Omit<T, "id">>) => Effect.Effect<T | null>
readonly delete: (id: string) => Effect.Effect<boolean>
}
// Фабрика для создания Tag
const createCrudServiceTag = <T extends Entity>(name: string) =>
Context.GenericTag<CrudService<T>>(`CrudService.${name}`)
// Использование фабрики
interface User extends Entity { email: string; name: string }
interface Product extends Entity { name: string; price: number }
interface Order extends Entity { userId: string; total: number }
const UserCrudService = createCrudServiceTag<User>("User")
const ProductCrudService = createCrudServiceTag<Product>("Product")
const OrderCrudService = createCrudServiceTag<Order>("Order")
// Типы корректны:
// UserCrudService: Context.Tag<CrudService<User>, CrudService<User>>
Проблема: Tag не сохраняет generic параметр
⚠️ Важное ограничение TypeScript:
// ❌ Это НЕ работает как ожидается
class GenericRepository<T> extends Context.Tag("GenericRepository")<
GenericRepository<T>,
Repository<T>
>() {}
// TypeScript не может различить:
// GenericRepository<User> и GenericRepository<Product>
// Они указывают на ОДИН И ТОТ ЖЕ тег в runtime!
Причина: классы в TypeScript не могут быть истинно generic на уровне runtime. Строковый идентификатор "GenericRepository" одинаков для всех инстансов.
Решение 1: Уникальные теги для каждого типа
// Создаём уникальный тег для каждого типа сущности
const makeRepositoryTag = <T extends Entity>(entityName: string) => {
// Уникальный идентификатор включает имя сущности
return class extends Context.Tag(`Repository.${entityName}`)<
typeof this,
Repository<T>
>() {
static readonly entityName = entityName
}
}
// Использование
class UserRepository extends makeRepositoryTag<User>("User") {}
class ProductRepository extends makeRepositoryTag<Product>("Product") {}
// Теперь это РАЗНЫЕ теги:
// UserRepository имеет идентификатор "Repository.User"
// ProductRepository имеет идентификатор "Repository.Product"
Решение 2: Branded Types
Использование branded types для уникальности:
// Brand для сущностей
type UserId = string & Brand.Brand<"UserId">
type ProductId = string & Brand.Brand<"ProductId">
// Теперь можем создать type-safe репозитории
interface TypedRepository<Id, T> {
readonly findById: (id: Id) => Effect.Effect<T | null>
readonly save: (entity: T) => Effect.Effect<T>
}
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
TypedRepository<UserId, User>
>() {}
class ProductRepository extends Context.Tag("ProductRepository")<
ProductRepository,
TypedRepository<ProductId, Product>
>() {}
// Type-safety:
const program = Effect.gen(function* () {
const userRepo = yield* UserRepository
const productRepo = yield* ProductRepository
// ✅ Корректно
const user = yield* userRepo.findById("user-1" as UserId)
// ❌ Ошибка компиляции: ProductId не совместим с UserId
// const wrong = yield* userRepo.findById("product-1" as ProductId)
})
Решение 3: Service Factory Pattern
Фабрика, создающая полноценные сервисы:
interface Entity {
readonly id: string;
}
interface RepositoryService<T extends Entity> {
readonly findById: (id: string) => Effect.Effect<T | null>;
readonly findAll: () => Effect.Effect<ReadonlyArray<T>>;
readonly save: (entity: T) => Effect.Effect<T>;
readonly delete: (id: string) => Effect.Effect<boolean>;
}
// Фабрика создаёт Tag, реализацию и Layer
const createRepository = <T extends Entity>(
entityName: string,
idGenerator: () => string = () => crypto.randomUUID(),
) => {
// Создаём уникальный Tag
class Tag extends Context.Tag(`Repository.${entityName}`)<
Tag,
RepositoryService<T>
>() {}
// In-memory реализация
const makeInMemory: Effect.Effect<RepositoryService<T>> = Effect.gen(
function* () {
const store = yield* Ref.make<ReadonlyMap<string, T>>(new Map());
return {
findById: (id) =>
Ref.get(store).pipe(Effect.map((m) => m.get(id) ?? null)),
findAll: () =>
Ref.get(store).pipe(Effect.map((m) => Array.from(m.values()))),
save: (entity) => {
const entityWithId = entity.id
? entity
: ({ ...entity, id: idGenerator() } as T);
return Ref.update(store, (m) =>
new Map(m).set(entityWithId.id, entityWithId),
).pipe(Effect.as(entityWithId));
},
delete: (id) =>
Ref.modify(store, (m) => {
const existed = m.has(id);
const newMap = new Map(m);
newMap.delete(id);
return [existed, newMap];
}),
};
},
);
// Layer
const Live = Layer.effect(Tag, makeInMemory);
// Accessor functions
const findById = Effect.serviceFunctionEffect(Tag, (repo) => repo.findById);
const findAll = Effect.serviceFunctionEffect(Tag, (repo) => repo.findAll);
const save = Effect.serviceFunctionEffect(Tag, (repo) => repo.save);
const remove = Effect.serviceFunctionEffect(Tag, (repo) => repo.delete);
return {
Tag,
Live,
findById,
findAll,
save,
remove,
} as const;
};
// Использование
interface User extends Entity {
readonly email: string;
readonly name: string;
}
interface Product extends Entity {
readonly name: string;
readonly price: number;
}
const UserRepo = createRepository<User>("User");
const ProductRepo = createRepository<Product>("Product");
// Программа
const program = Effect.gen(function* () {
// Создаём пользователя
const user = yield* UserRepo.save({
id: "",
email: "alice@example.com",
name: "Alice",
});
console.log("Created user:", user);
// Создаём продукт
const product = yield* ProductRepo.save({
id: "",
name: "Widget",
price: 99.99,
});
console.log("Created product:", product);
// Получаем все
const users = yield* UserRepo.findAll();
const products = yield* ProductRepo.findAll();
console.log("Users:", users.length);
console.log("Products:", products.length);
});
// Запуск с обоими репозиториями
const runnable = program.pipe(
Effect.provide(Layer.merge(UserRepo.Live, ProductRepo.Live)),
);
Effect.runPromise(runnable);
Generic сервисы с constraints
Ограничение типов через constraints:
// Constraint: сущность должна иметь timestamps
interface Timestamped {
readonly createdAt: Date
readonly updatedAt: Date
}
// Constraint: сущность должна поддерживать soft delete
interface SoftDeletable {
readonly deletedAt: Date | null
}
// Generic сервис с constraints
interface AuditableRepository<T extends Entity & Timestamped & SoftDeletable> {
readonly findById: (id: string) => Effect.Effect<T | null>
readonly findActive: () => Effect.Effect<ReadonlyArray<T>> // Без deleted
readonly findDeleted: () => Effect.Effect<ReadonlyArray<T>> // Только deleted
readonly save: (entity: Omit<T, "id" | "createdAt" | "updatedAt">) => Effect.Effect<T>
readonly softDelete: (id: string) => Effect.Effect<T | null>
readonly restore: (id: string) => Effect.Effect<T | null>
readonly hardDelete: (id: string) => Effect.Effect<boolean>
}
// Модель с constraints
interface AuditableUser extends Entity, Timestamped, SoftDeletable {
readonly email: string
readonly name: string
}
// Tag
class AuditableUserRepository extends Context.Tag("AuditableUserRepository")<
AuditableUserRepository,
AuditableRepository<AuditableUser>
>() {}
Параметризованные методы в сервисах
Сервисы с generic методами:
class SerializationError extends Data.TaggedError("SerializationError")<{
cause: unknown;
}> {}
class DeserializationError extends Data.TaggedError("DeserializationError")<{
cause: unknown;
}> {}
interface User {
id: string;
email: string;
name: string;
}
// Сервис с generic методами
interface SerializerInterface {
readonly serialize: <T>(
value: T,
) => Effect.Effect<string, SerializationError>;
readonly deserialize: <T>(
json: string,
) => Effect.Effect<T, DeserializationError>;
}
class Serializer extends Context.Tag("Serializer")<
Serializer,
SerializerInterface
>() {}
// Реализация
const jsonSerializer: SerializerInterface = {
serialize: <T>(value: T) =>
Effect.try({
try: () => JSON.stringify(value),
catch: (e) => new SerializationError({ cause: e }),
}),
deserialize: <T>(json: string) =>
Effect.try({
try: () => JSON.parse(json) as T,
catch: (e) => new DeserializationError({ cause: e }),
}),
};
// Использование
const program = Effect.gen(function* () {
const serializer = yield* Serializer;
// Generic вызовы
const userJson = yield* serializer.serialize<User>({
id: "1",
name: "Alice",
email: "a@b.c",
});
const user = yield* serializer.deserialize<User>(userJson);
console.log(user.name); // "Alice"
}).pipe(Effect.provideService(Serializer, jsonSerializer));
⚠️ Важно: Direct method access через Effect.Tag НЕ работает с generic методами!
// Effect.Tag НЕ поддерживает generic методы
class Serializer extends Effect.Tag("Serializer")<
Serializer,
{ serialize: <T>(value: T) => Effect.Effect<string> }
>() {}
// ❌ Serializer.serialize<User>(user) — НЕ работает
// ✅ Нужен явный доступ через yield* Serializer
Conditional Types в сервисах
Использование conditional types для адаптивных интерфейсов:
interface Entity {
id: string;
}
// Conditional type: разное поведение для разных типов
type RepositoryMethod<
T,
HasSoftDelete extends boolean,
> = HasSoftDelete extends true
? {
readonly delete: (id: string) => Effect.Effect<void>;
readonly softDelete: (id: string) => Effect.Effect<void>;
readonly restore: (id: string) => Effect.Effect<void>;
}
: {
readonly delete: (id: string) => Effect.Effect<void>;
};
// Сервис с conditional методами
type ConditionalRepository<
T extends Entity,
HasSoftDelete extends boolean = true,
> = {
readonly findById: (id: string) => Effect.Effect<T | null>;
readonly save: (entity: T) => Effect.Effect<T>;
} & RepositoryMethod<T, HasSoftDelete>;
// Использование
interface RegularEntity extends Entity {
name: string;
}
interface SoftDeletableEntity extends Entity {
name: string;
deletedAt: Date | null;
}
// Обычный репозиторий — только delete
class RegularRepo extends Context.Tag("RegularRepo")<
RegularRepo,
ConditionalRepository<RegularEntity, false>
>() {}
// С soft delete — delete + softDelete + restore
class SoftDeleteRepo extends Context.Tag("SoftDeleteRepo")<
SoftDeleteRepo,
ConditionalRepository<SoftDeletableEntity, true>
>() {}
Mapped Types для генерации сервисов
Автоматическая генерация методов:
// Модель
interface UserModel {
readonly id: string;
readonly email: string;
readonly name: string;
readonly age: number;
}
// Генерируем методы фильтрации для каждого поля
type FilterMethods<T> = {
readonly [K in keyof T as `findBy${Capitalize<K & string>}`]: (
value: T[K],
) => Effect.Effect<ReadonlyArray<T>>;
};
// Базовые методы + сгенерированные
type GeneratedRepository<T extends { readonly id: string }> =
FilterMethods<T> & {
readonly findById: (id: string) => Effect.Effect<T | null>;
readonly findAll: () => Effect.Effect<ReadonlyArray<T>>;
readonly save: (entity: T) => Effect.Effect<T>;
};
// Результат для UserModel:
// {
// findById: (id: string) => Effect<User | null>
// findAll: () => Effect<User[]>
// save: (entity: User) => Effect<User>
// findByEmail: (value: string) => Effect<User[]> // Сгенерировано
// findByName: (value: string) => Effect<User[]> // Сгенерировано
// findByAge: (value: number) => Effect<User[]> // Сгенерировано
// }
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
GeneratedRepository<UserModel>
>() {}
Примеры
Пример 1: Полная фабрика репозиториев
// === Типы ===
interface Entity {
readonly id: string;
}
interface WithTimestamps {
readonly createdAt: Date;
readonly updatedAt: Date;
}
// Ошибки
class NotFoundError extends Data.TaggedError("NotFoundError")<{
readonly entity: string;
readonly id: string;
}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly entity: string;
readonly message: string;
}> {}
// === Фабрика ===
interface RepositoryConfig<T extends Entity> {
readonly entityName: string;
readonly validate?: (entity: T) => Effect.Effect<void, ValidationError>;
readonly generateId?: () => string;
}
interface Repository<T extends Entity> {
readonly findById: (id: string) => Effect.Effect<T, NotFoundError>;
readonly findByIdOption: (id: string) => Effect.Effect<Option.Option<T>>;
readonly findAll: () => Effect.Effect<ReadonlyArray<T>>;
readonly findWhere: (
predicate: (entity: T) => boolean,
) => 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, NotFoundError | ValidationError>;
readonly upsert: (entity: T) => Effect.Effect<T, ValidationError>;
readonly delete: (id: string) => Effect.Effect<void, NotFoundError>;
readonly deleteWhere: (
predicate: (entity: T) => boolean,
) => Effect.Effect<number>;
readonly count: () => Effect.Effect<number>;
readonly exists: (id: string) => Effect.Effect<boolean>;
}
const createRepository = <T extends Entity>(config: RepositoryConfig<T>) => {
const {
entityName,
validate = () => Effect.void,
generateId = () => crypto.randomUUID(),
} = config;
// Tag
class Tag extends Context.Tag(`Repository.${entityName}`)<
Tag,
Repository<T>
>() {
static readonly entityName = entityName;
}
// In-memory реализация
const makeInMemory: Effect.Effect<Repository<T>> = Effect.gen(function* () {
const store = yield* Ref.make<ReadonlyMap<string, T>>(new Map());
const repo: Repository<T> = {
findById: (id) =>
Effect.gen(function* () {
const map = yield* Ref.get(store);
const entity = map.get(id);
if (!entity) {
return yield* Effect.fail(
new NotFoundError({ entity: entityName, id }),
);
}
return entity;
}),
findByIdOption: (id) =>
Ref.get(store).pipe(Effect.map((m) => Option.fromNullable(m.get(id)))),
findAll: () =>
Ref.get(store).pipe(Effect.map((m) => Array.from(m.values()))),
findWhere: (predicate) =>
Ref.get(store).pipe(
Effect.map((m) => Array.from(m.values()).filter(predicate)),
),
create: (data) =>
Effect.gen(function* () {
const entity = { ...data, id: generateId() } as T;
yield* validate(entity);
yield* Ref.update(store, (m) => new Map(m).set(entity.id, entity));
return entity;
}),
update: (id, data) =>
Effect.gen(function* () {
const existing = yield* repo.findById(id);
const updated = { ...existing, ...data } as T;
yield* validate(updated);
yield* Ref.update(store, (m) => new Map(m).set(id, updated));
return updated;
}),
upsert: (entity) =>
Effect.gen(function* () {
yield* validate(entity);
const id = entity.id || generateId();
const entityWithId = { ...entity, id } as T;
yield* Ref.update(store, (m) => new Map(m).set(id, entityWithId));
return entityWithId;
}),
delete: (id) =>
Effect.gen(function* () {
yield* repo.findById(id); // Проверяем существование
yield* Ref.update(store, (m) => {
const newMap = new Map(m);
newMap.delete(id);
return newMap;
});
}),
deleteWhere: (predicate) =>
Ref.modify(store, (m) => {
const entries = Array.from(m.entries());
const toDelete = entries.filter(([, v]) => predicate(v));
const newMap = new Map(entries.filter(([, v]) => !predicate(v)));
return [toDelete.length, newMap];
}),
count: () => Ref.get(store).pipe(Effect.map((m) => m.size)),
exists: (id) => Ref.get(store).pipe(Effect.map((m) => m.has(id))),
};
return repo;
});
const InMemoryLayer = Layer.effect(Tag, makeInMemory);
// Accessor functions
return {
Tag,
InMemoryLayer,
findById: Effect.serviceFunctionEffect(Tag, (r) => r.findById),
findByIdOption: Effect.serviceFunctionEffect(Tag, (r) => r.findByIdOption),
findAll: Effect.serviceFunctionEffect(Tag, (r) => r.findAll),
findWhere: Effect.serviceFunctionEffect(Tag, (r) => r.findWhere),
create: Effect.serviceFunctionEffect(Tag, (r) => r.create),
update: Effect.serviceFunctionEffect(Tag, (r) => r.update),
upsert: Effect.serviceFunctionEffect(Tag, (r) => r.upsert),
delete: Effect.serviceFunctionEffect(Tag, (r) => r.delete),
deleteWhere: Effect.serviceFunctionEffect(Tag, (r) => r.deleteWhere),
count: Effect.serviceFunctionEffect(Tag, (r) => r.count),
exists: Effect.serviceFunctionEffect(Tag, (r) => r.exists),
} as const;
};
// === Использование ===
interface User extends Entity {
readonly email: string;
readonly name: string;
readonly age: number;
}
interface Product extends Entity {
readonly name: string;
readonly price: number;
readonly inStock: boolean;
}
const UserRepo = createRepository<User>({
entityName: "User",
validate: (user) => {
if (!user.email.includes("@")) {
return Effect.fail(
new ValidationError({ entity: "User", message: "Invalid email" }),
);
}
if (user.age < 0) {
return Effect.fail(
new ValidationError({
entity: "User",
message: "Age must be positive",
}),
);
}
return Effect.void;
},
});
const ProductRepo = createRepository<Product>({
entityName: "Product",
validate: (product) => {
if (product.price < 0) {
return Effect.fail(
new ValidationError({
entity: "Product",
message: "Price must be positive",
}),
);
}
return Effect.void;
},
});
// Программа
const demo = Effect.gen(function* () {
// Создаём пользователей
const alice = yield* UserRepo.create({
email: "alice@example.com",
name: "Alice",
age: 30,
});
const bob = yield* UserRepo.create({
email: "bob@example.com",
name: "Bob",
age: 25,
});
console.log("Created users:", alice.id, bob.id);
// Создаём продукты
const widget = yield* ProductRepo.create({
name: "Widget",
price: 99.99,
inStock: true,
});
const gadget = yield* ProductRepo.create({
name: "Gadget",
price: 149.99,
inStock: false,
});
console.log("Created products:", widget.id, gadget.id);
// Поиск
const youngUsers = yield* UserRepo.findWhere((u) => u.age < 28);
console.log(
"Young users:",
youngUsers.map((u) => u.name),
);
const inStockProducts = yield* ProductRepo.findWhere((p) => p.inStock);
console.log(
"In stock:",
inStockProducts.map((p) => p.name),
);
// Статистика
const userCount = yield* UserRepo.count();
const productCount = yield* ProductRepo.count();
console.log(`Users: ${userCount}, Products: ${productCount}`);
});
Effect.runPromise(
demo.pipe(
Effect.provide(
Layer.merge(UserRepo.InMemoryLayer, ProductRepo.InMemoryLayer),
),
),
);
Пример 2: Event Sourcing с generic сервисами
// === Event Sourcing Infrastructure ===
interface DomainEvent<T extends string = string> {
readonly type: T
readonly aggregateId: string
readonly timestamp: Date
readonly payload: unknown
}
interface Aggregate {
readonly id: string
readonly version: number
}
// Generic Event Store
interface EventStore<E extends DomainEvent> {
readonly append: (aggregateId: string, events: ReadonlyArray<E>) => Effect.Effect<void>
readonly load: (aggregateId: string) => Effect.Effect<ReadonlyArray<E>>
readonly loadFrom: (aggregateId: string, fromVersion: number) => Effect.Effect<ReadonlyArray<E>>
readonly subscribe: () => Stream.Stream<E>
}
// Generic Aggregate Repository
interface AggregateRepository<A extends Aggregate, E extends DomainEvent> {
readonly load: (id: string) => Effect.Effect<A | null>
readonly save: (aggregate: A, events: ReadonlyArray<E>) => Effect.Effect<void>
}
// Factory для Event Store
const createEventStore = <E extends DomainEvent>(eventType: string) => {
class Tag extends Context.Tag(`EventStore.${eventType}`)<Tag, EventStore<E>>() {}
const makeInMemory: Effect.Effect<EventStore<E>> = Effect.gen(function* () {
const events = yield* Ref.make<ReadonlyMap<string, ReadonlyArray<E>>>(new Map())
const subscribers = yield* Queue.unbounded<E>()
return {
append: (aggregateId, newEvents) => Effect.gen(function* () {
yield* Ref.update(events, (m) => {
const existing = m.get(aggregateId) ?? []
return new Map(m).set(aggregateId, [...existing, ...newEvents])
})
// Notify subscribers
for (const event of newEvents) {
yield* Queue.offer(subscribers, event)
}
}),
load: (aggregateId) => Ref.get(events).pipe(
Effect.map((m) => m.get(aggregateId) ?? [])
),
loadFrom: (aggregateId, fromVersion) => Ref.get(events).pipe(
Effect.map((m) => (m.get(aggregateId) ?? []).slice(fromVersion))
),
subscribe: () => Stream.fromQueue(subscribers)
}
})
return { Tag, InMemoryLayer: Layer.effect(Tag, makeInMemory) }
}
// Factory для Aggregate Repository
const createAggregateRepository = <
A extends Aggregate,
E extends DomainEvent,
EventStoreTag extends Context.Tag<any, EventStore<E>>
>(
aggregateName: string,
eventStoreTag: EventStoreTag,
reducer: (state: A | null, event: E) => A
) => {
class Tag extends Context.Tag(`AggregateRepo.${aggregateName}`)<
Tag,
AggregateRepository<A, E>
>() {}
const makeLive: Effect.Effect<AggregateRepository<A, E>, never, Context.Tag.Identifier<EventStoreTag>> =
Effect.gen(function* () {
const eventStore = yield* eventStoreTag
return {
load: (id) => Effect.gen(function* () {
const events = yield* eventStore.load(id)
if (events.length === 0) return null
return events.reduce(reducer, null as A | null)
}),
save: (aggregate, events) => Effect.gen(function* () {
yield* eventStore.append(aggregate.id, events)
})
}
})
return { Tag, Live: Layer.effect(Tag, makeLive) }
}
// === Использование: User Domain ===
interface UserAggregate extends Aggregate {
readonly email: string
readonly name: string
readonly status: "active" | "suspended" | "deleted"
}
type UserEvent =
| DomainEvent<"UserCreated"> & { payload: { email: string; name: string } }
| DomainEvent<"UserRenamed"> & { payload: { name: string } }
| DomainEvent<"UserSuspended"> & { payload: {} }
| DomainEvent<"UserActivated"> & { payload: {} }
| DomainEvent<"UserDeleted"> & { payload: {} }
const UserEventStore = createEventStore<UserEvent>("User")
const userReducer = (state: UserAggregate | null, event: UserEvent): UserAggregate => {
switch (event.type) {
case "UserCreated":
return {
id: event.aggregateId,
version: 1,
email: event.payload.email,
name: event.payload.name,
status: "active"
}
case "UserRenamed":
return state ? { ...state, name: event.payload.name, version: state.version + 1 } : state!
case "UserSuspended":
return state ? { ...state, status: "suspended", version: state.version + 1 } : state!
case "UserActivated":
return state ? { ...state, status: "active", version: state.version + 1 } : state!
case "UserDeleted":
return state ? { ...state, status: "deleted", version: state.version + 1 } : state!
}
}
const UserRepository = createAggregateRepository(
"User",
UserEventStore.Tag,
userReducer
)
// Application Layer
const UserAppLayer = Layer.provide(
UserRepository.Live,
UserEventStore.InMemoryLayer
)
Пример 3: Plugin System с generic сервисами
// === Plugin Infrastructure ===
interface Plugin<Config, API> {
readonly name: string;
readonly version: string;
readonly initialize: (config: Config) => Effect.Effect<API>;
readonly shutdown: () => Effect.Effect<void>;
}
interface PluginManager<Plugins extends Record<string, Plugin<any, any>>> {
readonly getPlugin: <K extends keyof Plugins>(
name: K,
) => Effect.Effect<
ReturnType<Plugins[K]["initialize"]> extends Effect.Effect<
infer A,
any,
any
>
? A
: never
>;
readonly listPlugins: () => Effect.Effect<ReadonlyArray<string>>;
readonly isEnabled: (name: string) => Effect.Effect<boolean>;
}
// Factory для Plugin Manager
const createPluginManager = <Plugins extends Record<string, Plugin<any, any>>>(
plugins: Plugins,
configs: { [K in keyof Plugins]: Parameters<Plugins[K]["initialize"]>[0] },
) => {
type PluginAPIs = {
[K in keyof Plugins]: ReturnType<
Plugins[K]["initialize"]
> extends Effect.Effect<infer A, any, any>
? A
: never;
};
class Tag extends Context.Tag("PluginManager")<
Tag,
PluginManager<Plugins>
>() {}
const makeLive = Effect.gen(function* () {
// Initialize all plugins
const apis = {} as PluginAPIs;
for (const [name, plugin] of Object.entries(plugins)) {
const config = configs[name as keyof Plugins];
const api = yield* plugin.initialize(config);
(apis as any)[name] = api;
}
// Register shutdown handlers
yield* Effect.addFinalizer(() =>
Effect.all(
Object.values(plugins).map((p) => p.shutdown()),
{ concurrency: "unbounded" },
).pipe(Effect.asVoid),
);
return {
getPlugin: <K extends keyof Plugins>(name: K) =>
Effect.succeed(apis[name] as any),
listPlugins: () => Effect.succeed(Object.keys(plugins)),
isEnabled: (name: string) => Effect.succeed(name in plugins),
} as PluginManager<Plugins>;
});
return { Tag, Live: Layer.scoped(Tag, makeLive) };
};
// === Конкретные плагины ===
// Analytics Plugin
interface AnalyticsConfig {
readonly endpoint: string;
readonly batchSize: number;
}
interface AnalyticsAPI {
readonly track: (event: string, data?: object) => Effect.Effect<void>;
readonly identify: (userId: string, traits?: object) => Effect.Effect<void>;
}
const analyticsPlugin: Plugin<AnalyticsConfig, AnalyticsAPI> = {
name: "analytics",
version: "1.0.0",
initialize: (config) =>
Effect.gen(function* () {
console.log(`Analytics initialized: ${config.endpoint}`);
return {
track: (event, data) =>
Effect.sync(() => console.log(`[Analytics] Track: ${event}`, data)),
identify: (userId, traits) =>
Effect.sync(() =>
console.log(`[Analytics] Identify: ${userId}`, traits),
),
};
}),
shutdown: () => Effect.sync(() => console.log("Analytics shutdown")),
};
// Caching Plugin
interface CacheConfig {
readonly maxSize: number;
readonly ttlMs: number;
}
interface CacheAPI {
readonly get: <T>(key: string) => Effect.Effect<T | null>;
readonly set: <T>(key: string, value: T) => Effect.Effect<void>;
}
const cachePlugin: Plugin<CacheConfig, CacheAPI> = {
name: "cache",
version: "1.0.0",
initialize: (config) =>
Effect.gen(function* () {
const cache = new Map<string, { value: unknown; expires: number }>();
console.log(`Cache initialized: maxSize=${config.maxSize}`);
return {
get: <T>(key: string) =>
Effect.sync(() => {
const entry = cache.get(key);
if (!entry || entry.expires < Date.now()) {
cache.delete(key);
return null;
}
return entry.value as T;
}),
set: <T>(key: string, value: T) =>
Effect.sync(() => {
if (cache.size >= config.maxSize) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.set(key, { value, expires: Date.now() + config.ttlMs });
}),
};
}),
shutdown: () => Effect.sync(() => console.log("Cache shutdown")),
};
// === Собираем всё вместе ===
const plugins = {
analytics: analyticsPlugin,
cache: cachePlugin,
};
const pluginConfigs = {
analytics: { endpoint: "https://analytics.example.com", batchSize: 100 },
cache: { maxSize: 1000, ttlMs: 60000 },
};
const PluginManager = createPluginManager(plugins, pluginConfigs);
// Использование
const program = Effect.gen(function* () {
const pm = yield* PluginManager.Tag;
const analytics = yield* pm.getPlugin("analytics");
const cache = yield* pm.getPlugin("cache");
yield* analytics.track("app_started");
yield* cache.set("lastVisit", new Date());
const available = yield* pm.listPlugins();
console.log("Available plugins:", available);
});
Effect.runPromise(program.pipe(Effect.provide(PluginManager.Live)));
Пример 4: Type-Safe Query Builder
// === Type-Safe Query Builder ===
// Операторы сравнения
type ComparisonOperator =
| "eq"
| "ne"
| "gt"
| "gte"
| "lt"
| "lte"
| "like"
| "in";
// Условие запроса
type WhereCondition<T> = {
[K in keyof T]?: {
readonly operator: ComparisonOperator;
readonly value: T[K] | ReadonlyArray<T[K]>;
};
};
// Сортировка
type OrderBy<T> = {
readonly field: keyof T;
readonly direction: "asc" | "desc";
};
// Query object
interface Query<T> {
where: ReadonlyArray<WhereCondition<T>>;
orderBy: ReadonlyArray<OrderBy<T>>;
limit: number | null;
offset: number | null;
}
// Query Builder interface
interface QueryBuilder<T> {
readonly where: <K extends keyof T>(
field: K,
operator: ComparisonOperator,
value: T[K] | ReadonlyArray<T[K]>,
) => QueryBuilder<T>;
readonly orderBy: (
field: keyof T,
direction?: "asc" | "desc",
) => QueryBuilder<T>;
readonly limit: (n: number) => QueryBuilder<T>;
readonly offset: (n: number) => QueryBuilder<T>;
readonly build: () => Query<T>;
}
// QueryExecutor service
interface QueryExecutor<T> {
readonly execute: (query: Query<T>) => Effect.Effect<ReadonlyArray<T>>;
readonly count: (query: Query<T>) => Effect.Effect<number>;
readonly first: (query: Query<T>) => Effect.Effect<T | null>;
}
// Factory
const createQueryService = <T extends { id: string }>(entityName: string) => {
// Tag
class Tag extends Context.Tag(`QueryExecutor.${entityName}`)<
Tag,
QueryExecutor<T>
>() {}
// Query Builder
const query = (): QueryBuilder<T> => {
const state: Query<T> = {
where: [],
orderBy: [],
limit: null,
offset: null,
};
const builder: QueryBuilder<T> = {
where: (field, operator, value) => {
state.where = [
...state.where,
{ [field]: { operator, value } } as WhereCondition<T>,
];
return builder;
},
orderBy: (field, direction = "asc") => {
state.orderBy = [...state.orderBy, { field, direction }];
return builder;
},
limit: (n) => {
(state as any).limit = n;
return builder;
},
offset: (n) => {
(state as any).offset = n;
return builder;
},
build: () => ({ ...state }),
};
return builder;
};
// Accessor functions
const execute = Effect.serviceFunctionEffect(Tag, (e) => e.execute);
const first = Effect.serviceFunctionEffect(Tag, (e) => e.first);
const count = Effect.serviceFunctionEffect(Tag, (e) => e.count);
return { Tag, query, execute, count, first };
};
// === Использование ===
interface User {
readonly id: string;
readonly name: string;
readonly age: number;
readonly status: "active" | "inactive";
}
const Users = createQueryService<User>("User");
const findActiveAdults = Users.query()
.where("status", "eq", "active")
.where("age", "gte", 18)
.orderBy("name", "asc")
.limit(10)
.build();
const program = Effect.gen(function* () {
const users = yield* Users.execute(findActiveAdults);
console.log("Found users:", users);
const total = yield* Users.count(
Users.query().where("status", "eq", "active").build(),
);
console.log("Total active:", total);
});
Упражнения
Простая фабрика тегов
interface Entity { readonly id: string }
interface ReadRepository<T extends Entity> {
readonly findById: (id: string) => Effect.Effect<T | null>
readonly findAll: () => Effect.Effect<ReadonlyArray<T>>
}
// Задание: создайте фабрику для ReadRepository тегов
const createReadRepositoryTag = <T extends Entity>(name: string) => {
// Ваш код
}
// Использование:
interface User extends Entity { name: string }
interface Product extends Entity { price: number }
const UserReadRepo = createReadRepositoryTag<User>("User")
const ProductReadRepo = createReadRepositoryTag<Product>("Product")const createReadRepositoryTag = <T extends Entity>(name: string) => {
return Context.GenericTag<ReadRepository<T>>(`ReadRepository.${name}`)
}
// Использование
interface User extends Entity { name: string }
interface Product extends Entity { price: number }
const UserReadRepo = createReadRepositoryTag<User>("User")
const ProductReadRepo = createReadRepositoryTag<Product>("Product")Generic validator service
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly field: string
readonly message: string
}>() {}
interface Validator<T> {
readonly validate: (value: T) => Effect.Effect<T, ValidationError>
}
// Задание: создайте фабрику для Validator сервисов
// с возможностью добавления правил валидации
interface ValidatorBuilder<T> {
readonly rule: (
field: keyof T,
check: (value: T[keyof T]) => boolean,
message: string
) => ValidatorBuilder<T>
readonly build: () => Validator<T>
}
const createValidator = <T>(name: string): ValidatorBuilder<T> => {
// Ваш код
}const createValidator = <T>(name: string): ValidatorBuilder<T> => {
const rules: Array<{ field: keyof T; check: (value: any) => boolean; message: string }> = []
const builder: ValidatorBuilder<T> = {
rule: (field, check, message) => {
rules.push({ field, check, message })
return builder
},
build: () => ({
validate: (value: T) =>
Effect.gen(function* () {
for (const rule of rules) {
const fieldValue = value[rule.field]
if (!rule.check(fieldValue)) {
return yield* Effect.fail(
new ValidationError({ field: String(rule.field), message: rule.message })
)
}
}
return value
})
})
}
return builder
}
// Использование
interface User {
name: string
age: number
}
const UserValidator = createValidator<User>("User")
.rule("name", (v) => v.length >= 2, "Name too short")
.rule("age", (v) => v >= 0, "Age must be positive")
.build()Cache с generic key/value
interface CacheOptions {
readonly ttl: Duration.Duration
readonly maxSize: number
}
interface Cache<K, V> {
readonly get: (key: K) => Effect.Effect<V | null>
readonly set: (key: K, value: V) => Effect.Effect<void>
readonly delete: (key: K) => Effect.Effect<boolean>
readonly clear: () => Effect.Effect<void>
readonly size: () => Effect.Effect<number>
}
// Задание: создайте фабрику Cache с настраиваемыми типами ключа и значения
const createCacheService = <K, V>(
name: string,
options: CacheOptions
): {
Tag: Context.Tag<Cache<K, V>, Cache<K, V>>
InMemoryLayer: Layer.Layer<Cache<K, V>>
} => {
// Ваш код
}const createCacheService = <K, V>(
name: string,
options: CacheOptions
) => {
class Tag extends Context.Tag(`Cache.${name}`)<Tag, Cache<K, V>>() {}
const InMemoryLayer = Layer.effect(
Tag,
Effect.gen(function* () {
const store = yield* Ref.make<Map<K, { value: V; expires: number }>>(new Map())
return {
get: (key: K) =>
Ref.get(store).pipe(
Effect.map((map) => {
const entry = map.get(key)
if (!entry || entry.expires < Date.now()) {
return null
}
return entry.value
})
),
set: (key: K, value: V) =>
Ref.update(store, (map) => {
const newMap = new Map(map)
newMap.set(key, { value, expires: Date.now() + options.ttl })
return newMap
}),
delete: (key: K) =>
Ref.modify(store, (map) => {
const existed = map.has(key)
const newMap = new Map(map)
newMap.delete(key)
return [existed, newMap]
}),
clear: () => Ref.set(store, new Map()),
size: () => Ref.get(store).pipe(Effect.map((map) => map.size))
}
})
)
return { Tag, InMemoryLayer }
}State Machine Service
// Generic State Machine
interface StateMachine<State extends string, Event extends string> {
readonly current: () => Effect.Effect<State>
readonly transition: (event: Event) => Effect.Effect<State, InvalidTransitionError>
readonly canTransition: (event: Event) => Effect.Effect<boolean>
readonly history: () => Effect.Effect<ReadonlyArray<{ state: State; event: Event; timestamp: Date }>>
}
class InvalidTransitionError extends Data.TaggedError("InvalidTransitionError")<{
readonly from: string
readonly event: string
}>() {}
// Задание: создайте фабрику State Machine
type TransitionMap<State extends string, Event extends string> = {
[S in State]: {
[E in Event]?: State
}
}
const createStateMachine = <State extends string, Event extends string>(
name: string,
initialState: State,
transitions: TransitionMap<State, Event>
): {
Tag: Context.Tag<StateMachine<State, Event>, StateMachine<State, Event>>
Layer: Layer.Layer<StateMachine<State, Event>>
} => {
// Ваш код
}
// Пример использования:
type OrderState = "pending" | "paid" | "shipped" | "delivered" | "cancelled"
type OrderEvent = "pay" | "ship" | "deliver" | "cancel"
const OrderStateMachine = createStateMachine<OrderState, OrderEvent>("Order", "pending", {
pending: { pay: "paid", cancel: "cancelled" },
paid: { ship: "shipped", cancel: "cancelled" },
shipped: { deliver: "delivered" },
delivered: {},
cancelled: {}
})const createStateMachine = <State extends string, Event extends string>(
name: string,
initialState: State,
transitions: TransitionMap<State, Event>
) => {
class Tag extends Context.Tag(`StateMachine.${name}`)<Tag, StateMachine<State, Event>>() {}
interface HistoryEntry {
state: State
event: Event
timestamp: Date
}
const Layer = Layer.effect(
Tag,
Effect.gen(function* () {
const currentState = yield* Ref.make<State>(initialState)
const history = yield* Ref.make<ReadonlyArray<HistoryEntry>>([])
return {
current: () => Ref.get(currentState),
transition: (event: Event) =>
Effect.gen(function* () {
const current = yield* Ref.get(currentState)
const nextState = transitions[current][event]
if (nextState === undefined) {
return yield* Effect.fail(
new InvalidTransitionError({ from: current, event })
)
}
yield* Ref.set(currentState, nextState)
yield* Ref.update(history, (h) => [
...h,
{ state: nextState, event, timestamp: new Date() }
])
return nextState
}),
canTransition: (event: Event) =>
Ref.get(currentState).pipe(
Effect.map((current) => transitions[current][event] !== undefined)
),
history: () => Ref.get(history)
}
})
)
return { Tag, Layer }
}Type-safe Event Emitter
// Type-safe event emitter где события типизированы
type EventMap = Record<string, unknown>
interface TypedEventEmitter<Events extends EventMap> {
readonly emit: <K extends keyof Events>(
event: K,
payload: Events[K]
) => Effect.Effect<void>
readonly on: <K extends keyof Events>(
event: K
) => Stream.Stream<Events[K]>
readonly once: <K extends keyof Events>(
event: K
) => Effect.Effect<Events[K]>
}
// Задание: создайте фабрику TypedEventEmitter
const createEventEmitter = <Events extends EventMap>(
name: string
): {
Tag: Context.Tag<TypedEventEmitter<Events>, TypedEventEmitter<Events>>
Layer: Layer.Layer<TypedEventEmitter<Events>>
} => {
// Ваш код
}
// Пример:
interface AppEvents {
"user:created": { userId: string; email: string }
"user:deleted": { userId: string }
"order:placed": { orderId: string; total: number }
}
const AppEventEmitter = createEventEmitter<AppEvents>("App")const createEventEmitter = <Events extends EventMap>(name: string) => {
class Tag extends Context.Tag(`EventEmitter.${name}`)<Tag, TypedEventEmitter<Events>>() {}
const Layer = Layer.effect(
Tag,
Effect.gen(function* () {
const listeners = yield* Ref.make<Map<string, Queue.Queue<any>>>(new Map())
return {
emit: <K extends keyof Events>(event: K, payload: Events[K]) =>
Ref.get(listeners).pipe(
Effect.flatMap((map) => {
const queue = map.get(event as string)
return queue ? Queue.offer(queue, payload) : Effect.void
})
),
on: <K extends keyof Events>(event: K) =>
Stream.asyncEffect<Events[K], never>((emit) =>
Effect.gen(function* () {
const queue = yield* Queue.unbounded<Events[K]>()
yield* Ref.update(listeners, (map) => {
const newMap = new Map(map)
newMap.set(event as string, queue)
return newMap
})
while (true) {
const value = yield* Queue.take(queue)
yield* emit.single(value)
}
})
),
once: <K extends keyof Events>(event: K) =>
Effect.gen(function* () {
const queue = yield* Queue.unbounded<Events[K]>()
yield* Ref.update(listeners, (map) => {
const newMap = new Map(map)
newMap.set(event as string, queue)
return newMap
})
return yield* Queue.take(queue)
})
}
})
)
return { Tag, Layer }
}Specification Pattern
// Specification pattern для типобезопасных запросов
interface Specification<T> {
readonly isSatisfiedBy: (entity: T) => boolean
readonly and: (other: Specification<T>) => Specification<T>
readonly or: (other: Specification<T>) => Specification<T>
readonly not: () => Specification<T>
}
interface SpecificationRepository<T extends { id: string }> {
readonly findAll: (spec: Specification<T>) => Effect.Effect<ReadonlyArray<T>>
readonly count: (spec: Specification<T>) => Effect.Effect<number>
readonly exists: (spec: Specification<T>) => Effect.Effect<boolean>
}
// Задание: создайте систему Specification с фабрикой
// 1. Базовые спецификации: equals, greaterThan, lessThan, contains
// 2. Композиция: and, or, not
// 3. Generic репозиторий с поддержкой Specification// Specification implementation
const createSpecification = <T>(predicate: (entity: T) => boolean): Specification<T> => ({
isSatisfiedBy: predicate,
and: (other) =>
createSpecification((entity) => predicate(entity) && other.isSatisfiedBy(entity)),
or: (other) =>
createSpecification((entity) => predicate(entity) || other.isSatisfiedBy(entity)),
not: () =>
createSpecification((entity) => !predicate(entity))
})
// Base specifications
const Spec = {
equals: <T, K extends keyof T>(field: K, value: T[K]) =>
createSpecification<T>((entity) => entity[field] === value),
greaterThan: <T, K extends keyof T>(field: K, value: T[K]) =>
createSpecification<T>((entity) => entity[field] > value),
lessThan: <T, K extends keyof T>(field: K, value: T[K]) =>
createSpecification<T>((entity) => entity[field] < value),
contains: <T, K extends keyof T>(field: K, substring: string) =>
createSpecification<T>((entity) =>
String(entity[field]).includes(substring)
)
}
// Repository factory
const createSpecRepository = <T extends { id: string }>(name: string) => {
class Tag extends Context.Tag(`SpecRepo.${name}`)<Tag, SpecificationRepository<T>>() {}
const InMemoryLayer = Layer.effect(
Tag,
Effect.gen(function* () {
const store = yield* Ref.make<ReadonlyArray<T>>([])
return {
findAll: (spec) =>
Ref.get(store).pipe(
Effect.map((entities) => entities.filter(spec.isSatisfiedBy))
),
count: (spec) =>
Ref.get(store).pipe(
Effect.map((entities) => entities.filter(spec.isSatisfiedBy).length)
),
exists: (spec) =>
Ref.get(store).pipe(
Effect.map((entities) => entities.some(spec.isSatisfiedBy))
)
}
})
)
return { Tag, Spec, InMemoryLayer }
}Заключение
В этой статье мы изучили:
- 📖 Generic сервисы — параметризованные интерфейсы для переиспользования
- 📖 Фабрики тегов — программное создание тегов для generic типов
- 📖 Branded Types — номинальная типизация для безопасности
- 📖 Service Factory Pattern — полноценные фабрики с Tag, Layer и accessors
💡 Ключевой takeaway: Generic сервисы требуют уникальных идентификаторов для каждого конкретного типа. Используйте фабрики для создания типобезопасных, переиспользуемых сервисов.