Типобезопасный домен: Гексагональная архитектура на базе Effect Generic Repository: параметризованный базовый контракт
Глава

Generic Repository: параметризованный базовый контракт

Решение проблемы дублирования через параметризацию типов. Базовый интерфейс Repository<A, Id>, ограничения типов (Identifiable). Generic InMemory-фабрика, Generic SQL-фабрика. Композиция через расширение: CrudRepository → QueryableRepository → BatchRepository. Когда НЕ использовать Generic Repository. Антипаттерны: God Repository, чрезмерное абстрагирование.

Введение: проблема дублирования

Когда в системе появляется несколько агрегатов — Todo, User, Project — каждый требует свой Repository. Без обобщений мы получим дублирование:

// TodoRepository
class TodoRepository extends Context.Tag("TodoRepository")<TodoRepository, {
  readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
  readonly findById: (id: TodoId) => Effect.Effect<Option.Option<Todo>, RepositoryError>
  readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
  readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}>() {}

// UserRepository — те же методы, другие типы
class UserRepository extends Context.Tag("UserRepository")<UserRepository, {
  readonly save: (user: User) => Effect.Effect<void, RepositoryError>
  readonly findById: (id: UserId) => Effect.Effect<Option.Option<User>, RepositoryError>
  readonly delete: (id: UserId) => Effect.Effect<void, RepositoryError>
  readonly findAll: () => Effect.Effect<ReadonlyArray<User>, RepositoryError>
}>() {}

// ProjectRepository — и снова...
class ProjectRepository extends Context.Tag("ProjectRepository")<ProjectRepository, {
  readonly save: (project: Project) => Effect.Effect<void, RepositoryError>
  readonly findById: (id: ProjectId) => Effect.Effect<Option.Option<Project>, RepositoryError>
  readonly delete: (id: ProjectId) => Effect.Effect<void, RepositoryError>
  readonly findAll: () => Effect.Effect<ReadonlyArray<Project>, RepositoryError>
}>() {}

Три Repository — один и тот же паттерн, отличаются только типы Entity и Id. Generic Repository решает эту проблему через параметризацию типов.


Базовый Generic Repository: интерфейс

Определение параметризованного интерфейса

import { Effect, Option } from "effect"

/**
 * Базовый контракт Repository для любого агрегата.
 * 
 * @typeParam A - тип агрегата (Aggregate Root)
 * @typeParam Id - тип идентификатора агрегата
 * @typeParam E - тип ошибки (по умолчанию RepositoryError)
 */
interface Repository<
  in out A,
  in out Id,
  out E = RepositoryError
> {
  /** Сохранить агрегат (upsert) */
  readonly save: (aggregate: A) => Effect.Effect<void, E>
  
  /** Найти по идентификатору */
  readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, E>
  
  /** Удалить по идентификатору (идемпотентно) */
  readonly delete: (id: Id) => Effect.Effect<void, E>
  
  /** Получить все агрегаты */
  readonly findAll: () => Effect.Effect<ReadonlyArray<A>, E>
  
  /** Проверить существование */
  readonly exists: (id: Id) => Effect.Effect<boolean, E>
  
  /** Подсчитать количество */
  readonly count: () => Effect.Effect<number, E>
}

Использование для конкретных агрегатов

Теперь конкретные Repository наследуют базовый интерфейс и добавляют доменно-специфичные методы:

// ── TodoRepository ──────────────────────────────
// Расширяет базовый Repository + доменные методы

