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

Specification Pattern: гибкие запросы без утечки SQL

Specification как решение проблемы комбинаторного взрыва методов поиска. Specification как чистый предикат и как AST с интерпретаторами. Конструкторы (where, and, or, not), интерпретатор evaluate. Specification в контракте Repository. Доменные спецификации как именованные бизнес-правила. InMemory и SQL интерпретаторы. Сравнение Specification vs Filter Object. Specification для валидации.

Введение: проблема разрастания методов поиска

По мере развития приложения количество методов поиска в Repository растёт:

// Начинали с малого...
interface TodoRepository {
  readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
}

// Через месяц...
interface TodoRepository {
  readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findActive: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findCompleted: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByStatus: (s: Status) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findOverdue: (now: Date) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByOwner: (id: UserId) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findByTag: (tag: Tag) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findActiveByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  readonly findOverdueByOwner: (id: UserId, now: Date) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  // Комбинаторный взрыв: каждое новое условие × каждое существующее = ×2 методов
}

Проблемы:

  • Комбинаторный взрыв: N фильтров → 2^N комбинаций
  • Раздутый контракт: каждый адаптер должен реализовать все методы
  • Нарушение OCP: новый фильтр = изменение интерфейса + всех адаптеров

Specification Pattern решает эту проблему, инкапсулируя критерии поиска в отдельные объекты.


Specification Pattern: теория

Определение

Specification (Спецификация) — объект, который инкапсулирует бизнес-правило и может быть:

  • Проверен (isSatisfiedBy(candidate)) — подходит ли объект?
  • Скомбинирован (and, or, not) — составные условия
  • Передан в Repository как критерий поиска

Эрик Эванс ввёл Specification в DDD для двух целей:

  1. Валидация: проверка, удовлетворяет ли объект правилу
  2. Выборка: поиск объектов, удовлетворяющих правилу

Specification как чистый предикат

В функциональном стиле Specification — это предикат: функция A → boolean:

// Спецификация = предикат
type Specification<A> = (candidate: A) => boolean

// Примеры спецификаций для Todo
const isActive: Specification<Todo> = (todo) => todo.status === "active"
const isHighPriority: Specification<Todo> = (todo) => todo.priority === "high"
const isOverdue = (now: Date): Specification<Todo> => 
  (todo) => todo.dueDate !== undefined && todo.dueDate < now

// Композиция
const and = <A>(s1: Specification<A>, s2: Specification<A>): Specification<A> =>
  (candidate) => s1(candidate) && s2(candidate)

const or = <A>(s1: Specification<A>, s2: Specification<A>): Specification<A> =>
  (candidate) => s1(candidate) || s2(candidate)

const not = <A>(spec: Specification<A>): Specification<A> =>
  (candidate) => !spec(candidate)

// Составная спецификация
const activeAndHighPriority = and(isActive, isHighPriority)
const urgentOrOverdue = or(isHighPriority, isOverdue(new Date()))

Specification в Effect-ts: типобезопасная реализация

Подход 1: Specification как Schema-like DSL

Создаём типизированный DSL для спецификаций:

import { Data, Equal, pipe } from "effect"

// ════════════════════════════════════════════
// Алгебра спецификаций (AST)
// ════════════════════════════════════════════

type Spec<A> =
  | { readonly _tag: "All" }
  | { readonly _tag: "None" }
  | { readonly _tag: "Predicate"; readonly predicate: (a: A) => boolean; readonly label: string }
  | { readonly _tag: "And"; readonly left: Spec<A>; readonly right: Spec<A> }
  | { readonly _tag: "Or"; readonly left: Spec<A>; readonly right: Spec<A> }
  | { readonly _tag: "Not"; readonly inner: Spec<A> }

// ════════════════════════════════════════════
// Конструкторы
// ════════════════════════════════════════════

const all = <A>(): Spec<A> => ({ _tag: "All" })

const none = <A>(): Spec<A> => ({ _tag: "None" })

const where = <A>(label: string, predicate: (a: A) => boolean): Spec<A> => ({
  _tag: "Predicate",
  predicate,
  label,
})

const and = <A>(left: Spec<A>, right: Spec<A>): Spec<A> => ({
  _tag: "And",
  left,
  right,
})

const or = <A>(left: Spec<A>, right: Spec<A>): Spec<A> => ({
  _tag: "Or",
  left,
  right,
})

const not = <A>(inner: Spec<A>): Spec<A> => ({
  _tag: "Not",
  inner,
})

Интерпретатор: выполнение спецификации

Спецификация — это данные (AST). Интерпретатор решает, как выполнить:

// ════════════════════════════════════════════
// Интерпретатор: Spec → предикат (для in-memory фильтрации)
// ════════════════════════════════════════════

