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

Упражнения: определи границы домена для Todo

Практические упражнения: классификация домен/инфраструктура, определение типов доменных объектов, поиск нарушений чистоты, расширение модели (категории, тэги), рефакторинг с Ubiquitous Language, проектирование домена с нуля (библиотека)

Введение

Эти упражнения закрепляют понимание доменной модели, чистоты домена, типов доменных объектов и Ubiquitous Language. Каждое упражнение проверяет конкретный аспект знаний из Модуля 10.


Упражнение 1: Классификация — домен или инфраструктура?

Определите, к какому слою принадлежит каждый элемент: Domain, Application или Infrastructure.

#ЭлементВаш ответ
1Правило «Заголовок задачи не может быть пустым»
2SQL-запрос SELECT * FROM todos WHERE status = 'active'
3Отправка email-уведомления при завершении задачи
4Проверка, что пользователь авторизован
5Правило перехода статусов (Active → Completed)
6Сериализация Todo в JSON для HTTP-ответа
7Вычисление статистики: % завершённых задач
8Retry при ошибке подключения к базе данных
9Генерация UUID для нового Todo
10Правило «В списке не более 100 задач»
Ответы
#ЭлементСлойПояснение
1Пустой заголовокDomainБизнес-правило, не зависит от технологии
2SQL-запросInfrastructureДеталь персистентности
3Email-уведомлениеInfrastructureКонкретная технология доставки
4АвторизацияApplicationОркестрационная проверка, не бизнес-правило
5Переход статусовDomainБизнес-правило жизненного цикла
6JSON-сериализацияInfrastructureФормат передачи данных
7Статистика % завершённыхDomainБизнес-вычисление над данными
8RetryInfrastructureИнфраструктурный паттерн
9Генерация UUIDApplicationПобочный эффект (источник энтропии)
10Лимит 100 задачDomainБизнес-правило ограничения

Упражнение 2: Классификация типов

Определите тип каждого доменного объекта: Entity, Value Object, Aggregate, Domain Event, Domain Service или Domain Error.

#ОбъектТип
1Todo (задача с уникальным ID)
2Priority (Low, Medium, High, Critical)
3TodoList (список задач с лимитом и уникальностью заголовков)
4TodoCompleted (факт завершения задачи)
5EmailAddress (email с валидацией)
6InvalidStatusTransitionError (нельзя завершить архивную задачу)
7checkTitleUniqueness (проверка уникальности среди задач)
8Money (100 USD)
9TodoCreated (факт создания новой задачи)
10calculateStats (вычисление агрегированной статистики)
Ответы
#ОбъектТип
1TodoEntity — имеет уникальный ID
2PriorityValue Object — равенство по значению, нет ID
3TodoListAggregate — кластер с границей согласованности
4TodoCompletedDomain Event — факт в прошедшем времени
5EmailAddressValue Object — самовалидируемое значение
6InvalidStatusTransitionErrorDomain Error — бизнес-нарушение
7checkTitleUniquenessDomain Service — логика между объектами
8MoneyValue Object — 100 USD = 100 USD, нет ID
9TodoCreatedDomain Event — факт создания
10calculateStatsDomain Service — агрегация данных

Упражнение 3: Найди нарушение чистоты

В каждом фрагменте кода найдите нарушение чистоты домена и предложите исправление.

Фрагмент A

// domain/entities/todo.ts
import { Effect } from "effect"
import { Database } from "bun:sqlite"

export class Todo {
  constructor(public title: string) {}

  async save(): Promise<void> {
    const db = new Database("app.db")
    db.run("INSERT INTO todos VALUES (?)", [this.title])
  }
}
Ответ

Нарушения:

  1. Импорт bun:sqlite — инфраструктурная зависимость
  2. Метод save() — IO в доменной сущности
  3. public поля без readonly — мутабельность
  4. Нет валидации title

Исправление: Убрать save(), вынести персистентность в адаптер. Использовать Schema.Class с валидацией.

Фрагмент B

// domain/services/notification-service.ts
import { Effect } from "effect"

export const notifyOnComplete = (todoId: string) =>
  Effect.tryPromise(() =>
    fetch("https://api.notifications.com/send", {
      method: "POST",
      body: JSON.stringify({ todoId, message: "Todo completed!" })
    })
  )
Ответ

Нарушения:

  1. fetch — сетевой вызов в домене
  2. URL API — инфраструктурная деталь
  3. JSON.stringify — деталь сериализации

