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 для двух целей:
- Валидация: проверка, удовлетворяет ли объект правилу
- Выборка: поиск объектов, удовлетворяющих правилу
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({}) // Все записи
Сравнение подходов
| Характеристика | Specification | Filter 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" }))
Одна спецификация — два использования:
- Поиск:
repo.findMany(TodoSpecs.active)— найди все активные - Валидация:
evaluate(TodoValidation.canComplete)(todo)— можно ли завершить
Итоги
- Specification инкапсулирует критерий поиска в объект — решает проблему комбинаторного взрыва методов
- Чистые предикаты
(A) → boolean— основа функционального подхода к Specification - AST-подход (
Spec<A>= tagged union) позволяет интерпретировать спецификацию по-разному: in-memory filter, SQL WHERE, ElasticSearch query - Доменные спецификации (
TodoSpecs.active,TodoSpecs.urgent) — именованные бизнес-правила, переиспользуемые в поиске и валидации - Repository принимает Specification:
findMany(spec)заменяет десятки специализированных методов - SQL-интерпретатор конвертирует Spec → SQL WHERE для оптимальных запросов к БД
- Filter Object — простая альтернатива для проектов без сложных OR/NOT комбинаций
- Начинайте с простого: Filter Object → Specification по мере роста сложности