Типобезопасный домен: Гексагональная архитектура на базе Effect Доменная модель: что в ней живёт, а что — нет
Глава

Доменная модель: что в ней живёт, а что — нет

Определение доменной модели, её место в Hexagonal Architecture, полный разбор элементов домена (Entity, Value Object, Aggregate, Event, Error, Service), таблица принадлежности элементов к слоям, анемичная vs богатая модель, эвристики определения границ домена

Введение: зачем нам доменная модель

В предыдущих модулях мы построили архитектурный фундамент: разобрали принципы Hexagonal Architecture, научились видеть порты и адаптеры, поняли, как Effect.Service становится портом, а Layer — адаптером. Теперь пора заглянуть внутрь гексагона — в его самое сердце.

Доменная модель — это представление бизнес-реальности в коде. Не технической реальности (базы данных, HTTP-запросы, файловая система), а именно бизнес-реальности — тех правил, процессов и ограничений, которые существуют в предметной области вашего приложения независимо от того, написано ли оно на TypeScript, Java или вовсе на бумаге.

Представьте, что вы разрабатываете систему управления задачами. Задача может быть создана, у неё есть заголовок, приоритет, срок. Задачу можно завершить, но нельзя завершить уже завершённую задачу. Эти правила существуют до того, как вы напишете первую строку кода — они диктуются предметной областью. Именно эти правила и составляют домен.

┌─────────────────────────────────────────────────────────────┐
│                    Внешний мир                              │
│  HTTP, CLI, WebSocket, gRPC, Message Queue                  │
│                                                             │
│    ┌─────────────────────────────────────────────────┐      │
│    │              Application Layer                   │      │
│    │  Use Cases, Orchestration, Command/Query         │      │
│    │                                                  │      │
│    │    ┌─────────────────────────────────────┐       │      │
│    │    │         DOMAIN MODEL                │       │      │
│    │    │                                     │       │      │
│    │    │  Entities, Value Objects,            │       │      │
│    │    │  Aggregates, Domain Events,          │       │      │
│    │    │  Domain Services, Business Rules     │       │      │
│    │    │                                     │       │      │
│    │    │  ★ ZERO infrastructure dependencies │       │      │
│    │    │  ★ Pure business logic              │       │      │
│    │    │  ★ Self-validating types            │       │      │
│    │    └─────────────────────────────────────┘       │      │
│    │                                                  │      │
│    └─────────────────────────────────────────────────┘      │
│                                                             │
│  SQLite, FileSystem, Email, External APIs                    │
└─────────────────────────────────────────────────────────────┘

Что такое «домен» в контексте разработки

Слово «домен» (domain) пришло из Domain-Driven Design (DDD) Эрика Эванса. В контексте разработки домен — это предметная область, которую автоматизирует ваше приложение.

Для интернет-магазина домен — это товары, заказы, корзины, оплата, доставка. Для банковского приложения — счета, транзакции, лимиты, курсы валют. Для нашего Todo-приложения — задачи, их статусы, приоритеты, категории, правила перехода между состояниями.

Ключевое свойство домена: он существует независимо от технологий. Если завтра вы перепишете приложение с TypeScript на Rust, замените SQLite на PostgreSQL, а REST на GraphQL — бизнес-правила останутся теми же. Задача по-прежнему не может быть завершена дважды. Приоритет по-прежнему бывает Low, Medium, High. Заголовок задачи по-прежнему не может быть пустым.

Домен vs Технология: фундаментальное разделение

// ❌ ЭТО НЕ ДОМЕН — это технология
// SQL-запрос, HTTP-ответ, конфигурация базы данных
const query = "SELECT * FROM todos WHERE status = 'active'"
const response = new Response(JSON.stringify(todos), { status: 200 })
const dbConfig = { host: "localhost", port: 5432 }

// ✅ ЭТО ДОМЕН — это бизнес-правила
// Задача не может иметь пустой заголовок
// Завершённую задачу нельзя завершить повторно
// Приоритет задачи влияет на порядок отображения

type Priority = "Low" | "Medium" | "High" | "Critical"

type TodoStatus = "Active" | "Completed" | "Archived"

// Бизнес-правило: переход из Active → Completed разрешён,
// но из Completed → Active — нет (необходимо Reopen)

Доменная модель в Hexagonal Architecture

В Hexagonal Architecture доменная модель занимает центральное положение. Все зависимости направлены внутрь — к домену. Домен не знает и не должен знать ни о каких внешних технологиях.