const evaluate = <A>(spec: Spec<A>) => (candidate: A): boolean => {
  switch (spec._tag) {
    case "All":
      return true
    case "None":
      return false
    case "Predicate":
      return spec.predicate(candidate)
    case "And":
      return evaluate(spec.left)(candidate) && evaluate(spec.right)(candidate)
    case "Or":
      return evaluate(spec.left)(candidate) || evaluate(spec.right)(candidate)
    case "Not":
      return !evaluate(spec.inner)(candidate)
  }
}

// Использование
const spec = and(
  where<Todo>("active", (t) => t.status === "active"),
  where<Todo>("high-priority", (t) => t.priority === "high"),
)

const todos: ReadonlyArray<Todo> = [/* ... */]
const filtered = todos.filter(evaluate(spec))

Specification в контракте Repository

Вариант 1: Repository принимает Specification

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

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>
    
    // findAll заменяется на findMany со Specification
    readonly findMany: (
      spec: Spec<Todo>
    ) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
    
    // Подсчёт по спецификации
    readonly count: (
      spec: Spec<Todo>
    ) => Effect.Effect<number, RepositoryError>
  }
>() {}

Теперь вместо N методов поиска — один findMany:

// Вместо findActive()
yield* repo.findMany(where("active", (t) => t.status === "active"))

// Вместо findByPriority("high")
yield* repo.findMany(where("high", (t) => t.priority === "high"))

// Вместо findActiveByPriority("high") — комбинация!
yield* repo.findMany(
  and(
    where("active", (t) => t.status === "active"),
    where("high-priority", (t) => t.priority === "high"),
  )
)

// Вместо findOverdue(now) + findByOwner(userId) — любая комбинация!
yield* repo.findMany(
  and(
    where("overdue", (t) => t.dueDate !== undefined && t.dueDate < new Date()),
    where("owned", (t) => t.ownerId === userId),
  )
)

Вариант 2: Доменные спецификации как именованные константы

Спецификации определяются в домене как именованные бизнес-правила:

// ════════════════════════════════════════════
// src/domain/specifications/TodoSpecs.ts
// Доменные спецификации — бизнес-правила
// ════════════════════════════════════════════

export const TodoSpecs = {
  /** Все активные задачи */
  active: where<Todo>("active", (t) => t.status === "active"),
  
  /** Все завершённые задачи */
  completed: where<Todo>("completed", (t) => t.status === "completed"),
  
  /** Все архивированные задачи */
  archived: where<Todo>("archived", (t) => t.status === "archived"),
  
  /** Задачи с высоким приоритетом */
  highPriority: where<Todo>("high-priority", (t) => t.priority === "high"),
  
  /** Критические задачи */
  critical: where<Todo>("critical", (t) => t.priority === "critical"),
  
  /** Просроченные задачи */
  overdue: (asOf: Date) =>
    where<Todo>("overdue", (t) =>
      t.status === "active" && t.dueDate !== undefined && t.dueDate < asOf
    ),
  
  /** Задачи конкретного владельца */
  ownedBy: (ownerId: UserId) =>
    where<Todo>("owned-by", (t) => t.ownerId === ownerId),
  
  /** Срочные задачи — высокий приоритет И активные */
  urgent: and(
    where<Todo>("active", (t) => t.status === "active"),
    or(
      where<Todo>("high", (t) => t.priority === "high"),
      where<Todo>("critical", (t) => t.priority === "critical"),
    ),
  ),
} as const

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

// Чистый доменный код — никаких SQL
const getUrgentTodos = Effect.gen(function* () {
  const repo = yield* TodoRepository
  return yield* repo.findMany(TodoSpecs.urgent)
})

const getMyOverdueTodos = (userId: UserId) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const now = new Date()
    return yield* repo.findMany(
      and(TodoSpecs.overdue(now), TodoSpecs.ownedBy(userId))
    )
  })

Реализация в адаптерах

InMemory-адаптер: простая фильтрация

const TodoRepositoryInMemory = Layer.sync(TodoRepository, () => {
  const store = new Map<string, Todo>()
  
  return {
    save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
    
    findById: (id) => Effect.sync(() => Option.fromNullable(store.get(id))),
    
    delete: (id) => Effect.sync(() => { store.delete(id) }),
    
    // Specification → filter
    findMany: (spec) =>
      Effect.sync(() => {
        const predicate = evaluate(spec)
        return Array.from(store.values()).filter(predicate) as ReadonlyArray<Todo>
      }),
    
    count: (spec) =>
      Effect.sync(() => {
        const predicate = evaluate(spec)
        return Array.from(store.values()).filter(predicate).length
      }),
  }
})