Исправление: Уведомления — ответственность инфраструктуры. Домен генерирует событие TodoCompleted, а адаптер реагирует на него.

Фрагмент C

// domain/entities/todo.ts
import { Schema, Effect } from "effect"

export class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String,
  title: Schema.String,
  status: Schema.String,
}) {
  complete(): Effect.Effect<Todo> {
    console.log(`Completing todo: ${this.id}`)
    const maxRetries = parseInt(process.env.MAX_RETRIES ?? "3")
    return Effect.succeed(new Todo({ ...this, status: "done" }))
  }
}
Ответ

Нарушения:

  1. console.log — логирование в домене
  2. process.env — чтение конфигурации в домене
  3. status: "done" — не доменный термин (должно быть "Completed")
  4. Нет типизации статуса через Schema.Literal
  5. Нет обработки ошибок (что если статус уже done?)

Исправление: Убрать console.log и process.env. Типизировать статус. Добавить проверку перехода.


Упражнение 4: Расширение доменной модели

Бизнес-эксперт добавляет новые требования. Реализуйте их, сохраняя чистоту домена.

Требование: Категории задач

«Каждая задача может принадлежать одной категории: Work, Personal, Shopping, Health. Категорию можно менять только у активной задачи.»

Задание:

  1. Создайте Value Object TodoCategory
  2. Добавьте поле category в Entity Todo
  3. Добавьте метод changeCategory()
  4. Создайте Domain Event TodoCategoryChanged
Решение
// domain/value-objects/todo-category.ts
import { Schema } from "effect"

export const TodoCategory = Schema.Literal(
  "Work",
  "Personal",
  "Shopping",
  "Health"
)
export type TodoCategory = Schema.Schema.Type<typeof TodoCategory>

// Добавить в Todo Entity:
// category: Schema.Literal("Work", "Personal", "Shopping", "Health"),

// Метод:
// changeCategory(
//   newCategory: TodoCategory
// ): Effect.Effect<Todo, InvalidStatusTransitionError> {
//   if (this.status !== "Active") {
//     return Effect.fail(new InvalidStatusTransitionError({
//       from: this.status,
//       to: this.status,
//     }))
//   }
//   return Effect.succeed(new Todo({ ...this, category: newCategory }))
// }

// Событие:
// class TodoCategoryChanged extends Schema.TaggedClass<TodoCategoryChanged>()(
//   "TodoCategoryChanged",
//   {
//     todoId: Schema.String,
//     oldCategory: TodoCategory,
//     newCategory: TodoCategory,
//     occurredAt: Schema.DateFromSelf,
//   }
// ) {}

Требование: Тэги задач

«Задача может иметь от 0 до 5 тэгов. Тэг — строка от 1 до 30 символов, в нижнем регистре, без пробелов. Дубликаты тэгов не допускаются.»

Задание:

  1. Создайте Value Object TodoTag
  2. Создайте Value Object TodoTags (коллекция с инвариантами)
  3. Определите ошибки: TooManyTagsError, DuplicateTagError, InvalidTagError
Решение
// domain/value-objects/todo-tag.ts
import { Schema, Effect, Data } from "effect"

export const TodoTagBrand = Schema.String.pipe(
  Schema.trimmed(),
  Schema.lowercased(),
  Schema.minLength(1),
  Schema.maxLength(30),
  Schema.pattern(/^[a-z0-9-]+$/),
  Schema.brand("TodoTag")
)
export type TodoTag = Schema.Schema.Type<typeof TodoTagBrand>

export class TooManyTagsError extends Data.TaggedError("TooManyTagsError")<{
  readonly maxTags: number
}> {}

export class DuplicateTagError extends Data.TaggedError("DuplicateTagError")<{
  readonly tag: string
}> {}

const MAX_TAGS = 5

export const addTag = (
  tags: ReadonlyArray<TodoTag>,
  newTag: TodoTag
): Effect.Effect<ReadonlyArray<TodoTag>, TooManyTagsError | DuplicateTagError> => {
  if (tags.length >= MAX_TAGS) {
    return Effect.fail(new TooManyTagsError({ maxTags: MAX_TAGS }))
  }
  if (tags.includes(newTag)) {
    return Effect.fail(new DuplicateTagError({ tag: newTag }))
  }
  return Effect.succeed([...tags, newTag])
}