Это не метафора и не рекомендация — в нашем стеке это гарантия типовой системы. Если доменный код попытается импортировать что-то из инфраструктурного слоя, компилятор TypeScript просто не скомпилирует проект (при правильной настройке путей и модулей).

Слои гексагона и их ответственности

┌──────────────────────────────────────────────────────┐
│  DRIVING ADAPTERS (Primary)                          │
│  HTTP Controller, CLI Handler, WebSocket Handler     │
│  → Переводят внешние запросы в вызовы Application    │
├──────────────────────────────────────────────────────┤
│  APPLICATION LAYER                                    │
│  Use Cases, Command/Query Handlers                    │
│  → Оркестрирует доменную логику, НЕ содержит её      │
├──────────────────────────────────────────────────────┤
│  ★ DOMAIN LAYER ★                                    │
│  Entities, Value Objects, Aggregates, Events          │
│  → Содержит ВСЮ бизнес-логику                        │
│  → НУЛЕВЫЕ зависимости от внешних слоёв              │
├──────────────────────────────────────────────────────┤
│  DRIVEN ADAPTERS (Secondary)                          │
│  SQLite Repository, File Storage, Email Sender        │
│  → Реализуют порты, определённые доменом/приложением │
└──────────────────────────────────────────────────────┘

Что живёт в доменной модели

Давайте чётко определим, какие элементы принадлежат доменной модели. Каждый из этих элементов будет подробно разобран в последующих главах и модулях, но здесь важно увидеть полную картину.

1. Entities (Сущности)

Сущность — это объект с уникальной идентичностью, которая сохраняется на протяжении всего жизненного цикла. Два объекта-сущности равны тогда и только тогда, когда у них одинаковый идентификатор, независимо от значений остальных полей.

import { Schema } from "effect"

// TodoId — уникальный идентификатор задачи
class TodoId extends Schema.Class<TodoId>("TodoId")({
  value: Schema.String.pipe(Schema.brand("TodoId"))
}) {}

// Todo Entity — объект с идентичностью
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String.pipe(
    Schema.minLength(1),
    Schema.maxLength(255)
  ),
  status: Schema.Literal("Active", "Completed", "Archived"),
  priority: Schema.Literal("Low", "Medium", "High", "Critical"),
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {}

Ключевое свойство: два Todo с id = "abc" — это одна и та же задача, даже если у них разные заголовки (например, заголовок был изменён).

2. Value Objects (Объекты-значения)

Объект-значение не имеет идентичности. Два Value Object равны, если все их поля равны. Value Object неизменяем (immutable) и самовалидируется.

import { Schema } from "effect"

// Email — Value Object с встроенной валидацией
class Email extends Schema.Class<Email>("Email")({
  value: Schema.String.pipe(
    Schema.pattern(
      /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
    ),
    Schema.brand("Email")
  )
}) {}

// Priority — Value Object как перечисление
class Priority extends Schema.Class<Priority>("Priority")({
  value: Schema.Literal("Low", "Medium", "High", "Critical"),
}) {
  // Бизнес-логика: сравнение приоритетов
  static readonly order = {
    Low: 0,
    Medium: 1,
    High: 2,
    Critical: 3,
  } as const

  isHigherThan(other: Priority): boolean {
    return Priority.order[this.value] > Priority.order[other.value]
  }
}

3. Aggregates (Агрегаты)

Агрегат — это кластер связанных сущностей и объектов-значений, объединённых границей транзакционной согласованности. Агрегат гарантирует, что все его инварианты соблюдаются при любом изменении.

// TodoList — агрегат, содержащий список задач
// Бизнес-правило: в списке не может быть двух задач с одинаковым заголовком
class TodoList extends Schema.Class<TodoList>("TodoList")({
  id: TodoListId,
  name: Schema.String.pipe(Schema.minLength(1)),
  todos: Schema.Array(Todo),
  maxTodos: Schema.Number.pipe(Schema.int(), Schema.positive()),
}) {
  // Инвариант: не превышен лимит задач
  get isFull(): boolean {
    return this.todos.length >= this.maxTodos
  }

  // Инвариант: нет дубликатов заголовков
  hasDuplicateTitle(title: string): boolean {
    return this.todos.some(
      (todo) => todo.title.toLowerCase() === title.toLowerCase()
    )
  }
}

4. Domain Events (Доменные события)

Доменное событие — это факт, произошедший в домене. Событие неизменяемо, произошло в прошлом и несёт информацию о том, что случилось.

import { Schema } from "effect"

