Лабораторная работа 2. Доменная модель¶
Об этом материале
Меняется один проект: Hr.Domain. Ни хранения, ни портов, ни HTTP. Покрываются правила: BR-01 … BR-14 и BR-20 … BR-24, имя теста содержит номер. Оценка: 47 баллов. Ветка lab-02, отдельный pull request.
Понятия, которые вы выделили в первой работе, становятся типами. Всё, что вы напишете здесь, компилируется и покрыто тестами, но не умеет ни сохраняться, ни принимать запросы:
Hr.Domain— единственный проект, который в этой работе меняется. Это самая тяжёлая лабораторная курса, тяжелее инфраструктурной, потому что на её результате стоит всё остальное. Три пары аудиторных плюс домашняя часть.
Цель¶
Выразить правила предметной области кодом так, чтобы объект нельзя было привести в недопустимое состояние — ни из сценария, ни из теста, ни по невнимательности.
О примерах и стеке¶
Код в этой методичке на C#, потому что примеры нужно на чём-то писать. Требования предъявляются к смыслу, а не к синтаксису: неизменяемый объект-значение с проверкой при создании выражается и записью в C#, и структурой с приватными полями в Go, и замороженным датаклассом в Python. Если ваш стек не поддерживает какую-то конструкцию буквально — добейтесь того же свойства другими средствами и объясните как в ADR.
Единственное, что не обсуждается: недопустимое состояние объекта должно быть недостижимо. Каким приёмом языка это сделано — ваше дело.
Что нужно иметь под рукой¶
Свои файлы из первой лабораторной — глоссарий, события, границы — и ТЗ, разделы 6 и 7. Замечания, полученные в ревью первой работы, должны быть учтены до начала этой: модель, построенная на неверных границах, переписывается целиком.
Ход аудиторной части (три пары)¶
Первая пара — из чего состоят понятия.
- Разбор замечаний по первой работе, уточнение границ
- Что стало объектом-значением, а что сущностью
- Границы агрегатов: вакансия и кандидатура
- Типизированные идентификаторы и ссылки между агрегатами
Вторая пара — что модель защищает.
- Инварианты: где именно они проверяются
- Снимок пайплайна:
BR-07иBR-23 - Правило решения
Все: где живёт состояние - Сигнатура «кто принимает решение»
Третья пара — события и проверяемость.
- Доменные события: создаём и накапливаем, не публикуем
- Тесты: билдеры, формулировки, по два теста на правило
- Диаграмма классов и ADR о границах агрегатов
- Взаимное ревью моделей: смотрим границы соседа
Здесь ноутбук уже открыт: работа идёт в коде.
Последние пятнадцать минут — не формальность. Вы меняетесь с соседом ссылками на ветки и отвечаете друг другу на один вопрос: почему граница проведена именно так. Чужая модель той же предметной области — самый быстрый способ увидеть, что ваше решение было не единственно возможным. Заодно это репетиция ревью, через которое пройдёт ваш pull request.
Объекты-значения и сущности¶
Различие одно, и оно не про размер. Сущность имеет идентичность: два кандидата с одинаковыми именем и почтой — разные кандидаты, если это разные люди. Объект-значение идентичности не имеет и равен другому такому же по составу: два адреса электронной почты с одинаковым текстом — один и тот же адрес.
Практическое следствие: объект-значение неизменяем, его не «меняют», а заменяют целиком.
Он же — естественное место для проверки формата: если EmailAddress нельзя создать
из строки без собачки, то в системе не существует кандидата с битой почтой, и проверять
это больше нигде не нужно.
public sealed record EmailAddress
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static EmailAddress Create(string raw)
{
var normalized = raw?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(normalized) || !normalized.Contains('@'))
throw new DomainException("email address is malformed");
return new EmailAddress(normalized);
}
}
type EmailAddress struct{ value string }
func NewEmailAddress(raw string) (EmailAddress, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" || !strings.Contains(normalized, "@") {
return EmailAddress{}, ErrMalformedEmail
}
return EmailAddress{value: normalized}, nil
}
func (e EmailAddress) String() string { return e.value }
У каждого варианта своя дыра, и её полезно назвать вслух. В Go нельзя запретить
нулевое значение: var e EmailAddress создаст пустой адрес мимо всякой проверки,
поэтому проверять придётся ещё и на входе в агрегат. В Python замороженный датакласс
защищает от изменения, но не мешает вызвать конструктор напрямую в обход create.
В C# приватный конструктор закрывает и то и другое — но и там остаётся сериализация,
которая умеет создавать объекты, не спрашивая вас.
Абсолютной защиты не даёт ни один язык. Задача — сделать неправильный путь заметно менее удобным, чем правильный, и назвать оставшуюся щель в ADR, а не делать вид, что её нет.
Кандидаты в объекты-значения в нашей области: адрес почты, телефон, вилка зарплаты, срок действия оффера, причина отклонения, название этапа. Кандидаты в сущности: вакансия, кандидатура, кандидат, сотрудник, профессиональная роль.
Обратите внимание на причину отклонения. Соблазн сделать её строкой велик, но BR-09
требует не короче десяти символов — а значит, у неё есть правило, и правилу нужен дом.
Типизированные идентификаторы¶
Голый идентификатор общего типа в сигнатуре метода — приглашение перепутать аргументы. Компилятор не заметит, что вместо идентификатора вакансии передали идентификатор кандидатуры, а тест поймает это далеко не всегда.
public readonly record struct ApplicationId(Guid Value)
{
public static ApplicationId New() => new(Guid.CreateVersion7());
}
NFR-10 требует монотонно возрастающие идентификаторы — это UUID версии 7,
и генератор для него есть в любой экосистеме. Тот случай, когда техническое
требование удобно закрыть один раз в домене и больше к нему не возвращаться.
Границы агрегата¶
Агрегат — группа объектов, которая изменяется целиком и целиком же отвечает за свои правила. У агрегата есть корень: единственный объект, через который к нему обращаются снаружи.
Правило, по которому проводится граница, одно: внутрь попадает то, что обязано меняться одной неделимой операцией, чтобы правило не нарушилось. Всё остальное — снаружи, и на него ссылаются по идентификатору.
Отсюда главный вопрос этой работы. BR-03 требует, чтобы кандидатура двигалась строго
по этапам своего пайплайна, а пайплайн задан вакансией. Значит ли это, что вакансия
и кандидатура — один агрегат?
Если да, то каждое решение по любому кандидату блокирует всю вакансию целиком,
а у популярной вакансии кандидатур сотни. Если нет — откуда кандидатура берёт список
этапов в момент проверки? Ответ уже написан в задании: перечитайте BR-07 и BR-23.
Ссылки между агрегатами — только по идентификатору. JobApplication не хранит
Vacancy объектом; она хранит VacancyId. Это не оптимизация, а способ сделать
границу видимой: если из одного агрегата можно пройти в другой по ссылке, граница
существует только на словах.
Снимок пайплайна¶
BR-07 говорит: пайплайн фиксируется в момент отклика, и редактирование вакансии
не влияет на идущие кандидатуры. BR-23 уточняет, что именно фиксируется: поимённые
назначения — по идентификатору сотрудника, ролевые — по идентификатору роли, а состав
обладателей роли не фиксируется вовсе.
Это значит, что внутри кандидатуры живёт собственная копия последовательности этапов с назначениями — не ссылка на вакансию, а именно копия, снятая один раз и дальше неизменная. Тест, который вы обязаны написать: изменение пайплайна вакансии после отклика не меняет ход существующей кандидатуры.
Инварианты и место их проверки¶
Инвариант — утверждение, истинное всё время жизни объекта. Отсюда два следствия, которые студенты обычно недооценивают.
Первое: объект не должен существовать в недопустимом состоянии даже мгновение.
Значит, проверка идёт в конструкторе или фабричном методе, а не в отдельном
Validate(), который кто-то должен не забыть вызвать.
Второе: правило проверяется там, где есть все нужные данные. Если для проверки не хватает данных внутри агрегата, это сигнал: либо граница проведена неверно, либо данные надо передать аргументом.
public sealed class JobApplication
{
private readonly List<StageDecision> _decisions = [];
private readonly PipelineSnapshot _pipeline;
public ApplicationId Id { get; }
public VacancyId VacancyId { get; }
public ApplicationStatus Status { get; private set; }
public int CurrentStageIndex { get; private set; }
public IReadOnlyList<StageDecision> Decisions => _decisions;
public void Approve(Decider decider, IClock clock)
{
if (Status is not ApplicationStatus.InProgress)
throw new DomainException("BR-04/BR-05: candidacy is in a terminal state");
var stage = _pipeline.StageAt(CurrentStageIndex);
if (_decisions.Any(d => d.StageIndex == CurrentStageIndex
&& d.DeciderId == decider.Id))
throw new DomainException("BR-22: decision already made by this employee");
_decisions.Add(StageDecision.Approve(CurrentStageIndex, decider, clock.UtcNow));
if (!stage.IsSatisfiedBy(_decisions)) // BR-21: правило решения этапа
return; // ждём остальных
if (CurrentStageIndex == _pipeline.LastIndex)
Status = ApplicationStatus.OfferIssued;
else
CurrentStageIndex++; // BR-03: строго на один вперёд
}
}
Разберите этот пример на занятии: он намеренно неполон. В нём не проверено BR-17 —
что решающий вообще назначен на текущий этап, — и не создано ни одного доменного
события. И то и другое вам предстоит дописать.
Кто принимает решение¶
BR-17 требует, чтобы решение принимал только исполнитель текущего этапа, а BR-18 —
чтобы обладание ролью определялось на момент решения. Полностью эта проверка появится
в шестой лабораторной, но подготовиться к ней нужно сейчас.
Приём простой: метод получает не идентификатор сотрудника, а объект «тот, кто решает» — его идентификатор вместе с набором ролей, которыми он обладает сейчас. Откуда взялся этот набор, домен не спрашивает: это не его дело. Зато проверить, годится ли такой решающий для текущего этапа, домен может сам, и правило остаётся в домене.
Продумайте сигнатуру уже в этой работе, даже если тело проверки пока пустое. Менять её потом дороже, чем спроектировать сразу.
Доменные события¶
Каждое решение порождает факт: ApplicationSubmitted, ApplicationAdvanced,
ApplicationRejected, OfferIssued. В этой работе события создаются и накапливаются
внутри агрегата, но никуда не отправляются: публикация появится в седьмой
лабораторной, и это принципиально разные вещи.
Событие неизменяемо, содержит момент времени и идентификаторы участников, но не ссылки на объекты. Хорошая проверка: событие должно оставаться осмысленным через год, когда все упомянутые в нём объекты уже изменились.
Требования к коду¶
- Ни одного публичного сеттера. Состояние меняется только методами с осмысленными именами предметной области.
- Ни одной изменяемой коллекции наружу: внешний код получает копию или неизменяемое представление.
- Никаких строк там, где есть перечислимое множество значений.
Status == "Rejected"— ошибка, а не стиль. - Никакого обращения к системным часам и генератору идентификаторов внутри методов: время и новые идентификаторы приходят аргументами. Иначе
BR-11иFR-19непроверяемы. - Никаких ссылок на другие агрегаты объектами — только типизированные идентификаторы.
- Нарушение бизнес-правила — доменное исключение или доменный результат, но не
ArgumentExceptionи неInvalidOperationException. - В
Hr.Domainне появляется ни одного пакета, знающего про хранение, сеть или формат передачи. - Всё, что видно снаружи модуля, — по-английски. По-русски пишутся комментарии.
Тесты¶
Покрываются правила BR-01 … BR-14 и BR-20 … BR-24. Правила про доступ
(BR-16 … BR-19) — шестая работа.
- Имя теста содержит номер правила, в принятом для языка стиле:
BR_03_stage_cannot_be_skipped,TestBR03_StageCannotBeSkipped,test_br_03_stage_cannot_be_skipped. - Ни одного мока. Если понадобился — граница проведена неверно.
- Тестовые данные собираются билдерами, а не десятью строками подготовки в каждом тесте.
- На каждое правило минимум два теста: нарушение отвергнуто и допустимый случай разрешён. Тест, проверяющий только падение, оставляет возможность запретить вообще всё.
public static class ApplicationBuilder
{
public static JobApplication AtLastStage() => /* ... */;
public static JobApplication Rejected() => /* ... */;
}
[Fact]
public void BR_07_editing_vacancy_pipeline_does_not_affect_existing_candidacy()
{
var vacancy = VacancyBuilder.Published(stages: 3);
var application = vacancy.Apply(CandidateBuilder.Any(), FixedClock.Default);
vacancy.ReplacePipeline(PipelineBuilder.With(stages: 5));
application.PipelineLength.Should().Be(3);
}
Что сдаётся¶
Ветка lab-02, отдельный pull request.
src/<домен>/ домен целиком
tests/<домен>/ тесты правил
docs/
├── glossary.md обновлён по итогам работы
├── boundaries.md обновлён: границы уточнены
├── adr/0002-aggregates.md почему границы агрегатов проведены так
└── diagrams/domain.mmd диаграмма классов домена на Mermaid
ADR по границам агрегатов — центральный документ работы. В разделе «Альтернативы» должен быть разобран вариант, который вы отвергли: если вы развели вакансию и кандидатуру, покажите, чем плох общий агрегат, и наоборот.
Диаграмма классов рисуется через classDiagram, показывает корни агрегатов, границы
между ними и ссылки по идентификаторам.
Критерии оценки¶
| Что оценивается | Баллов |
|---|---|
| Границы агрегатов и их обоснование в ADR | 8 |
Снимок пайплайна: BR-07, BR-23 |
5 |
| Объекты-значения: состав, неизменяемость, проверки при создании | 5 |
| Инварианты проверяются при создании и в методах, а не снаружи | 5 |
Правило решения Все: состояние и BR-21 |
4 |
| Доменные события созданы и накапливаются | 3 |
| Тесты: покрытие правил, имена, отсутствие моков | 6 |
| Чистота домена: пакеты, сеттеры, коллекции, время | 4 |
| Диаграмма классов и обновлённые документы | 3 |
| Продуманная сигнатура «кто принимает решение» | до 2 |
| Разбор чужой модели: содержательные замечания соседу | до 2 |
| Итого | 47 |
Работа не принимается при наличии открытых на запись свойств состояния, строковых статусов, обращения к системным часам внутри домена или моков в доменных тестах.
Типичные ошибки¶
Анемичная модель. Класс с двадцатью свойствами и без единого метода, а вся логика — в тестах и будущих сценариях. Проверка: если ваш агрегат можно заменить простой структурой данных без потери смысла, поведения в нём нет.
Сеттеры «только для тестов». Как только они появились, инвариант перестал быть инвариантом: любой код в системе может обойти правило, и компилятор не возразит. Тестовые данные собираются через фабрики и билдеры.
Публичная изменяемая коллекция. Если наружу отдаётся сам список решений,
BR-10 — история только дополняется — нарушается его очисткой из любой точки
программы.
Один гигантский агрегат. Вакансия со списком всех кандидатур внутри выглядит естественно и убивает систему на первой же популярной позиции.
Ссылка на другой агрегат объектом. application.Vacancy.Close() — граница
нарисована, но не существует.
Время внутри домена. Обращение к системным часам прямо в методе делает BR-11
и FR-19 непроверяемыми: тест на истечение срока оффера придётся ждать неделю.
Правило проверено в сценарии, а не в домене. Признак — метод агрегата, который
всё принимает и ничего не отвергает, и if в будущем хендлере. Это возврат
к транзакционному скрипту из первой лекции.
`TODO: добавить проверку позже». Правило, оставленное на потом, к защите превращается в правило, которого нет. Если проверку сейчас сделать нечем — это материал для ADR, а не для комментария в коде.
Вопросы на защите¶
- Покажите объект-значение. Что произойдёт, если сделать его изменяемым?
- Почему кандидатура ссылается на вакансию по идентификатору, а не объектом?
- Где именно проверяется
BR-03и какие данные для этого нужны? - Как ваша модель ведёт себя, если пайплайн вакансии отредактировали после отклика? Покажите тест.
- При правиле
Всегде хранится информация о том, кто уже высказался? Почему там, а не в другом месте? - Ваш агрегат создаёт события. Что произойдёт, если тот же метод вызвать дважды?
- Почему в доменных тестах нет ни одного мока?
- Назовите правило, которое вы не смогли выразить в домене. Чего не хватило?
Последний вопрос — не ловушка. Такие правила есть, и умение их назвать ценнее, чем попытка сделать вид, что всё уместилось.
Что дальше¶
В третьей работе появится слой сценариев: команды, порты, транзакционные границы. Домен при этом меняться почти не должен — а если начнёт, это ценный сигнал. Каждое такое изменение фиксируйте отдельным ADR: именно в этих местах и обнаруживается, что модель была построена на неверном допущении.