SQLite-адаптер: два подхода

Подход A: Загрузить всё и фильтровать (простой, для малых данных)

const TodoRepositorySqlite = Layer.effect(TodoRepository,
  Effect.gen(function* () {
    const db = yield* SqliteClient
    
    return {
      // ...
      findMany: (spec) =>
        pipe(
          // Загружаем всё из БД
          Effect.try(() => db.query("SELECT * FROM todos").all()),
          // Маппим в доменные объекты
          Effect.flatMap((rows) => Effect.all(rows.map(rowToTodo))),
          // Фильтруем по спецификации в памяти
          Effect.map((todos) => todos.filter(evaluate(spec))),
        ),
    }
  })
)

Этот подход прост, но загружает все данные в память. Для маленьких таблиц — нормально. Для больших — нужен подход B.

Подход B: Спецификация → SQL WHERE (оптимальный)

Для production нужен интерпретатор, который превращает Specification в SQL:

// ════════════════════════════════════════════
// Интерпретатор: Spec → SQL WHERE clause
// ════════════════════════════════════════════

interface SqlClause {
  readonly where: string
  readonly params: ReadonlyArray<unknown>
}

/**
 * Реестр маппинга спецификаций на SQL.
 * Каждая именованная спецификация имеет SQL-эквивалент.
 */
const todoSpecToSql: Record<string, (spec: Spec<Todo>) => SqlClause> = {
  "active": () => ({ where: "status = ?", params: ["active"] }),
  "completed": () => ({ where: "status = ?", params: ["completed"] }),
  "archived": () => ({ where: "status = ?", params: ["archived"] }),
  "high-priority": () => ({ where: "priority = ?", params: ["high"] }),
  "critical": () => ({ where: "priority = ?", params: ["critical"] }),
}

const specToSql = (spec: Spec<Todo>): SqlClause => {
  switch (spec._tag) {
    case "All":
      return { where: "1 = 1", params: [] }
    
    case "None":
      return { where: "1 = 0", params: [] }
    
    case "Predicate": {
      const handler = todoSpecToSql[spec.label]
      if (handler) return handler(spec)
      // Fallback: загружаем всё и фильтруем в памяти
      return { where: "1 = 1", params: [] }
    }
    
    case "And": {
      const left = specToSql(spec.left)
      const right = specToSql(spec.right)
      return {
        where: `(${left.where}) AND (${right.where})`,
        params: [...left.params, ...right.params],
      }
    }
    
    case "Or": {
      const left = specToSql(spec.left)
      const right = specToSql(spec.right)
      return {
        where: `(${left.where}) OR (${right.where})`,
        params: [...left.params, ...right.params],
      }
    }
    
    case "Not": {
      const inner = specToSql(spec.inner)
      return {
        where: `NOT (${inner.where})`,
        params: inner.params,
      }
    }
  }
}

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

const TodoRepositorySqlite = Layer.effect(TodoRepository,
  Effect.gen(function* () {
    const db = yield* SqliteClient
    
    return {
      findMany: (spec) =>
        pipe(
          Effect.try(() => {
            const { where, params } = specToSql(spec)
            return db.query(`SELECT * FROM todos WHERE ${where}`).all(...params)
          }),
          Effect.flatMap((rows) => Effect.all(rows.map(rowToTodo))),
          Effect.mapError(toRepositoryError("findMany")),
        ),
      
      count: (spec) =>
        Effect.try({
          try: () => {
            const { where, params } = specToSql(spec)
            const row = db.query(
              `SELECT COUNT(*) as cnt FROM todos WHERE ${where}`
            ).get(...params) as { cnt: number }
            return row.cnt
          },
          catch: toRepositoryError("count"),
        }),
    }
  })
)

Расширенные спецификации

Сортировка и пагинация

Specification можно расширить сортировкой и пагинацией:

// ════════════════════════════════════════════
// QueryOptions — дополнение к Specification
// ════════════════════════════════════════════

interface SortField<A> {
  readonly field: keyof A & string
  readonly direction: "asc" | "desc"
}

interface QueryOptions<A> {
  readonly spec: Spec<A>
  readonly sort?: ReadonlyArray<SortField<A>>
  readonly offset?: number
  readonly limit?: number
}

// Расширяем контракт Repository
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    // ... базовые методы ...
    
    readonly findMany: (
      options: QueryOptions<Todo>
    ) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
    
    readonly count: (
      spec: Spec<Todo>
    ) => Effect.Effect<number, RepositoryError>
  }
>() {}

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

// Активные задачи, сортировка по приоритету, первые 10
const result = yield* repo.findMany({
  spec: TodoSpecs.active,
  sort: [{ field: "priority", direction: "desc" }],
  offset: 0,
  limit: 10,
})