class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    todoId: TodoId,
    title: Schema.String,
    priority: Schema.Literal("Low", "Medium", "High", "Critical"),
    occurredAt: Schema.DateFromSelf,
  }
) {}

class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
  "TodoCompleted",
  {
    todoId: TodoId,
    completedAt: Schema.DateFromSelf,
    occurredAt: Schema.DateFromSelf,
  }
) {}

// Union всех доменных событий
type TodoEvent = TodoCreated | TodoCompleted

5. Domain Services (Доменные сервисы)

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

import { Effect } from "effect"

// Доменный сервис: проверка уникальности заголовка
// Это чистая бизнес-логика, НЕ обращение к БД
const checkTitleUniqueness = (
  existingTodos: ReadonlyArray<Todo>,
  newTitle: string
): Effect.Effect<void, DuplicateTitleError> =>
  existingTodos.some(
    (t) => t.title.toLowerCase() === newTitle.toLowerCase()
  )
    ? Effect.fail(new DuplicateTitleError({ title: newTitle }))
    : Effect.void

6. Domain Errors (Доменные ошибки)

Ошибки — это полноценная часть домена. Они описывают ситуации, когда бизнес-правило нарушено. Доменная ошибка — это не 500 Internal Server Error, а осмысленное бизнес-исключение.

import { Data } from "effect"

class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
  readonly todoId: string
}> {}

class InvalidStatusTransitionError extends Data.TaggedError(
  "InvalidStatusTransitionError"
)<{
  readonly from: string
  readonly to: string
}> {}

class DuplicateTitleError extends Data.TaggedError("DuplicateTitleError")<{
  readonly title: string
}> {}

class TodoListFullError extends Data.TaggedError("TodoListFullError")<{
  readonly maxTodos: number
  readonly currentCount: number
}> {}

7. Business Rules (Бизнес-правила)

Бизнес-правила — это ограничения, которые домен накладывает на данные и операции. Они могут быть выражены явно через типы, валидацию или функции.

// Бизнес-правило: допустимые переходы между статусами
const VALID_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlySet<TodoStatus>> =
  new Map([
    ["Active", new Set(["Completed", "Archived"])],
    ["Completed", new Set(["Archived"])],
    ["Archived", new Set<TodoStatus>()], // Архив — конечное состояние
  ])

const canTransition = (
  from: TodoStatus,
  to: TodoStatus
): boolean => {
  const allowed = VALID_TRANSITIONS.get(from)
  return allowed !== undefined && allowed.has(to)
}

Что НЕ живёт в доменной модели

Столь же важно понимать, что не должно находиться в доменном слое. Любая утечка инфраструктурных деталей разрушает изоляцию и подрывает ценность всей архитектуры.

1. Детали персистентности

// ❌ НАРУШЕНИЕ: SQL в домене
class TodoRepository {
  async findById(id: string): Promise<Todo> {
    const row = await db.query("SELECT * FROM todos WHERE id = ?", [id])
    return mapRowToTodo(row)
  }
}

// ❌ НАРУШЕНИЕ: ORM-декораторы в доменной сущности
@Entity()
class Todo {
  @PrimaryGeneratedColumn()
  id!: string

  @Column()
  title!: string
}

// ✅ ПРАВИЛЬНО: домен определяет только контракт (порт)
// Реализация (SQL, ORM) живёт в адаптере
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String.pipe(Schema.minLength(1)),
  // ...чистые доменные поля, никаких @Column
}) {}

2. Сетевые вызовы и HTTP

// ❌ НАРУШЕНИЕ: fetch в доменной логике
const notifyUser = async (todo: Todo) => {
  await fetch("https://api.notifications.com/send", {
    method: "POST",
    body: JSON.stringify({ message: `Todo ${todo.title} completed` })
  })
}

// ✅ ПРАВИЛЬНО: домен генерирует событие, 
// а адаптер решает, как уведомить
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
  "TodoCompleted",
  { todoId: TodoId, completedAt: Schema.DateFromSelf }
) {}
// Уведомление — это реакция на событие в Application/Infrastructure слое

3. Фреймворк-специфичный код

// ❌ НАРУШЕНИЕ: Express/Hono/Koa в домене
import { Router } from "express"
const router = Router()
router.post("/todos", (req, res) => { /* ... */ })

// ❌ НАРУШЕНИЕ: React-компоненты в домене
const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => (
  <div>{todo.title}</div>
)

// ✅ ПРАВИЛЬНО: домен — чистый TypeScript + Effect
// Никаких импортов express, react, hono, etc.

