Упражнения: определи границы домена для Todo
Практические упражнения: классификация домен/инфраструктура, определение типов доменных объектов, поиск нарушений чистоты, расширение модели (категории, тэги), рефакторинг с Ubiquitous Language, проектирование домена с нуля (библиотека)
Введение
Эти упражнения закрепляют понимание доменной модели, чистоты домена, типов доменных объектов и Ubiquitous Language. Каждое упражнение проверяет конкретный аспект знаний из Модуля 10.
Упражнение 1: Классификация — домен или инфраструктура?
Определите, к какому слою принадлежит каждый элемент: Domain, Application или Infrastructure.
| # | Элемент | Ваш ответ |
|---|---|---|
| 1 | Правило «Заголовок задачи не может быть пустым» | |
| 2 | SQL-запрос SELECT * FROM todos WHERE status = 'active' | |
| 3 | Отправка email-уведомления при завершении задачи | |
| 4 | Проверка, что пользователь авторизован | |
| 5 | Правило перехода статусов (Active → Completed) | |
| 6 | Сериализация Todo в JSON для HTTP-ответа | |
| 7 | Вычисление статистики: % завершённых задач | |
| 8 | Retry при ошибке подключения к базе данных | |
| 9 | Генерация UUID для нового Todo | |
| 10 | Правило «В списке не более 100 задач» |
Ответы
| # | Элемент | Слой | Пояснение |
|---|---|---|---|
| 1 | Пустой заголовок | Domain | Бизнес-правило, не зависит от технологии |
| 2 | SQL-запрос | Infrastructure | Деталь персистентности |
| 3 | Email-уведомление | Infrastructure | Конкретная технология доставки |
| 4 | Авторизация | Application | Оркестрационная проверка, не бизнес-правило |
| 5 | Переход статусов | Domain | Бизнес-правило жизненного цикла |
| 6 | JSON-сериализация | Infrastructure | Формат передачи данных |
| 7 | Статистика % завершённых | Domain | Бизнес-вычисление над данными |
| 8 | Retry | Infrastructure | Инфраструктурный паттерн |
| 9 | Генерация UUID | Application | Побочный эффект (источник энтропии) |
| 10 | Лимит 100 задач | Domain | Бизнес-правило ограничения |
Упражнение 2: Классификация типов
Определите тип каждого доменного объекта: Entity, Value Object, Aggregate, Domain Event, Domain Service или Domain Error.
| # | Объект | Тип |
|---|---|---|
| 1 | Todo (задача с уникальным ID) | |
| 2 | Priority (Low, Medium, High, Critical) | |
| 3 | TodoList (список задач с лимитом и уникальностью заголовков) | |
| 4 | TodoCompleted (факт завершения задачи) | |
| 5 | EmailAddress (email с валидацией) | |
| 6 | InvalidStatusTransitionError (нельзя завершить архивную задачу) | |
| 7 | checkTitleUniqueness (проверка уникальности среди задач) | |
| 8 | Money (100 USD) | |
| 9 | TodoCreated (факт создания новой задачи) | |
| 10 | calculateStats (вычисление агрегированной статистики) |
Ответы
| # | Объект | Тип |
|---|---|---|
| 1 | Todo | Entity — имеет уникальный ID |
| 2 | Priority | Value Object — равенство по значению, нет ID |
| 3 | TodoList | Aggregate — кластер с границей согласованности |
| 4 | TodoCompleted | Domain Event — факт в прошедшем времени |
| 5 | EmailAddress | Value Object — самовалидируемое значение |
| 6 | InvalidStatusTransitionError | Domain Error — бизнес-нарушение |
| 7 | checkTitleUniqueness | Domain Service — логика между объектами |
| 8 | Money | Value Object — 100 USD = 100 USD, нет ID |
| 9 | TodoCreated | Domain Event — факт создания |
| 10 | calculateStats | Domain 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])
}
}
Ответ
Нарушения:
- Импорт
bun:sqlite— инфраструктурная зависимость - Метод
save()— IO в доменной сущности publicполя безreadonly— мутабельность- Нет валидации 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!" })
})
)
Ответ
Нарушения:
fetch— сетевой вызов в домене- URL API — инфраструктурная деталь
- 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" }))
}
}
Ответ
Нарушения:
console.log— логирование в доменеprocess.env— чтение конфигурации в доменеstatus: "done"— не доменный термин (должно быть"Completed")- Нет типизации статуса через
Schema.Literal - Нет обработки ошибок (что если статус уже
done?)
Исправление: Убрать console.log и process.env. Типизировать статус. Добавить проверку перехода.
Упражнение 4: Расширение доменной модели
Бизнес-эксперт добавляет новые требования. Реализуйте их, сохраняя чистоту домена.
Требование: Категории задач
«Каждая задача может принадлежать одной категории: Work, Personal, Shopping, Health. Категорию можно менять только у активной задачи.»
Задание:
- Создайте Value Object
TodoCategory - Добавьте поле
categoryв EntityTodo - Добавьте метод
changeCategory() - Создайте 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 символов, в нижнем регистре, без пробелов. Дубликаты тэгов не допускаются.»
Задание:
- Создайте Value Object
TodoTag - Создайте Value Object
TodoTags(коллекция с инвариантами) - Определите ошибки:
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 дней назад, считается просроченной.»
Задание:
- Составьте глоссарий (минимум 10 терминов)
- Определите: какие объекты — Entity, какие — Value Object
- Перечислите Domain Events (минимум 3)
- Перечислите Domain Errors (минимум 3)
- Напишите сигнатуры (только типы, без реализации) основных доменных функций
Решение
Глоссарий: 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 (нет зависимостей)
- Именование в коде совпадает с языком бизнес-эксперта
- Ни один доменный файл не импортирует инфраструктуру