Упражнения: расширь 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 задач
Задание:
- Создай Value Object
TodoTagс валидацией - Добавь поле
tags: ReadonlyArray<TodoTag>в Entity - Реализуй методы
addTag(tag: TodoTag)иremoveTag(tag: TodoTag) - Добавь инвариант: количество тегов ≤ 10
- Определи доменные ошибки
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обновляется
Задание:
- Добавь метод
reopen()в Todo Entity - Определи ошибку
CannotReopenArchived - Обнови диаграмму состояний (State Machine) с учётом нового перехода
- Напиши минимум 3 теста:
- Успешное переоткрытие
- Отклонение для pending задачи
- Отклонение для архивированной задачи
Упражнение 3: Приоритетные правила (★★☆)
Добавь бизнес-правила, связанные с приоритетом.
Требования:
- Задача с приоритетом
highобязана иметь описание - Задача с приоритетом
highобязана иметьdueDate - При попытке установить
highбез описания или dueDate — доменная ошибка
Задание:
- Модифицируй метод
updatePriorityдля проверки этих правил - Модифицируй метод
create— если переданhigh, проверь наличие описания - Добавь Schema.filter инвариант для внешних данных
- Определи ошибку
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 typeUsername— Value Object (3-50 символов, alphanumeric + underscore)Email— Value Object (валидный email)- Статусы:
Active,Suspended,Deleted - Suspended пользователь не может создавать задачи
- Deleted — терминальное состояние
Задание:
- Создай Value Objects:
UserId,Username,UserEmail - Создай Entity
Userчерез Schema.Class - Реализуй
EqualпоUserId - Реализуй поведение:
create,suspend,activate,softDelete - Определи ошибки:
UserSuspended,UserDeleted,InvalidEmailFormat
Упражнение 5: Связь между Entity (★★★)
Свяжи Todo и User через assigneeId.
Требования:
- У задачи есть необязательный
assigneeId: Option<UserId> - Задача может быть назначена только
Activeпользователю - При удалении пользователя его задачи должны быть переназначены
Задание:
- Добавь поле
assigneeIdв Todo Entity - Реализуй метод
assign(userId: UserId)— устанавливает assigneeId - Реализуй метод
unassign()— сбрасывает assigneeId - Подумай: где проверяется, что пользователь 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 (три отдельных класса).
Задание:
- Создай
PendingTodo,CompletedTodo,ArchivedTodoчерезSchema.TaggedClass - Определи
type Todo = PendingTodo | CompletedTodo | ArchivedTodo - Реализуй переходы как функции с типами:
complete: (todo: PendingTodo) => Effect<CompletedTodo>archive: (todo: PendingTodo | CompletedTodo) => Effect<ArchivedTodo>
- Реализуй
updateTitleтак, чтобы он принимал толькоPendingTodo - Попробуй вызвать
complete(archivedTodo)— убедись, что компилятор отклоняет - Реализуй pattern matching через
Match.type<Todo>()
Вопрос для размышления:
Как организовать decode из БД, если в таблице todos нет поля _tag? Как определить, какой конкретный тип создать?
Упражнение 7: Property-Based Testing (★★★)
Напиши property-based тесты для Todo Entity.
Задание:
Используя Schema.Arbitrary и fast-check, проверь следующие свойства:
- Идемпотентность create: создание задачи всегда возвращает Pending
- Сохранение id: любая операция сохраняет id неизменным
- Монотонность времени: updatedAt всегда >= createdAt после любой операции
- Терминальность archived: после archive никакая операция не успешна
- 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 не зависит от инфраструктуры |
| Тестируемость | Каждый метод легко протестировать |