4. Конфигурация и переменные окружения

// ❌ НАРУШЕНИЕ: чтение env в домене
const MAX_TODOS = parseInt(process.env.MAX_TODOS ?? "100")

// ✅ ПРАВИЛЬНО: лимит — часть доменной модели как параметр
class TodoList extends Schema.Class<TodoList>("TodoList")({
  maxTodos: Schema.Number.pipe(Schema.int(), Schema.positive()),
  // maxTodos задаётся при создании, а не читается из env
}) {}

5. Логирование и мониторинг

// ❌ НАРУШЕНИЕ: console.log/logger в домене
const completeTodo = (todo: Todo): Todo => {
  console.log(`Completing todo: ${todo.id}`)  // ❌
  logger.info("Todo completed", { id: todo.id })  // ❌
  return { ...todo, status: "Completed" }
}

// ✅ ПРАВИЛЬНО: домен возвращает результат,
// логирование — ответственность Application Layer или Middleware
const completeTodo = (
  todo: Todo
): Effect.Effect<Todo, InvalidStatusTransitionError> =>
  canTransition(todo.status, "Completed")
    ? Effect.succeed({ ...todo, status: "Completed" as const })
    : Effect.fail(
        new InvalidStatusTransitionError({
          from: todo.status,
          to: "Completed",
        })
      )

6. Сериализация и форматы передачи

// ❌ НАРУШЕНИЕ: JSON.stringify/parse в домене
class Todo {
  toJSON(): string {
    return JSON.stringify({ id: this.id, title: this.title })
  }

  static fromJSON(json: string): Todo {
    return new Todo(JSON.parse(json))
  }
}

// ✅ ПРАВИЛЬНО: Schema сама определяет encode/decode,
// но это используется на ГРАНИЦАХ, а не внутри домена
// Внутри домена мы работаем с типизированными объектами

7. Тайминги, retry, rate limiting

// ❌ НАРУШЕНИЕ: инфраструктурные паттерны в домене
const createTodo = async (data: CreateTodoInput) => {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await saveTodo(data)
    } catch {
      await sleep(1000 * attempt)
    }
  }
}

// ✅ ПРАВИЛЬНО: retry — это concern инфраструктуры/application
// Доменная функция просто выполняет бизнес-логику

Таблица принадлежности: что где живёт

ЭлементDomainApplicationInfrastructure
Бизнес-правила
Entities, Value Objects
Domain Events
Domain Errors
Валидация бизнес-правил
Use Case оркестрация
Авторизация
Логирование
SQL-запросы
HTTP-маршруты
Сериализация JSON
Конфигурация
Retry/Circuit Breaker
Кеширование
Отправка email

Доменная модель как чистые функции

В функциональном стиле с Effect-ts доменная модель выражается через чистые функции и неизменяемые данные. Это даёт фундаментальные преимущества:

Предсказуемость

Чистая функция при одних и тех же входных данных всегда возвращает одинаковый результат. Нет скрытых зависимостей, нет побочных эффектов.

// Чистая доменная функция: одни и те же входные данные → один и тот же результат
const calculatePriorityScore = (
  priority: Priority,
  isOverdue: boolean,
  daysSinceCreation: number
): number => {
  const baseScore = Priority.order[priority]
  const overdueBonus = isOverdue ? 10 : 0
  const ageBonus = Math.min(daysSinceCreation, 30) * 0.1
  return baseScore + overdueBonus + ageBonus
}

Тестируемость

Чистые функции тестируются тривиально — не нужны моки, стабы, тестовые базы данных. Просто вызови функцию и проверь результат.

import { describe, it, expect } from "bun:test"

describe("calculatePriorityScore", () => {
  it("should give overdue bonus", () => {
    const score = calculatePriorityScore("Medium", true, 5)
    expect(score).toBeGreaterThan(
      calculatePriorityScore("Medium", false, 5)
    )
  })
})

Композируемость

Маленькие чистые функции легко комбинируются в более сложные операции через Effect pipe.

import { Effect, pipe } from "effect"

const createTodo = (
  input: CreateTodoInput,
  existingTodos: ReadonlyArray<Todo>
): Effect.Effect<readonly [Todo, TodoCreated], CreateTodoError> =>
  pipe(
    // Шаг 1: Валидировать заголовок
    validateTitle(input.title),
    // Шаг 2: Проверить уникальность
    Effect.flatMap(() =>
      checkTitleUniqueness(existingTodos, input.title)
    ),
    // Шаг 3: Создать сущность
    Effect.flatMap(() => buildTodo(input)),
    // Шаг 4: Создать доменное событие
    Effect.map((todo) => [
      todo,
      new TodoCreated({
        todoId: todo.id,
        title: todo.title,
        priority: todo.priority,
        occurredAt: new Date(),
      }),
    ] as const)
  )