interface TodoRepositoryShape extends Repository<Todo, TodoId> {
  readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByPriority: (priority: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findOverdue: (asOf: Date) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  TodoRepositoryShape
>() {}

// ── UserRepository ──────────────────────────────
interface UserRepositoryShape extends Repository<User, UserId> {
  readonly findByEmail: (email: Email) => Effect.Effect<Option.Option<User>, RepositoryError>
  readonly findByRole: (role: UserRole) => Effect.Effect<ReadonlyArray<User>, RepositoryError>
}

class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  UserRepositoryShape
>() {}

// ── ProjectRepository ───────────────────────────
interface ProjectRepositoryShape extends Repository<Project, ProjectId> {
  readonly findByOwner: (ownerId: UserId) => Effect.Effect<ReadonlyArray<Project>, RepositoryError>
  readonly findPublic: () => Effect.Effect<ReadonlyArray<Project>, RepositoryError>
}

class ProjectRepository extends Context.Tag("ProjectRepository")<
  ProjectRepository,
  ProjectRepositoryShape
>() {}

Ограничения типов: что можно передавать

Ограничение на агрегат

Не любой тип может быть агрегатом. Агрегат должен иметь идентификатор. Выразим это через ограничение типа:

/**
 * Базовое ограничение: агрегат должен иметь поле id.
 * Это гарантирует, что Repository может идентифицировать агрегат.
 */
interface Identifiable<Id> {
  readonly id: Id
}

/**
 * Repository работает только с Identifiable-объектами.
 */
interface Repository<
  A extends Identifiable<Id>,
  Id,
  E = RepositoryError
> {
  readonly save: (aggregate: A) => Effect.Effect<void, E>
  readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, E>
  readonly delete: (id: Id) => Effect.Effect<void, E>
  readonly findAll: () => Effect.Effect<ReadonlyArray<A>, E>
}

Теперь невозможно создать Repository для типа без идентификатора:

// ✅ Todo имеет id — компилируется
interface Todo extends Identifiable<TodoId> {
  readonly id: TodoId
  readonly title: TodoTitle
}
type TodoRepo = Repository<Todo, TodoId>  // OK

// ❌ Тип без id — не компилируется
interface Config {
  readonly theme: string
  readonly language: string
}
type ConfigRepo = Repository<Config, string>
// Error: Type 'Config' does not satisfy the constraint 'Identifiable<string>'

Ограничение на идентификатор

Идентификатор должен быть сравниваемым. В Effect для этого есть Equal:

import { Equal } from "effect"

/**
 * Идентификатор должен поддерживать сравнение.
 * Branded types из Schema автоматически это обеспечивают.
 */
interface Repository<
  A extends Identifiable<Id>,
  Id extends Equal.Equal,
  E = RepositoryError
> {
  readonly save: (aggregate: A) => Effect.Effect<void, E>
  readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, E>
  // ...
}

На практике branded types из Effect Schema уже реализуют необходимые протоколы, поэтому строгое ограничение через Equal.Equal может быть избыточным. Часто достаточно просто string-based branded types:

// Простой подход — branded string как Id
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type
// TodoId — это string & Brand<"TodoId">
// Сравнение через === работает корректно для строк

Паттерны реализации Generic Repository

Паттерн 1: Generic InMemory Repository

Создаём одну generic-реализацию InMemory Repository, переиспользуемую для любого агрегата:

import { Effect, Layer, Option, Context } from "effect"

/**
 * Фабрика InMemory Repository для любого агрегата.
 * Используется для тестов и прототипирования.
 * 
 * @param tag - Context.Tag сервиса
 * @param getId - функция извлечения Id из агрегата
 */
const makeInMemoryRepository = <
  A extends Identifiable<Id>,
  Id extends string,
  Tag extends Context.Tag<Tag, Repository<A, Id>>
>(
  tag: Tag,
  getId: (aggregate: A) => Id,
) =>
  Layer.sync(tag, () => {
    const store = new Map<Id, A>()
    
    return {
      save: (aggregate: A) =>
        Effect.sync(() => {
          store.set(getId(aggregate), aggregate)
        }),
      
      findById: (id: Id) =>
        Effect.sync(() =>
          Option.fromNullable(store.get(id))
        ),
      
      delete: (id: Id) =>
        Effect.sync(() => {
          store.delete(id)
        }),
      
      findAll: () =>
        Effect.sync(() =>
          Array.from(store.values()) as ReadonlyArray<A>
        ),
      
      exists: (id: Id) =>
        Effect.sync(() => store.has(id)),
      
      count: () =>
        Effect.sync(() => store.size),
    } satisfies Repository<A, Id>
  })

Использование:

// InMemory Repository для Todo — одна строка!
const TodoRepositoryInMemory = makeInMemoryRepository(
  TodoRepository,
  (todo: Todo) => todo.id,
)

// InMemory Repository для User — ещё одна строка!
const UserRepositoryInMemory = makeInMemoryRepository(
  UserRepository,
  (user: User) => user.id,
)

Паттерн 2: Generic адаптер с маппингом

Для реальных адаптеров (SQLite) нужен маппинг между доменными и инфраструктурными типами:

/**
 * Конфигурация для generic SQL-адаптера.
 */
interface SqlRepositoryConfig<A, Id extends string, Row> {
  readonly tableName: string
  readonly toRow: (aggregate: A) => Row
  readonly fromRow: (row: Row) => Effect.Effect<A, RepositoryError>
  readonly getId: (aggregate: A) => Id
  readonly columns: ReadonlyArray<string>
}

/**
 * Фабрика SQL Repository.
 */
const makeSqlRepository = <
  A extends Identifiable<Id>,
  Id extends string,
  Row extends Record<string, unknown>,
  Tag extends Context.Tag<Tag, Repository<A, Id>>
>(
  tag: Tag,
  config: SqlRepositoryConfig<A, Id, Row>,
) =>
  Layer.effect(tag,
    Effect.gen(function* () {
      const db = yield* SqliteClient
      
      return {
        save: (aggregate: A) =>
          Effect.try({
            try: () => {
              const row = config.toRow(aggregate)
              const columns = config.columns.join(", ")
              const placeholders = config.columns.map(() => "?").join(", ")
              const values = config.columns.map((col) => (row as any)[col])
              
              db.run(
                `INSERT OR REPLACE INTO ${config.tableName} (${columns}) VALUES (${placeholders})`,
                values
              )
            },
            catch: (error) =>
              new RepositoryError({
                operation: "save",
                message: `Failed to save to ${config.tableName}`,
                cause: error,
              }),
          }),
        
        findById: (id: Id) =>
          pipe(
            Effect.try({
              try: () =>
                db.query(`SELECT * FROM ${config.tableName} WHERE id = ?`).get(id),
              catch: (error) =>
                new RepositoryError({
                  operation: "findById",
                  message: `Failed to find in ${config.tableName}`,
                  cause: error,
                }),
            }),
            Effect.flatMap((row) =>
              row
                ? pipe(config.fromRow(row as Row), Effect.map(Option.some))
                : Effect.succeed(Option.none())
            ),
          ),
        
        delete: (id: Id) =>
          Effect.try({
            try: () => {
              db.run(`DELETE FROM ${config.tableName} WHERE id = ?`, [id])
            },
            catch: (error) =>
              new RepositoryError({
                operation: "delete",
                message: `Failed to delete from ${config.tableName}`,
                cause: error,
              }),
          }),
        
        findAll: () =>
          pipe(
            Effect.try({
              try: () =>
                db.query(`SELECT * FROM ${config.tableName}`).all() as ReadonlyArray<Row>,
              catch: (error) =>
                new RepositoryError({
                  operation: "findAll",
                  message: `Failed to query ${config.tableName}`,
                  cause: error,
                }),
            }),
            Effect.flatMap((rows) =>
              Effect.all(rows.map(config.fromRow))
            ),
          ),
        
        exists: (id: Id) =>
          pipe(
            Effect.try({
              try: () => {
                const row = db.query(
                  `SELECT 1 FROM ${config.tableName} WHERE id = ? LIMIT 1`
                ).get(id)
                return row !== null
              },
              catch: (error) =>
                new RepositoryError({
                  operation: "exists",
                  message: `Failed to check existence in ${config.tableName}`,
                  cause: error,
                }),
            }),
          ),
        
        count: () =>
          Effect.try({
            try: () => {
              const row = db.query(
                `SELECT COUNT(*) as cnt FROM ${config.tableName}`
              ).get() as { cnt: number }
              return row.cnt
            },
            catch: (error) =>
              new RepositoryError({
                operation: "count",
                message: `Failed to count in ${config.tableName}`,
                cause: error,
              }),
          }),
      } satisfies Repository<A, Id>
    })
  )

Использование:

// SQLite Repository для Todo
const TodoRepositorySqlite = makeSqlRepository(
  TodoRepository,
  {
    tableName: "todos",
    columns: ["id", "title", "priority", "status", "created_at", "completed_at"] as const,
    getId: (todo: Todo) => todo.id,
    toRow: (todo: Todo) => ({
      id: todo.id,
      title: todo.title,
      priority: todo.priority,
      status: todo.status,
      created_at: todo.createdAt.toISOString(),
      completed_at: pipe(todo.completedAt, Option.map(d => d.toISOString()), Option.getOrNull),
    }),
    fromRow: (row) =>
      Schema.decode(Todo)({
        id: row.id,
        title: row.title,
        priority: row.priority,
        status: row.status,
        createdAt: new Date(row.created_at as string),
        completedAt: row.completed_at ? Option.some(new Date(row.completed_at as string)) : Option.none(),
      }),
  }
)

Когда НЕ использовать Generic Repository

Проблема: Generic Repository может стать Leaky Abstraction

Generic Repository опасен тем, что навязывает одинаковый интерфейс всем агрегатам. Но разные агрегаты имеют разные потребности:

// TodoRepository: нужны доменные запросы
interface TodoRepositoryShape extends Repository<Todo, TodoId> {
  readonly findOverdue: (asOf: Date) => Effect.Effect<ReadonlyArray<Todo>>
  readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>>
}

// UserRepository: нужен поиск по email
interface UserRepositoryShape extends Repository<User, UserId> {
  readonly findByEmail: (email: Email) => Effect.Effect<Option.Option<User>>
}

// EventStoreRepository: вообще другая семантика — append-only
interface EventStore extends Repository<DomainEvent, EventId> {
  // ❌ delete не имеет смысла для Event Store!
  // ❌ save должен быть append-only, не upsert!
}

Правило: Generic для утилит, Specific для контрактов

Generic Repository — хорош для:

  • Утилитарного кода (InMemory-адаптер для тестов)
  • Базового набора методов, который гарантированно есть у всех Repository
  • Фабрик и хелперов

Specific Repository — обязателен для:

  • Доменных контрактов (каждый агрегат уникален)
  • Публичного API порта (то, что видит Application Layer)
  • Доменно-специфичных методов
// ✅ Рекомендуемый подход: Generic для базы + Specific для домена

// Базовый интерфейс — минимальный набор
interface Repository<A extends Identifiable<Id>, Id> {
  readonly save: (aggregate: A) => Effect.Effect<void, RepositoryError>
  readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, RepositoryError>
  readonly delete: (id: Id) => Effect.Effect<void, RepositoryError>
}

// Конкретный контракт — расширяет базу доменными методами
interface TodoRepositoryShape extends Repository<Todo, TodoId> {
  readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}

// Tag всегда с конкретным типом
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  TodoRepositoryShape
>() {}

Когда НЕ наследовать от Generic

  1. Event Store — другая семантика (append-only), delete и save-as-upsert неприменимы
  2. Read Model Repository — только чтение, save/delete не нужны
  3. Aggregate с составным ключомfindById(key1, key2) не вписывается в findById(Id)
// Event Store — НЕ наследует от Repository
interface EventStoreShape {
  readonly append: (
    streamId: string,
    events: ReadonlyArray<DomainEvent>,
    expectedVersion: number,
  ) => Effect.Effect<void, ConcurrencyError | RepositoryError>
  
  readonly loadStream: (
    streamId: string
  ) => Effect.Effect<ReadonlyArray<DomainEvent>, RepositoryError>
}

// Read Model — НЕ наследует от Repository
interface TodoReadModelShape {
  readonly getById: (id: TodoId) => Effect.Effect<Option.Option<TodoView>, RepositoryError>
  readonly list: (filter: TodoFilter) => Effect.Effect<PagedResult<TodoView>, RepositoryError>
  readonly stats: () => Effect.Effect<TodoStats, RepositoryError>
  // Нет save/delete — обновляется через проекции
}

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

Паттерн: Repository с Scope

Для Repository, которые управляют ресурсами (connection pool, файловые хэндлы), используем Layer.scoped:

const makeScopedRepository = <
  A extends Identifiable<Id>,
  Id extends string,
  Tag extends Context.Tag<Tag, Repository<A, Id>>
>(
  tag: Tag,
  config: SqlRepositoryConfig<A, Id, any>,
) =>
  Layer.scoped(tag,
    Effect.gen(function* () {
      // Acquire: открываем connection pool
      const pool = yield* Effect.acquireRelease(
        Effect.sync(() => createConnectionPool(config.tableName)),
        (pool) => Effect.sync(() => pool.close())
      )
      
      // Return: repository с managed pool
      return {
        save: (aggregate: A) =>
          pool.withConnection((conn) =>
            Effect.try(() => { /* ... */ })
          ),
        // ... остальные методы
      } satisfies Repository<A, Id>
    })
  )

Паттерн: Composition через расширение

Строим сложные Repository через композицию:

// Базовый Repository
interface CrudRepository<A extends Identifiable<Id>, Id> {
  readonly save: (aggregate: A) => Effect.Effect<void, RepositoryError>
  readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, RepositoryError>
  readonly delete: (id: Id) => Effect.Effect<void, RepositoryError>
}

// Расширение: поиск
interface QueryableRepository<A extends Identifiable<Id>, Id> 
  extends CrudRepository<A, Id> {
  readonly findAll: () => Effect.Effect<ReadonlyArray<A>, RepositoryError>
  readonly count: () => Effect.Effect<number, RepositoryError>
  readonly exists: (id: Id) => Effect.Effect<boolean, RepositoryError>
}

// Расширение: batch-операции
interface BatchRepository<A extends Identifiable<Id>, Id>
  extends QueryableRepository<A, Id> {
  readonly saveAll: (aggregates: ReadonlyArray<A>) => Effect.Effect<void, RepositoryError>
  readonly deleteAll: (ids: ReadonlyArray<Id>) => Effect.Effect<void, RepositoryError>
}

// Конкретный Repository выбирает нужный уровень
interface TodoRepositoryShape extends BatchRepository<Todo, TodoId> {
  readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}

Антипаттерны Generic Repository

Антипаттерн 1: God Repository

// ❌ Один Repository для всего
interface GenericRepository {
  readonly save: <A>(collection: string, aggregate: A) => Effect.Effect<void>
  readonly findById: <A>(collection: string, id: string) => Effect.Effect<A | null>
  readonly query: <A>(collection: string, filter: Record<string, unknown>) => Effect.Effect<A[]>
}
// Проблемы: потеря типобезопасности, нет доменных методов, 
// collection — строка (typo → runtime error)

Антипаттерн 2: Чрезмерное абстрагирование

// ❌ Абстракция ради абстракции
interface Repository<A, Id, E, C extends Context, T extends Transaction> {
  readonly save: (aggregate: A, ctx: C, tx?: T) => Effect.Effect<void, E>
  readonly findById: (id: Id, ctx: C, tx?: T) => Effect.Effect<Option.Option<A>, E>
  // ...
}
// 5 параметров типа — слишком сложно, сложно использовать

Антипаттерн 3: Generic без доменных методов

// ❌ Только generic методы, без доменной специфики
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  Repository<Todo, TodoId>  // ← ТОЛЬКО базовые методы
>() {}

// Проблема: где findActive()? findByPriority()? findOverdue()?
// Application Layer вынужден фильтровать в памяти:
const active = yield* repo.findAll().pipe(
  Effect.map(todos => todos.filter(t => t.status === "active"))
)
// ❌ Загружает ВСЕ todos в память, потом фильтрует — неэффективно

Практический рецепт

Рекомендуемый подход для реальных проектов:

1. Определите базовый интерфейс с 3-4 методами:

interface Repository<A extends Identifiable<Id>, Id> {
  readonly save: (aggregate: A) => Effect.Effect<void, RepositoryError>
  readonly findById: (id: Id) => Effect.Effect<Option.Option<A>, RepositoryError>
  readonly delete: (id: Id) => Effect.Effect<void, RepositoryError>
}

2. Для каждого агрегата — свой интерфейс, расширяющий базу:

interface TodoRepositoryShape extends Repository<Todo, TodoId> {
  readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}

3. Tag всегда типизирован конкретным интерфейсом:

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  TodoRepositoryShape
>() {}

4. Generic фабрики — для утилитарного кода (тесты, прототипы):

const TodoRepositoryTest = makeInMemoryRepository(TodoRepository, t => t.id)

Итоги

  1. Generic Repository решает проблему дублирования базовых методов (save, findById, delete)
  2. Ограничения типов (Identifiable<Id>) гарантируют, что только корректные агрегаты используются
  3. Конкретные Repository расширяют Generic доменными методами (findActive, findByPriority)
  4. Generic фабрики полезны для тестовых InMemory-адаптеров и SQL-адаптеров
  5. Не злоупотребляйте абстракцией: Event Store, Read Model — не наследуют от Repository
  6. Tag всегда конкретный: TodoRepository extends Context.Tag<..., TodoRepositoryShape>
  7. Композиция через расширение: CrudRepository → QueryableRepository → BatchRepository
  8. Минимальный базовый контракт: 3-4 метода, расширяйте по необходимости