Типобезопасная сортировка

Используем Template Literal Types для безопасности полей:

// Только реальные поля Todo
type TodoSortField = keyof Pick<Todo, "title" | "priority" | "status" | "createdAt">

interface TodoQueryOptions {
  readonly spec: Spec<Todo>
  readonly sort?: ReadonlyArray<{
    readonly field: TodoSortField
    readonly direction: "asc" | "desc"
  }>
  readonly offset?: number
  readonly limit?: number
}

Specification vs Criteria/Filter объект

Альтернатива: простой Filter

Для менее сложных случаев достаточно типизированного Filter-объекта:

// ════════════════════════════════════════════
// Простой подход: Filter object
// ════════════════════════════════════════════

interface TodoFilter {
  readonly status?: Status
  readonly priority?: Priority
  readonly ownerId?: UserId
  readonly overdueAsOf?: Date
  readonly search?: string
}

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 findMany: (filter: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  }
>() {}

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

// Простое API
yield* repo.findMany({ status: "active", priority: "high" })
yield* repo.findMany({ ownerId: userId, overdueAsOf: new Date() })
yield* repo.findMany({})  // Все записи

Сравнение подходов

ХарактеристикаSpecificationFilter Object
СложностьВысокаяНизкая
ГибкостьПроизвольные комбинации (AND/OR/NOT)Только AND между полями
ТипобезопасностьСредняя (зависит от реализации)Высокая (статические поля)
SQL-маппингТребует интерпретаторПрямолинейный
ПереиспользованиеОтличное (именованные спецификации)Хорошее (spread/merge объектов)
Подходит дляСложные домены с динамическими правиламиБольшинство CRUD-приложений

Рекомендация

Для Todo-приложения и большинства проектов — начинайте с Filter Object. Переходите на Specification, когда:

  • Нужны OR-комбинации (Filter Object поддерживает только AND)
  • Нужны NOT-условия
  • Спецификации используются и для валидации, и для поиска
  • Домен сложный с динамическими бизнес-правилами
// ✅ Начните с простого Filter Object
interface TodoFilter {
  readonly status?: Status
  readonly priority?: Priority
}

// ✅ Перейдите на Specification, когда Filter недостаточен
// Пример: "активные И (высокий приоритет ИЛИ просроченные)"
// Это не выражается через простой Filter с AND-семантикой
const spec = and(
  TodoSpecs.active,
  or(TodoSpecs.highPriority, TodoSpecs.overdue(now)),
)

Specification для валидации

Specification используется не только для поиска, но и для валидации бизнес-правил:

// ════════════════════════════════════════════
// Спецификации как валидационные правила
// ════════════════════════════════════════════

const TodoValidation = {
  /** Задачу можно завершить только если она активна */
  canComplete: where<Todo>(
    "can-complete",
    (t) => t.status === "active"
  ),
  
  /** Задачу можно архивировать только если она завершена */
  canArchive: where<Todo>(
    "can-archive",
    (t) => t.status === "completed"
  ),
  
  /** Задача может иметь subtasks только если активна */
  canAddSubtask: where<Todo>(
    "can-add-subtask",
    (t) => t.status === "active" && (t.subtasks?.length ?? 0) < 20
  ),
}

// Использование в доменном сервисе
const completeTodo = (todo: Todo) =>
  evaluate(TodoValidation.canComplete)(todo)
    ? Effect.succeed(todo.complete())
    : Effect.fail(new InvalidTodoTransition({ from: todo.status, to: "completed" }))

Одна спецификация — два использования:

  1. Поиск: repo.findMany(TodoSpecs.active) — найди все активные
  2. Валидация: evaluate(TodoValidation.canComplete)(todo) — можно ли завершить

Итоги

  1. Specification инкапсулирует критерий поиска в объект — решает проблему комбинаторного взрыва методов
  2. Чистые предикаты (A) → boolean — основа функционального подхода к Specification
  3. AST-подход (Spec<A> = tagged union) позволяет интерпретировать спецификацию по-разному: in-memory filter, SQL WHERE, ElasticSearch query
  4. Доменные спецификации (TodoSpecs.active, TodoSpecs.urgent) — именованные бизнес-правила, переиспользуемые в поиске и валидации
  5. Repository принимает Specification: findMany(spec) заменяет десятки специализированных методов
  6. SQL-интерпретатор конвертирует Spec → SQL WHERE для оптимальных запросов к БД
  7. Filter Object — простая альтернатива для проектов без сложных OR/NOT комбинаций
  8. Начинайте с простого: Filter Object → Specification по мере роста сложности