Анемичная модель vs Богатая модель

Это одна из ключевых дилемм доменного моделирования, и понимание разницы критически важно.

Анемичная модель (Anti-pattern)

В анемичной модели сущности — это просто контейнеры данных (DTO) без поведения. Вся логика вынесена в отдельные «сервисы».

// ❌ Анемичная модель — Entity без поведения
interface Todo {
  id: string
  title: string
  status: "Active" | "Completed" | "Archived"
  priority: "Low" | "Medium" | "High"
}

// Вся логика — в «сервисах»
class TodoService {
  complete(todo: Todo): Todo {
    if (todo.status !== "Active") {
      throw new Error("Cannot complete")
    }
    return { ...todo, status: "Completed" }
  }

  changeTitle(todo: Todo, newTitle: string): Todo {
    if (newTitle.length === 0) {
      throw new Error("Title cannot be empty")
    }
    return { ...todo, title: newTitle }
  }

  archive(todo: Todo): Todo {
    if (todo.status === "Archived") {
      throw new Error("Already archived")
    }
    return { ...todo, status: "Archived" }
  }
}

Проблемы анемичной модели:

  • Бизнес-правила рассеяны по разным сервисам
  • Легко обойти валидацию, изменив поля напрямую
  • Нарушается инкапсуляция — логика отделена от данных
  • Сложно найти все правила для конкретной сущности

Богатая модель (Рекомендуемый подход)

В богатой модели сущность сама содержит свои правила и поведение. В функциональном стиле это выражается через функции, привязанные к типу.

import { Effect, Data, Schema, pipe } from "effect"

// ✅ Богатая модель — поведение рядом с данными
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)),
  status: Schema.Literal("Active", "Completed", "Archived"),
  priority: Schema.Literal("Low", "Medium", "High", "Critical"),
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {
  // Поведение: завершение задачи
  complete(): Effect.Effect<Todo, InvalidStatusTransitionError> {
    return this.status === "Active"
      ? Effect.succeed(
          new Todo({
            ...this,
            status: "Completed",
            completedAt: new Date(),
          })
        )
      : Effect.fail(
          new InvalidStatusTransitionError({
            from: this.status,
            to: "Completed",
          })
        )
  }

  // Поведение: архивирование задачи
  archive(): Effect.Effect<Todo, InvalidStatusTransitionError> {
    return this.status === "Archived"
      ? Effect.fail(
          new InvalidStatusTransitionError({
            from: "Archived",
            to: "Archived",
          })
        )
      : Effect.succeed(
          new Todo({
            ...this,
            status: "Archived",
          })
        )
  }

  // Поведение: изменение заголовка
  changeTitle(
    newTitle: string
  ): Effect.Effect<Todo, EmptyTitleError> {
    return newTitle.trim().length === 0
      ? Effect.fail(new EmptyTitleError())
      : Effect.succeed(
          new Todo({
            ...this,
            title: newTitle.trim(),
          })
        )
  }

  // Запрос: просрочена ли задача
  isOverdue(now: Date, dueDate: Date): boolean {
    return this.status === "Active" && now > dueDate
  }
}

Преимущества богатой модели:

  • Бизнес-правила локализованы — всё в одном месте
  • Невозможно обойти валидацию — каждое изменение проходит через метод
  • Самодокументируемость — тип показывает, что можно делать с сущностью
  • Типовая безопасность — E-канал Effect показывает, какие ошибки возможны

Функциональный подход: модули функций

В строго функциональном стиле вместо методов класса можно использовать модуль функций, привязанный к типу. Это популярный подход в Effect-ts:

// Тип данных — чистая структура
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)),
  status: Schema.Literal("Active", "Completed", "Archived"),
  priority: Schema.Literal("Low", "Medium", "High", "Critical"),
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {}

