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

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

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")
Упражнение

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> => {
  // Ваш код
}
Упражнение

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>>
} => {
  // Ваш код
}
Упражнение

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: {}
})
Упражнение

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")
Упражнение

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

Заключение

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

  • 📖 Generic сервисы — параметризованные интерфейсы для переиспользования
  • 📖 Фабрики тегов — программное создание тегов для generic типов
  • 📖 Branded Types — номинальная типизация для безопасности
  • 📖 Service Factory Pattern — полноценные фабрики с Tag, Layer и accessors

💡 Ключевой takeaway: Generic сервисы требуют уникальных идентификаторов для каждого конкретного типа. Используйте фабрики для создания типобезопасных, переиспользуемых сервисов.