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

Упражнения: расширь Todo Entity новыми правилами

Семь практических заданий нарастающей сложности: добавление тегов, reopen-переход, приоритетные правила, Entity User, связь между Entity, Tagged Union подход, Property-Based Testing.

Упражнение 1: Добавь поле tags (★☆☆)

Расширь Todo Entity, добавив поддержку тегов.

Требования:

  • У задачи может быть от 0 до 10 тегов
  • Каждый тег — Value Object TodoTag (строка от 1 до 30 символов, lowercase, без пробелов)
  • Теги уникальны в рамках одной задачи (нет дубликатов)
  • Добавление тегов доступно только для Pending задач
  • Удаление тегов доступно для Pending и Completed задач

Задание:

  1. Создай Value Object TodoTag с валидацией
  2. Добавь поле tags: ReadonlyArray<TodoTag> в Entity
  3. Реализуй методы addTag(tag: TodoTag) и removeTag(tag: TodoTag)
  4. Добавь инвариант: количество тегов ≤ 10
  5. Определи доменные ошибки DuplicateTag, TagNotFound, TooManyTags

Подсказка:

const TodoTag = Schema.String.pipe(
  Schema.trimmed(),
  Schema.lowercased(),
  Schema.pattern(/^\S+$/, { message: () => "Тег не может содержать пробелы" }),
  Schema.minLength(1),
  Schema.maxLength(30),
  Schema.brand("TodoTag"),
)

Упражнение 2: Реализуй reopen — возврат из Completed в Pending (★★☆)

Иногда задачу нужно «переоткрыть» после завершения.

Требования:

  • Переход Completed → Pending допустим
  • При переоткрытии completedAt сбрасывается в Option.none()
  • Переоткрытие невозможно для архивированных задач
  • updatedAt обновляется

Задание:

  1. Добавь метод reopen() в Todo Entity
  2. Определи ошибку CannotReopenArchived
  3. Обнови диаграмму состояний (State Machine) с учётом нового перехода
  4. Напиши минимум 3 теста:
    • Успешное переоткрытие
    • Отклонение для pending задачи
    • Отклонение для архивированной задачи

Упражнение 3: Приоритетные правила (★★☆)

Добавь бизнес-правила, связанные с приоритетом.

Требования:

  • Задача с приоритетом high обязана иметь описание
  • Задача с приоритетом high обязана иметь dueDate
  • При попытке установить high без описания или dueDate — доменная ошибка

Задание:

  1. Модифицируй метод updatePriority для проверки этих правил
  2. Модифицируй метод create — если передан high, проверь наличие описания
  3. Добавь Schema.filter инвариант для внешних данных
  4. Определи ошибку HighPriorityRequiresDetails

Подсказка:

Вместо проверки в updatePriority, можно реализовать promote() — специальный метод для повышения приоритета до high, который требует описание и dueDate как параметры:

readonly promote = (params: {
  readonly description: string
  readonly dueDate: DueDate
}): Effect.Effect<Todo, TodoModificationForbidden>

Упражнение 4: Entity User (★★☆)

Создай вторую Entity в домене — User, владелец задач.

Требования:

  • UserId — branded type
  • Username — Value Object (3-50 символов, alphanumeric + underscore)
  • Email — Value Object (валидный email)
  • Статусы: Active, Suspended, Deleted
  • Suspended пользователь не может создавать задачи
  • Deleted — терминальное состояние

Задание:

  1. Создай Value Objects: UserId, Username, UserEmail
  2. Создай Entity User через Schema.Class
  3. Реализуй Equal по UserId
  4. Реализуй поведение: create, suspend, activate, softDelete
  5. Определи ошибки: UserSuspended, UserDeleted, InvalidEmailFormat

Упражнение 5: Связь между Entity (★★★)

Свяжи Todo и User через assigneeId.

Требования:

  • У задачи есть необязательный assigneeId: Option<UserId>
  • Задача может быть назначена только Active пользователю
  • При удалении пользователя его задачи должны быть переназначены

Задание:

  1. Добавь поле assigneeId в Todo Entity
  2. Реализуй метод assign(userId: UserId) — устанавливает assigneeId
  3. Реализуй метод unassign() — сбрасывает assigneeId
  4. Подумай: где проверяется, что пользователь Active? В Entity или в Application Service?

Подсказка:

Entity не знает о других Entity (не имеет доступа к UserRepository). Поэтому проверка «пользователь активен» — это ответственность Application Service, а не Entity. Entity лишь хранит UserId.

// В Application Service:
const assignTodo = (todoId: TodoId, userId: UserId) =>
  Effect.gen(function* () {
    const user = yield* UserRepository.findById(userId)
    if (user.status !== "active") {
      return yield* Effect.fail(new UserSuspended({ userId }))
    }
    const todo = yield* TodoRepository.findById(todoId)
    const assigned = yield* todo.assign(userId)
    yield* TodoRepository.save(assigned)
  })

Упражнение 6: Tagged Union подход (★★★)

Перереализуй Todo Entity через Tagged Union (три отдельных класса).

Задание:

  1. Создай PendingTodo, CompletedTodo, ArchivedTodo через Schema.TaggedClass
  2. Определи type Todo = PendingTodo | CompletedTodo | ArchivedTodo
  3. Реализуй переходы как функции с типами:
    • complete: (todo: PendingTodo) => Effect<CompletedTodo>
    • archive: (todo: PendingTodo | CompletedTodo) => Effect<ArchivedTodo>
  4. Реализуй updateTitle так, чтобы он принимал только PendingTodo
  5. Попробуй вызвать complete(archivedTodo) — убедись, что компилятор отклоняет
  6. Реализуй pattern matching через Match.type<Todo>()

Вопрос для размышления:

Как организовать decode из БД, если в таблице todos нет поля _tag? Как определить, какой конкретный тип создать?


Упражнение 7: Property-Based Testing (★★★)

Напиши property-based тесты для Todo Entity.

Задание:

Используя Schema.Arbitrary и fast-check, проверь следующие свойства:

  1. Идемпотентность create: создание задачи всегда возвращает Pending
  2. Сохранение id: любая операция сохраняет id неизменным
  3. Монотонность времени: updatedAt всегда >= createdAt после любой операции
  4. Терминальность archived: после archive никакая операция не успешна
  5. Round-trip: encode(decode(x)) === x для любого валидного Todo

Подсказка:

import { Arbitrary } from "effect"
import * as fc from "fast-check"

const todoArb = Arbitrary.make(Todo)

fc.assert(
  fc.property(todoArb, (todo) => {
    // Свойство: id никогда не пустой
    return todo.id.length > 0
  })
)

Критерии оценки

КритерийОписание
ТипобезопасностьBranded types, правильные типы ошибок
ИммутабельностьНикакой мутации, только новые экземпляры
ИнвариантыБизнес-правила проверяются и гарантируются
ЧистотаПобочные эффекты только через Effect
ОшибкиВсе ошибки типизированы, в E-канале
ИзоляцияEntity не зависит от инфраструктуры
ТестируемостьКаждый метод легко протестировать