// Модуль функций — поведение для типа
const TodoOps = {
  complete: (
    self: Todo
  ): Effect.Effect<Todo, InvalidStatusTransitionError> =>
    self.status === "Active"
      ? Effect.succeed(
          new Todo({
            ...self,
            status: "Completed",
            completedAt: new Date(),
          })
        )
      : Effect.fail(
          new InvalidStatusTransitionError({
            from: self.status,
            to: "Completed",
          })
        ),

  archive: (
    self: Todo
  ): Effect.Effect<Todo, InvalidStatusTransitionError> =>
    self.status !== "Archived"
      ? Effect.succeed(new Todo({ ...self, status: "Archived" }))
      : Effect.fail(
          new InvalidStatusTransitionError({
            from: "Archived",
            to: "Archived",
          })
        ),

  isActive: (self: Todo): boolean =>
    self.status === "Active",

  isOverdue: (self: Todo, now: Date, dueDate: Date): boolean =>
    self.status === "Active" && now > dueDate,
} as const

Оба подхода (методы на Schema.Class и отдельный модуль функций) валидны. Выбирайте тот, который лучше подходит вашей команде. В курсе мы будем использовать оба, показывая их взаимозаменяемость.

Границы доменной модели

Определение границ — одна из самых сложных задач в проектировании. Вот набор эвристик, которые помогают:

Эвристика 1: «Тест бумаги и ручки»

Если бизнес-процесс можно описать на бумаге без упоминания компьютера — это домен. Если для описания нужен компьютер — это инфраструктура.

  • «Задача создаётся с заголовком и приоритетом» → Домен ✅
  • «Задача сохраняется в таблицу todos» → Инфраструктура ❌
  • «Нельзя создать задачу с пустым заголовком» → Домен ✅
  • «При создании задачи отправляется HTTP 201» → Инфраструктура ❌

Эвристика 2: «Тест замены технологии»

Если правило останется при замене технологии — это домен. Если нет — инфраструктура.

  • Заменили SQLite на PostgreSQL: «Задача не может быть завершена дважды» — осталось → Домен ✅
  • Заменили REST на GraphQL: «Ответ 200 OK» — исчезло → Инфраструктура ❌

Эвристика 3: «Тест эксперта предметной области»

Если бизнес-эксперт (не программист) понимает правило — это домен. Если нужен программист — скорее всего, инфраструктура.

  • «Задачи с критическим приоритетом отображаются первыми» → Бизнес-эксперт понимает → Домен ✅
  • «Используем индекс B-Tree для ускорения поиска» → Только программист → Инфраструктура ❌

Эвристика 4: «Тест стабильности»

Домен меняется медленно — бизнес-правила стабильны. Инфраструктура меняется часто — фреймворки, базы данных, API обновляются постоянно.

Скорость изменений:

МЕДЛЕННО ◄────────────────────────────────────► БЫСТРО

  Бизнес-правила     Application     Инфраструктура
  "Задача имеет       Use Cases      REST → GraphQL
   приоритет"                        SQLite → Postgres
                                     Express → Hono

Доменная модель и Effect-ts: естественное соответствие

Effect-ts предоставляет идеальный инструментарий для моделирования домена:

Концепция доменаИнструмент Effect
Типизированные данныеSchema.Class, Schema.Struct
ВалидацияSchema.filter, Schema.brand
ИдентичностьSchema.brand("TodoId")
НеизменяемостьSchema.Class создаёт immutable объекты
Доменные ошибкиData.TaggedError
Бизнес-операцииEffect<Success, Error>
Чистые вычисленияEffect.succeed, Effect.fail
Доменные событияSchema.TaggedClass
ПеречисленияSchema.Literal, Schema.Union

Эта таблица — не натяжка и не метафора. Effect-ts спроектирован так, что его конструкции напрямую отображаются на концепции доменного моделирования. В следующих главах мы увидим это на практике.

Резюме

Доменная модель — это ядро вашего приложения, содержащее всю бизнес-логику в чистом, типобезопасном виде без каких-либо внешних зависимостей.

В домене живут:

  • Entities (сущности с идентичностью)
  • Value Objects (неизменяемые значения с валидацией)
  • Aggregates (кластеры с границей согласованности)
  • Domain Events (факты о произошедшем)
  • Domain Services (межагрегатная логика)
  • Domain Errors (бизнес-исключения)
  • Business Rules (ограничения и инварианты)

В домене НЕ живут:

  • SQL-запросы и ORM-маппинг
  • HTTP-обработчики и маршруты
  • Сериализация и десериализация
  • Логирование и мониторинг
  • Конфигурация и переменные окружения
  • Retry, caching, rate limiting
  • Фреймворк-специфичный код

В следующей главе мы подробно разберём чистоту домена — почему нулевые зависимости от инфраструктуры так важны и как Effect-ts помогает гарантировать эту чистоту на уровне типовой системы.