export const removeTag = (
  tags: ReadonlyArray<TodoTag>,
  tag: TodoTag
): ReadonlyArray<TodoTag> =>
  tags.filter((t) => t !== tag)

Упражнение 5: Ubiquitous Language

Переведите технический код в код с Ubiquitous Language.

Фрагмент для рефакторинга:

type Item = {
  uid: string
  txt: string
  s: number      // 0=open, 1=done, 2=deleted
  lvl: string    // "L", "M", "H"
  ts: number     // unix timestamp
  done_ts: number | null
}

const proc = (item: Item, action: number): Item => {
  if (action === 1 && item.s === 0) {
    return { ...item, s: 1, done_ts: Date.now() }
  }
  if (action === 2 && item.s !== 2) {
    return { ...item, s: 2 }
  }
  throw new Error("bad action")
}

const getItems = (items: Item[], filter: number): Item[] =>
  items.filter(i => i.s === filter)

Задание: Перепишите этот код, используя Ubiquitous Language и Effect-ts.

Решение
import { Schema, Effect, Data } from "effect"

// Типы с доменными именами
type TodoStatus = "Active" | "Completed" | "Archived"
type Priority = "Low" | "Medium" | "High"

class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String.pipe(Schema.brand("TodoId")),
  title: Schema.String.pipe(Schema.minLength(1)),
  status: Schema.Literal("Active", "Completed", "Archived"),
  priority: Schema.Literal("Low", "Medium", "High"),
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {
  complete(now: Date): Effect.Effect<Todo, InvalidStatusTransitionError> {
    return this.status === "Active"
      ? Effect.succeed(new Todo({
          ...this,
          status: "Completed",
          completedAt: now,
        }))
      : Effect.fail(new InvalidStatusTransitionError({
          from: this.status,
          to: "Completed",
        }))
  }

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

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

const filterByStatus = (
  todos: ReadonlyArray<Todo>,
  status: TodoStatus
): ReadonlyArray<Todo> =>
  todos.filter((todo) => todo.status === status)

Упражнение 6: Проектирование с нуля

Представьте, что вы моделируете домен библиотеки (Library). Бизнес-эксперт описывает:

«В библиотеке есть книги. Каждая книга имеет название, автора и ISBN. Книга может быть доступна или выдана. Читатель может взять книгу (если она доступна) и вернуть книгу. Нельзя взять книгу, которая уже выдана. Книга, выданная более 14 дней назад, считается просроченной

Задание:

  1. Составьте глоссарий (минимум 10 терминов)
  2. Определите: какие объекты — Entity, какие — Value Object
  3. Перечислите Domain Events (минимум 3)
  4. Перечислите Domain Errors (минимум 3)
  5. Напишите сигнатуры (только типы, без реализации) основных доменных функций
Решение

Глоссарий: Book, Title, Author, ISBN, BookStatus (Available/Borrowed), Reader, BorrowDate, ReturnDate, Overdue, Borrow, Return

Типы:

  • Entity: Book (имеет ISBN как ID), Reader (имеет ID)
  • Value Object: Title, Author, ISBN, BookStatus, BorrowDate

Events:

  • BookBorrowed (книга выдана)
  • BookReturned (книга возвращена)
  • BookBecameOverdue (книга стала просроченной)

Errors:

  • BookNotAvailableError (книга уже выдана)
  • BookNotBorrowedError (попытка вернуть не выданную книгу)
  • BookNotFoundError (книга не найдена)

Сигнатуры:

const borrowBook: (
  book: Book,
  readerId: ReaderId,
  borrowDate: Date
) => Effect.Effect<Book, BookNotAvailableError>

const returnBook: (
  book: Book,
  returnDate: Date
) => Effect.Effect<Book, BookNotBorrowedError>

const isOverdue: (
  book: Book,
  now: Date
) => boolean

Критерии самооценки

После выполнения упражнений проверьте себя:

  • Могу уверенно отличить доменный код от инфраструктурного
  • Понимаю разницу между Entity и Value Object
  • Могу спроектировать границы агрегата
  • Умею формулировать доменные события в прошедшем времени
  • Доменные ошибки выражают бизнес-нарушения, не технические проблемы
  • Весь мой доменный код имеет R = never (нет зависимостей)
  • Именование в коде совпадает с языком бизнес-эксперта
  • Ни один доменный файл не импортирует инфраструктуру