Перейти к содержимому
Программа DDDDDD.4 DDD на практике

DDD в существующей системе

Как внедрять DDD не на чистом листе, а в живую систему: пузырь нового контекста, предохранительный слой вокруг унаследованного кода, шаблон «душитель» и поэтапное выделение модулей; с чего начать завтра, как рефакторить функцию-монолит к модели под защитой характеризационных тестов и какие провалы внедрения встречаются чаще всего.

УРОКddd/ddd-4-practice/03-legacy.mdx2 чУрок 3 из 4
После урока вы сможете
  • Выбрать стратегию внедрения DDD в существующую систему — пузырь, автономный пузырь, открытие старых возможностей как сервисов, «душитель» — и назвать её цену
  • Отгородить новую модель от унаследованных данных предохранительным слоем так, чтобы старые поля и коды не просочились в модель
  • Рефакторить функцию-монолит к модели маленькими шагами под защитой характеризационных тестов и отделять рефакторинг от изменения поведения
  • Составить план «DDD с завтрашнего дня» — глоссарий и карта контекстов как есть, одна болевая точка — и узнавать типичные провалы внедрения

Онлайн-школе три года. Команда «Набор и деньги» прошла программу DDD, договорилась о едином языке, нарисовала контексты и знает, как выглядит агрегат «Поток». Потом открывает репозиторий и видит то, что видела в начале программы: таблицу orders, поле st со значениями 0, 1 и 2 и функцию enroll_and_pay с комментарием «НЕ ТРОГАТЬ!!!». От этой функции зависят веб-форма, админка, ночные отчёты и выгрузка для бухгалтерии. Бизнес тем временем просит лист ожидания с предложением места на 24 часа — к следующему набору.

Первое желание — «переписать всё правильно». Почти всегда это ошибка: живую систему, которая приносит деньги, нельзя остановить на полгода. Вопрос не «как спроектировать идеально», а «как ввести новую модель рядом со старой и постепенно переносить в неё самое ценное».

В практике урока вы выделите модель Набора из функции-монолита под защитой характеризационных тестов — тестов-фиксаторов из урока 11.1.2 «Чужой код».

В унаследованном кодеунаследованный код (legacy)EN legacy codeКод, который достался от других людей или от прошлых версий проекта; авторов часто нет рядом, тестов мало. В китайском сленге — «цзучуань дайма» («код от предков»). есть то, чего нет ни в одном документе: правила, которые бизнес когда-то попросил и забыл, — они живут только в условиях if. Эванс в статье «Getting Started with DDD When Surrounded by Legacy Systems» (2013) говорит так: у старой системы уже есть своя модель, пусть и неявная, и изменить её разумными усилиями почти никогда нельзя.

Переписывание с нуляпереписывание с нуля («большой взрыв»)EN big bang rewriteЗамена работающей системы новой, написанной целиком заново и включаемой одним переключением. Опасно: неявные правила старого кода теряются, цель движется, пользы нет до самого конца. Альтернатива — пузырь контекста и шаблон «душитель». («большой взрыв») проваливается по нескольким причинам:

  • Неявные правила теряются: новую систему пишут по документам, а половина правил живёт только в коде.
  • Цель движется: изменения, которые бизнес просит по ходу, приходится делать дважды — в старой и в новой системе.
  • Пользы нет до самого конца, а день переключения — один большой риск.

Авторы «Cosmic Python» советуют начинать с вопроса «какую проблему мы решаем?» и связывать улучшения с новыми возможностями: под полугодовой проект три недели на расчистку фундамента согласовать проще. Боб Грегори называет это «налогом на архитектуру». Лист ожидания — как раз такой повод.

А Майкл Физерс в книге «Working Effectively with Legacy Code» (2004) даёт определение, которое задаёт порядок работы: унаследованный код — это просто код без тестов. Значит, первый шаг любой стратегии — тесты.

В статье 2013 года Эванс предлагает четыре стратегии. Объединяет их одна мысль: нельзя просто добавить новые классы с другой моделью рядом со старыми — получатся два набора противоречащих друг другу правил и понятий. Новой модели нужна граница.

Пузырь контекстапузырь контекстаEN bubble contextСтратегия Эрика Эванса (2013): небольшой ограниченный контекст с новой чистой моделью, созданный под конкретную задачу и отгороженный от унаследованной системы предохранительным слоем. Своей базы обычно нет — данные берутся из старой через слой. (bubble context) — маленький ограниченный контекст с новой чистой моделью, созданный под конкретную задачу и отгороженный от старой системы предохранительным слоемпредохранительный слойEN Anticorruption Layer (ACL)Шаблон карты контекстов: нижестоящий контекст ставит изолирующий слой, который переводит модель чужой системы в термины собственной модели (и обратно). Чужие поля, статусы и слова не проходят дальше слоя. Встречаются переводы «предохранительный уровень» и «антикоррупционный слой».. Эванс советует взять скромную, но ценную цель со сложной логикой и небольшую дисциплинированную команду, которая сама владеет своим кодом. Это пилот: команда набирается опыта, компания видит, работает ли подход.

Своей базы у пузыря обычно нет: его репозиторий читает и пишет старые таблицы через предохранительный слой. Плюс — нет синхронизации двух баз. Минус — каждое новое понятие приходится заводить и в старой базе и описывать его перевод, поэтому в пузырь берут только то, что оправдывает эту цену. В школе естественный кандидат — Набор с листом ожидания: правила сложные, это ядро, а в старом коде листа ожидания нет. Таблицу waitlist заведут в старой базе, но пользоваться ею будет только пузырь.

flowchart LR
UI["Новая страница<br/>«Лист ожидания»"] --> M
subgraph B["Пузырь: Набор"]
  M["Модель<br/>Cohort, Enrollment, Waitlist"] --> R["Репозиторий"]
end
R --> ACL["Предохранительный слой<br/>st ↔ статус, max ↔ capacity"]
ACL --> DB[("Старая база<br/>groups, orders, waitlist")]
OLD["Старый код<br/>enroll_and_pay, админка, отчёты"] --> DB
Пузырь контекста: новая модель работает со старой базой только через предохранительный слой, старый код продолжает жить как жил

Автономный пузырьавтономный пузырьEN autonomous bubbleРазвитие пузыря контекста (Эванс, 2013): у контекста своё хранилище, а предохранительный слой асинхронно синхронизирует данные со старой системой — ночным пакетом или событиями — с договорённостью о свежести. Работает, даже если старая система недоступна. — следующий шаг, когда пилот удался и разработки стало больше. У пузыря своё хранилище, а предохранительный слой асинхронно синхронизирует данные с договорённостью о свежести («к началу следующего рабочего дня»): ночным скриптом или на событиях старой системы. Автономный пузырь работает, даже если старая система недоступна, и хранит новые данные у себя.

Открыть унаследованные возможности как сервисы

Заголовок раздела «Открыть унаследованные возможности как сервисы»

Третья стратегия — не уносить функции из старой системы, а дать к ним чистый доступ: поверх старого кода ставят службу с открытым протоколомслужба с открытым протоколомEN Open Host Service (OHS)Шаблон карты контекстов: вышестоящий контекст открывает для всех потребителей единый документированный протокол (например, публичный API) вместо отдельной интеграции под каждого. с небольшим продуманным языком, внутри неё — предохранительный слой. Эванс здесь трезв: заменять все старые системы не обязательно, и погоня за полной заменой сама по себе не приносит пользы. Если старая часть стабильна, не относится к ядру и даёт доступ к нужному — пусть живёт. Так школа обходится со старой CRM корпоративных продаж с карты контекстов: её никто не переписывает, а корпоративные записи Набор получает только через свой предохранительный слой.

Пузырь растёт, и каждой новой возможности нужны новые данные из старой системы. Опасность двойная: слой превращается в огромный переводчик «на всё», или разработчики срезают путь и протаскивают старые поля насквозь. Второе мы в программе называем «туннелем» в предохранительном слое: граница размывается, и новая модель начинает повторять старую. Советы Эванса (в пересказе): группировать возможности по старым данным, которые им нужны; расширение слоя — отдельная задача в плане; добавление данных — работа по моделированию, а не новое поле в ближайшем классе.

СтратегияГде данныеКогда выбиратьЦена
Пузырь контекстав старой базе, через слойпервый пилот, данных из старой системы нужно немногоновые понятия приходится заводить и в старой базе
Автономный пузырьсвоё хранилище, синхронизацияпилот удался, разработки много, нужны свои данныесинхронизация, данные «свежие» только по договорённости
Унаследованные возможности как сервисыв старой системестарая часть стабильна, не ядро, но нужна новым контекстамслужба и слой внутри неё
Расширение пузырякак у пузыряпузырь стал главным местом новой разработкидисциплина: слой растёт вместе с возможностями, без туннелей

DDD Crew включила пузырь и автономный пузырь в список черт модели для канвы ограниченного контекста (Model Traits worksheet): заполняя канву пилота, укажите эту черту явно.

Предохранительный слой вокруг унаследованного кода

Заголовок раздела «Предохранительный слой вокруг унаследованного кода»

В уроке DDD.2.2 предохранительный слой защищал школу от чужой платёжной системы. Здесь «чужая система» — собственный старый код, но принцип тот же: слой — единственное место, где известны старые слова. По Эвансу, интерфейс хорошего слоя целиком выражен в терминах нижестоящей модели, то есть пузыря.

Старая системаНовая модель Набора
таблица groups, ключ grpпоток Cohort, cohort_id
groups.max, groups.startcapacity, starts_at
orders.usrstudent_id
orders.st: 0, 1, 2EnrollmentStatus: PENDING_PAYMENT, CONFIRMED, CANCELLED
ответ {"err": "dup"}исключение AlreadyEnrolled

Правила, которые стоит соблюдать:

  • Перевод в обе стороны и только в слое. Ни один класс модели не знает, что «подтверждена» хранится как 1.
  • Неизвестное — ошибка, а не значение по умолчанию. Тройка в st — падение с понятным сообщением, а не тихая «отмена».
  • Слой — отдельная строка в плане. Эванс советует явно закладывать его в сроки, иначе его урежут первым.
  • У слоя свои тесты. Перевод — тоже код, и ломается он тихо.

Таблица выше — это глоссарий «как есть» (о нём ниже), переписанный в код: глоссарий и слой рождаются вместе.

Мартин Фаулер описал этот шаблон в 2004 году в заметке, которая сейчас называется StranglerFigApplication. Образ — из тропических лесов Австралии: фикус-душитель прорастает в кроне дерева, пускает корни к земле и постепенно обрастает ствол-хозяина, пока от того ничего не останется.

Шаблон «душитель»шаблон «душитель» (strangler fig)EN Strangler Fig ApplicationПостепенная замена старой системы: перед ней ставят перехватчик, функции по одной переносят в новый код и переключают на него запросы, старый код удаляют, когда им никто не пользуется. Назван Мартином Фаулером (2004) по фикусу-душителю, который обрастает дерево-хозяина. (strangler fig) делает то же с системой:

  1. Перехват. Перед старой системой ставят перехватчик: маршрутизатор запросов, фасад или просто старую функцию, которая теперь решает, куда передать вызов.
  2. Новая реализация одной функции — рядом, в пузыре, со своими тестами.
  3. Переключение. Перехватчик отправляет в новый код часть запросов: сначала одного потока, потом всех.
  4. Удаление старого кода, когда им никто не пользуется.

Цикл повторяется для следующей функции. Старая система всё время работает, польза появляется после каждого цикла.

flowchart TB
U["Запросы: сайт, админка, поддержка"] --> F["Перехватчик<br/>маршрутизатор или фасад"]
F -->|"запись на поток,<br/>лист ожидания"| N["Новый код:<br/>пузырь Набора"]
F -->|"всё остальное"| O["Старая система"]
N -.->|"предохранительный слой<br/>или события"| O
Шаблон «душитель»: перехватчик отправляет в новый код всё больше функций, пока у старой системы не останется работы

В «Cosmic Python» описан вариант через перехват событий (event interception): научить старую систему публиковать события об изменениях, построить новую систему, которая собирает из них свою модель, и заменить старую новой. О надёжной публикации событий — в уроке DDD.4.2.

Пузырь отвечает на вопрос, где живёт новая модель и как она отгорожена, «душитель» — как постепенно переводить на неё работу. Обычно их сочетают. Риски «душителя» — бесконечный перенос, при котором две системы живут годами, и перехватчик, который обрастает логикой и становится новым комом грязи. Лекарство — даты удаления старого кода в плане и правило «в перехватчике нет бизнес-правил». И помните оговорку Эванса: доводить замену до конца не обязательно.

Не всякую систему нужно резать на сервисы. Часто полезнее навести границы внутри монолита — пакеты по контекстам catalog, enrollment, payment, как в уроке DDD.2.1. По шагам, каждый из которых можно выкатить:

  1. Найти шов по карте контекстов — например, весь код, который знает о платёжной системе.
  2. Собрать сценарии использования. «Cosmic Python» советует назвать их глаголами («выставить счёт», «отменить неоплаченные записи»), сделать по функции на сценарий и вынести в эти функции ввод-вывод из модели.
  3. Создать пакет с открытым интерфейсом (payment/api.py) и перевести на него старые вызовы.
  4. Запретить заглядывать во внутренности — автоматически: Import Linter проверяет в CI правила импортов между пакетами; подойдёт и свой тест на модуле ast, как линтер языка из урока DDD.1.2.
  5. Разделить данные: у таблицы один владелец, остальные читают через его интерфейс или узнают из событий.
  6. Только потом, если есть причина, выносить модуль в отдельный сервис (урок DDD.4.2).

Всё сказанное звучит как программа на год. Начало при этом — три шага, на которые хватит недели.

1. Глоссарий «как есть». Слова кода рядом со словами экспертов: orders — «запись на поток», group — «поток», st == 1 — «подтверждена», promo — «??? спросить маркетинг». Знаки вопроса — самое ценное: это правила, о которых никто не договаривался. Словарь станет спецификацией предохранительного слоя и первой версией глоссария доменаглоссарий доменаEN domain glossaryЖивой документ рядом с кодом: термин единого языка, его имя в коде, определение, пример и запрещённые синонимы. Меняется в одном коммите с кодом..

2. Карта контекстов «как есть». Картакарта контекстовEN context mapСхема и описание связей между ограниченными контекстами и командами: кто от кого зависит (upstream или downstream) и по какому шаблону отношений они взаимодействуют. Рисуют местность как есть, а не как хотелось бы. системы такой, какая она сегодня: ком грязи — честный контекст с честной подписью; кто владеет таблицами, кто от кого зависит, где болит.

3. Одна болевая точка в основном поддомене, которая стоит денег: лист ожидания или возвраты, на которые жалуется поддержка. Нужны эксперт, небольшая команда и измеримая цель вроде «жалоб на возвраты вдвое меньше к концу квартала». Там и появится первый пузырь.

Технический рецензент «Cosmic Python» Дэвид Седдон в эпилоге книги советует то же: начать с конкретной проблемы, не «кипятить океан», не ждать разрешения перестроить всё и не бояться ошибок; не вышло с первой задачей — возьмите другую.

В уроке 11.1.2 рефакторингрефакторингEN refactoringИзменение устройства кода без изменения его поведения. Безопасен только под защитой тестов. определён как изменение устройства кода без изменения поведения, а страховка для него — тест-фиксатор. У Физерса это характеризационный тестхарактеризационный тест (тест-фиксатор)EN characterization testТест, который фиксирует фактическое поведение кода — то, что код делает сейчас, включая странности и ошибки, — а не то, что он должен делать. Пишется перед рефакторингом унаследованного кода; термин Майкла Физерса. (characterization test): он описывает, что код делает сейчас, а не что должен. Приём простой: вызвать код, написать заведомо неверную проверку, запустить, посмотреть фактический результат и вписать его в проверку. Ошибки старого кода тест фиксирует тоже — его задача заметить, если поведение изменится.

Алгоритм изменения унаследованного кода по Физерсу:

  1. Найти места, которые предстоит изменить.
  2. Найти места, где можно проверить результат.
  3. Разорвать зависимости, которые мешают запустить код в тесте.
  4. Написать характеризационные тесты.
  5. Внести изменения и рефакторить.

Третий шаг опирается на швышов (в коде)EN seamПо Майклу Физерсу — место, где можно изменить поведение программы, не редактируя код в этом месте: параметр, функция модуля, которую тест подменяет, объект, переданный снаружи. Через швы старый код запускают в тестах без базы, почты и платёжной системы. (seams) — места, где можно изменить поведение программы, не редактируя код в этом месте. В Python это функция модуля, которую тест подменяет через monkeypatch, параметр со значением по умолчанию, объект, переданный снаружи. Так в практике тест обойдётся без платёжной системы и почты.

Главное правило, которое практика проверит на себе: рефакторинг и изменение поведения — разные шаги. Странность — это горячая точкагорячая точкаEN hotspotСтикер Event Storming (ярко-розовый, повёрнутый ромбом) для вопросов, конфликтов, противоречий в языке, болевых мест и всего, что решили обсудить позже. Фасилитатор ставит его вместо затянувшегося спора. и вопрос эксперту. Менять поведение — отдельным маленьким коммитом вместе с тестом; внутри рефакторинга тесты-фиксаторы не правят.

ПровалКак выглядитКак избежать
Большое переписывание«через полгода выкатим новую систему»пузырь и «душитель»: польза после каждого шага
Шаблоны без языкарепозитории и агрегаты есть, глоссария и эксперта нет; Вон Вернон называет это DDD-Liteначать с глоссария «как есть» и разговоров с экспертами
Всё объявили ядромDDD тащат в админку справочниковкарта поддоменов из урока DDD.1.3: полный арсенал — только в ядре
Туннель в предохранительном слоев модели появились поля st, grp, flag2ревью слоя; старые слова в модели — повод остановиться
Моделирование без поставкитри месяца стикеров и диаграмм, в продакшене ничегоцель пилота с метрикой и сроком, первый выпуск через недели
Переименовали и успокоилисьOrder стал Enrollment, правила по-прежнему в контроллерахпереносить в модель правила, а не только имена
Рефакторинг вперемешку с исправлениямиодин огромный PR «перевели на DDD и поправили баги»тесты-фиксаторы и отдельные коммиты на каждое изменение поведения

Нужны только Python и pytest. Положите файлы из листингов в одну папку и запускайте pytest -q из неё.

Модуль записи в том виде, в каком он достался команде. charge_card и send_mail в жизни ходят в платёжную систему и на почту; здесь они падают, чтобы случайный запуск не «списал деньги».

legacy.py
"""Модуль записи онлайн-школы. Написан в 2023 году, с тех пор «просто работает»."""
from datetime import timedelta
db = {"groups": {}, "orders": [], "next_id": 1} # в жизни — таблицы groups и orders в базе
def charge_card(card, amount):
"""Списать деньги с карты через платёжную систему (в жизни — HTTP-запрос)."""
raise RuntimeError("настоящая платёжная система недоступна")
def send_mail(to, text):
"""Отправить письмо студенту (в жизни — почтовый сервер)."""
raise RuntimeError("настоящая почта недоступна")
def enroll_and_pay(usr, grp, card, now):
"""Записать в группу и сразу оплатить. НЕ ТРОГАТЬ!!! (Петя, 2023)"""
g = db["groups"].get(grp)
if g is None:
return {"ok": False, "err": "no_group"}
if now > g["start"]:
return {"ok": False, "err": "closed"}
busy = 0
for o in db["orders"]:
if o["grp"] == grp and o["st"] != 2: # 0 — не оплачен, 1 — оплачен, 2 — отменён
if o["usr"] == usr:
return {"ok": False, "err": "dup"}
busy += 1
if busy >= g["max"]:
return {"ok": False, "err": "no_slots"}
price = g["price"]
if g.get("promo") and g["start"] - now > timedelta(days=30):
price = int(price * 0.9) # добавили в 2024-м по просьбе маркетинга
order = {"id": db["next_id"], "usr": usr, "grp": grp, "st": 0, "ts": now, "sum": price}
db["next_id"] += 1
db["orders"].append(order)
res = charge_card(card, price)
if res["status"] == "OK":
order["st"] = 1
send_mail(usr, f"Вы записаны в группу {grp}")
else:
order["err"] = res.get("code")
return {"ok": True, "id": order["id"], "paid": order["st"] == 1, "sum": price}

Сначала зафиксируем поведение. Фикстура mailbox перед каждым тестом даёт чистую «базу» и подменяет внешние системы через швы — функции модуля: карта 0000 отклоняется, любая другая проходит, письма складываются в список. Имена тестов описывают то, что код делает сейчас; пометка «КАК ЕСТЬ» в докстринге — то, что удивило и уйдёт вопросом к эксперту.

test_characterization.py
"""Характеризационные тесты: фиксируют, что модуль записи делает СЕЙЧАС, а не что он «должен» делать."""
from datetime import datetime, timedelta
import pytest
import legacy
START = datetime(2026, 11, 2, 10, 0)
DAY = timedelta(days=1)
@pytest.fixture(autouse=True)
def mailbox(monkeypatch):
"""Перед каждым тестом: чистая «база» и подменённые внешние системы. Швы — функции модуля."""
monkeypatch.setattr(legacy, "db", {"groups": {}, "orders": [], "next_id": 1})
legacy.db["groups"]["py-11"] = {"start": START, "max": 2, "price": 3_000_000, "promo": True}
declined = {"status": "DECLINED", "code": "51"}
monkeypatch.setattr(legacy, "charge_card",
lambda card, amount: declined if card == "0000" else {"status": "OK"})
sent = []
monkeypatch.setattr(legacy, "send_mail", lambda to, text: sent.append((to, text)))
return sent
def test_paid_enrollment_sends_mail(mailbox):
"""Успешная оплата: запись оплачена, письмо ушло."""
result = legacy.enroll_and_pay("anna", "py-11", "4242", START - 10 * DAY)
assert result == {"ok": True, "id": 1, "paid": True, "sum": 3_000_000}
assert mailbox == [("anna", "Вы записаны в группу py-11")]
def test_early_discount_only_more_than_30_days_before_start():
"""КАК ЕСТЬ: скидка 10 % при promo, если до старта строго больше 30 суток. В модели DDD.3.3 правило другое!"""
early = legacy.enroll_and_pay("anna", "py-11", "4242", START - 31 * DAY)
boundary = legacy.enroll_and_pay("boris", "py-11", "4242", START - 30 * DAY)
assert (early["sum"], boundary["sum"]) == (2_700_000, 3_000_000)
def test_no_slots_when_group_is_full():
"""Третий студент на двух местах получает no_slots."""
legacy.enroll_and_pay("anna", "py-11", "4242", START - 10 * DAY)
legacy.enroll_and_pay("boris", "py-11", "4242", START - 10 * DAY)
assert legacy.enroll_and_pay("vera", "py-11", "4242", START - 9 * DAY) == {"ok": False, "err": "no_slots"}
def test_second_enrollment_of_same_student_is_refused():
"""Повторная запись того же студента — dup."""
legacy.enroll_and_pay("anna", "py-11", "4242", START - 10 * DAY)
assert legacy.enroll_and_pay("anna", "py-11", "4242", START - 9 * DAY) == {"ok": False, "err": "dup"}
def test_declined_payment_still_takes_seat(mailbox):
"""КАК ЕСТЬ: платёж отклонён — ok=True, paid=False, место занято, письма нет."""
first = legacy.enroll_and_pay("anna", "py-11", "0000", START - 10 * DAY)
legacy.enroll_and_pay("boris", "py-11", "4242", START - 10 * DAY)
assert first == {"ok": True, "id": 1, "paid": False, "sum": 3_000_000}
assert legacy.enroll_and_pay("vera", "py-11", "4242", START - 9 * DAY)["err"] == "no_slots"
assert [to for to, _ in mailbox] == ["boris"]
def test_cancelled_student_can_enroll_again():
"""После отмены (st=2) тот же студент записывается снова — так решили и эксперты в уроке DDD.2.3."""
legacy.enroll_and_pay("anna", "py-11", "4242", START - 10 * DAY)
legacy.db["orders"][0]["st"] = 2
assert legacy.enroll_and_pay("anna", "py-11", "4242", START - 9 * DAY)["ok"] is True
def test_enrollment_exactly_at_start_is_accepted():
"""КАК ЕСТЬ: запись ровно в момент старта проходит (сравнение now > start). Расходится с правилом 3!"""
assert legacy.enroll_and_pay("anna", "py-11", "4242", START)["ok"] is True
assert legacy.enroll_and_pay("boris", "py-11", "4242", START + DAY)["err"] == "closed"
$ pytest -q
....... [100%]
7 passed in 0.02s

Тесты зелёные на старом коде — так и должно быть: тест-фиксатор, который не проходит на исходном коде, ничего не фиксирует. А вот что мы узнали о школе, не изменив ни строчки:

  • Ранняя скидка −10 % для промо-потоков строго больше чем за 30 суток. В модели из урока DDD.3.3 правило другое: доменный сервис enrollment_price даёт −5 % за 30 суток и более любому потоку (и −10 % выпускнику; из двух скидок — большая). Расхождение здесь намеренное: так выглядит типичная находка в живой системе — правило, о котором эксперты договорились, и правило, которое годами работает в коде, не совпадают ни в размере, ни в границе (> против >=), ни в том, к каким потокам относятся. Какая версия действует — решает эксперт, а до решения тест-фиксатор держит старое поведение, и рефакторинг его не меняет.
  • Запись ровно в момент старта проходит, хотя правило 3 говорит обратное. Кто прав — решает эксперт.
  • Отклонённый платёж держит место с ответом ok: True. По модели это запись «ожидает оплаты» — она и правда занимает место.
  • После отмены можно записаться снова. Этот открытый вопрос глоссария эксперты закрыли в уроке DDD.2.3 так же — повезло. Но узнали мы ответ старого кода из теста, а не из документации.

Шаг 2. Модель Набора и предохранительный слой

Заголовок раздела «Шаг 2. Модель Набора и предохранительный слой»

Теперь выделим модель. В enrollment.py — правила Набора на едином языке, без словарей базы и без платёжной системы. Это упрощённая версия агрегата из модуля DDD.3: только то, что нужно этой функции.

enrollment.py
"""Набор: модель записи на поток. Здесь нет ни словарей базы, ни платёжной системы — только правила."""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
class EnrollmentStatus(Enum):
PENDING_PAYMENT = "ожидает оплаты"
CONFIRMED = "подтверждена"
CANCELLED = "отменена"
class EnrollmentClosed(Exception):
"""Запись на поток закрыта: поток уже стартовал."""
class AlreadyEnrolled(Exception):
"""Студент уже записан на этот поток."""
class NoFreeSeats(Exception):
"""На потоке нет свободных мест."""
@dataclass
class Enrollment:
"""Запись на поток."""
enrollment_id: int
student_id: str
enrolled_at: datetime
status: EnrollmentStatus = EnrollmentStatus.PENDING_PAYMENT
def takes_seat(self):
return self.status is not EnrollmentStatus.CANCELLED
def confirm(self):
"""Платёж получен — запись подтверждена."""
self.status = EnrollmentStatus.CONFIRMED
@dataclass
class Cohort:
"""Поток курса: дата старта, вместимость и записи."""
cohort_id: str
starts_at: datetime
capacity: int
enrollments: list = field(default_factory=list)
def enroll(self, enrollment_id, student_id, now):
"""Записать студента на поток по правилам Набора 1–3."""
if now >= self.starts_at: # правило 3: закрыта в момент старта
raise EnrollmentClosed(f"Запись на поток {self.cohort_id} закрыта")
taking_seats = [e for e in self.enrollments if e.takes_seat()]
if any(e.student_id == student_id for e in taking_seats): # правило 2
raise AlreadyEnrolled(f"Студент {student_id} уже записан на поток {self.cohort_id}")
if len(taking_seats) >= self.capacity: # правило 1
raise NoFreeSeats(f"На потоке {self.cohort_id} нет свободных мест")
enrollment = Enrollment(enrollment_id, student_id, enrolled_at=now)
self.enrollments.append(enrollment)
return enrollment

А legacy.py превращается в перехватчик в духе «душителя»: снаружи прежняя функция enroll_and_pay с прежними ответами (от неё зависят веб-форма и админка), внутри — предохранительный слой и вызов модели. Данные пока живут в старых таблицах: их читают отчёты и выгрузка для бухгалтерии.

legacy.py
"""Модуль записи после рефакторинга: снаружи прежний интерфейс, внутри — модель Набора."""
from datetime import timedelta
from enrollment import AlreadyEnrolled, Cohort, Enrollment, EnrollmentClosed, EnrollmentStatus, NoFreeSeats
db = {"groups": {}, "orders": [], "next_id": 1} # данные пока прежние: их читает остальной старый код
def charge_card(card, amount):
"""Списать деньги с карты через платёжную систему (в жизни — HTTP-запрос)."""
raise RuntimeError("настоящая платёжная система недоступна")
def send_mail(to, text):
"""Отправить письмо студенту (в жизни — почтовый сервер)."""
raise RuntimeError("настоящая почта недоступна")
# --- Предохранительный слой: единственное место, где известны «st», «grp», «max» и коды ошибок ---
STATUS_FROM_LEGACY = {0: EnrollmentStatus.PENDING_PAYMENT, 1: EnrollmentStatus.CONFIRMED,
2: EnrollmentStatus.CANCELLED}
STATUS_TO_LEGACY = {status: code for code, status in STATUS_FROM_LEGACY.items()}
LEGACY_ERRORS = {EnrollmentClosed: "closed", AlreadyEnrolled: "dup", NoFreeSeats: "no_slots"}
def load_cohort(group_id):
"""Собрать поток из старых таблиц groups и orders."""
group = db["groups"].get(group_id)
if group is None:
return None
cohort = Cohort(group_id, starts_at=group["start"], capacity=group["max"])
cohort.enrollments = [Enrollment(o["id"], o["usr"], o["ts"], STATUS_FROM_LEGACY[o["st"]])
for o in db["orders"] if o["grp"] == group_id]
return cohort
def save_enrollment(cohort, enrollment, amount):
"""Записать новую запись в старую таблицу orders и вернуть её строку."""
row = {"id": enrollment.enrollment_id, "usr": enrollment.student_id, "grp": cohort.cohort_id,
"st": STATUS_TO_LEGACY[enrollment.status], "ts": enrollment.enrolled_at, "sum": amount}
db["orders"].append(row)
return row
# --- Старая версия ранней скидки: с enrollment_price из урока DDD.3.3 не совпадает — вопрос эксперту ---
def early_enrollment_price(group, now):
"""КАК ЕСТЬ: −10 % при promo, если до старта строго больше 30 суток."""
if group.get("promo") and group["start"] - now > timedelta(days=30):
return int(group["price"] * 0.9)
return group["price"]
# --- Прежний вход для веб-формы: теперь это тонкий сценарий вокруг модели ---
def enroll_and_pay(usr, grp, card, now):
"""Записать на поток и сразу оплатить. Интерфейс прежний — старые вызовы не ломаются."""
cohort = load_cohort(grp)
if cohort is None:
return {"ok": False, "err": "no_group"}
try:
enrollment = cohort.enroll(db["next_id"], usr, now)
except (EnrollmentClosed, AlreadyEnrolled, NoFreeSeats) as error:
return {"ok": False, "err": LEGACY_ERRORS[type(error)]}
db["next_id"] += 1
amount = early_enrollment_price(db["groups"][grp], now)
row = save_enrollment(cohort, enrollment, amount)
payment = charge_card(card, amount)
if payment["status"] == "OK":
enrollment.confirm()
row["st"] = STATUS_TO_LEGACY[enrollment.status]
send_mail(usr, f"Вы записаны в группу {grp}")
else:
row["err"] = payment.get("code")
return {"ok": True, "id": enrollment.enrollment_id,
"paid": enrollment.status is EnrollmentStatus.CONFIRMED, "sum": amount}

Тесты не меняем ни на символ и запускаем:

$ pytest -q --tb=short
......F [100%]
=================================== FAILURES ===================================
_________________ test_enrollment_exactly_at_start_is_accepted _________________
test_characterization.py:71: in test_enrollment_exactly_at_start_is_accepted
assert legacy.enroll_and_pay("anna", "py-11", "4242", START)["ok"] is True
E assert False is True
=========================== short test summary info ============================
FAILED test_characterization.py::test_enrollment_exactly_at_start_is_accepted
1 failed, 6 passed in 0.04s

Шесть тестов прошли: код внутри другой, а поведение прежнее. Седьмой упал — и это лучший момент практики. В модель перенесено правило из глоссария (now >= self.starts_at), а старый код сравнивал строго (now > g["start"]). Рефакторинг незаметно изменил поведение, тест-фиксатор это поймал. Без него запись ровно в момент старта тихо перестала бы работать, и никто не связал бы это с «косметическим» рефакторингом.

Шаг 3. Изменение поведения — отдельным шагом

Заголовок раздела «Шаг 3. Изменение поведения — отдельным шагом»

Красный тест нельзя «подправить, чтобы прошёл». Порядок такой: откатить рефакторинг до зелёного состояния, спросить эксперта и, если правило подтвердится, изменить поведение отдельным коммитом — одна строка в старом коде (now >= g["start"]) и тест, который заменяет test_enrollment_exactly_at_start_is_accepted и теперь описывает правило, а не случайность:

def test_enrollment_closes_exactly_at_start():
"""Правило 3, подтверждено экспертом: запись закрывается в момент старта потока."""
assert legacy.enroll_and_pay("anna", "py-11", "4242", START) == {"ok": False, "err": "closed"}
assert legacy.enroll_and_pay("boris", "py-11", "4242", START - timedelta(minutes=1))["ok"] is True

После этого рефакторинг из шага 2 повторяется без единой правки и снова проходит все тесты:

$ pytest -q
....... [100%]
7 passed in 0.02s

История в Git — четыре маленьких зелёных коммита: тесты-фиксаторы; «запись закрывается в момент старта (решение методиста)» — одна строка и один тест; модель Набора со своими модульными тестами; перевод enroll_and_pay на модель и предохранительный слой — чистый рефакторинг. Лист ожидания появится уже в модели, новые страницы будут вызывать её напрямую, и однажды старая enroll_and_pay останется без вызовов — тогда её удалят. Это «душитель» в миниатюре.

1. Найдите шов (10 минут). В enroll_and_pay время приходит параметром now. Представьте, что вместо этого внутри функции стоит now = datetime.now(). Как написать тест «запись ровно в момент старта»? Предложите два шва.

Возможный разбор

Первый шов — параметр со значением по умолчанию: def enroll_and_pay(usr, grp, card, now=None) и внутри now = now or datetime.now(). Старые вызовы не меняются, тест передаёт своё время. Второй — функция модуля def current_time(): return datetime.now(), которую тест подменяет через monkeypatch.setattr(legacy, "current_time", lambda: START), так же как charge_card. Первый вариант проще и честнее: зависимость от времени видна в сигнатуре.

2. Туннель или перевод? (10 минут). Менеджеры набора просят показывать в новом интерфейсе листа ожидания поле orders.src — «источник записи» со значениями w, a, p, смысла которых никто не помнит. Разработчик предлагает добавить в Enrollment поле src: str и прокинуть его как есть. Что не так и что делать?

Возможный разбор

Это туннель в предохранительном слое: старое поле без перевода попадает в модель, и через полгода src == "w" начнут проверять в правилах. Сначала — работа по моделированию: выяснить у экспертов, что значат значения (например, сайт, админка, партнёр), нужно ли это понятие Набору вообще. Если нужно — завести его на едином языке (EnrollmentChannel.WEBSITE…) и перевод в слое, неизвестные коды — ошибка. Если поле нужно только для показа, по совету Эванса можно вообще обойти пузырь и показать его прямо из старой системы.

3. План для своей системы (30 минут). Для системы, которую знаете: глоссарий «как есть» на 10 слов, карта контекстов «как есть» и одна болевая точка для пузыря — цель, метрика, эксперт, стратегия Эванса и почему.

Проверь себя6 вопросов
1 Школа хочет лист ожидания с предложением места на 24 часа. Старая система работает, данных из неё нужно немного (потоки и записи), команда впервые пробует DDD. Какая стратегия Эванса подходит лучше всего для старта?
2 Что из перечисленного — признаки «туннеля» в предохранительном слое? Выберите все верные варианты.Можно выбрать несколько ответов
3 После рефакторинга упал характеризационный тест: старый код пропускал запись ровно в момент старта, а модель — нет. Что правильно сделать?
4 Чем шаблон «душитель» отличается от пузыря контекста?
5 Как Майкл Физерс называет место, где можно изменить поведение программы, не редактируя код в этом месте, — например, функцию модуля, которую тест подменяет через monkeypatch? (одно слово по-русски)
6 Промо-поток стоит 30 000 руб. Студент записывается ровно за 30 суток до старта. Сколько рублей спишет старая enroll_and_pay из практики? (целое число)
  • DDD почти всегда внедряют в живую систему. Переписывание с нуля теряет неявные правила, гонится за движущейся целью и не даёт пользы до конца. Унаследованный код, по Физерсу, — код без тестов, поэтому первый шаг — тесты.
  • Четыре стратегии Эванса: пузырь контекста, автономный пузырь, унаследованные возможности как сервисы и дисциплинированное расширение пузыря. Заменять всё старое не обязательно.
  • Предохранительный слой — единственное место, где известны старые поля и коды: перевод в обе стороны, неизвестное — ошибка, никаких туннелей; добавление данных — работа по моделированию.
  • «Душитель» переносит работу постепенно через перехватчик; в монолите границы наводят поэтапно — сценарии, пакеты с открытым интерфейсом, проверка импортов, разделение данных.
  • Начать завтра: глоссарий и карта контекстов «как есть», одна болевая точка. Рефакторинг — под характеризационными тестами и маленькими шагами; изменение поведения — отдельный шаг с решением эксперта.

Дальше: урок DDD.4.4 — DDD и системы с ИИ: контексты как границы инструментов агента и RAG-индексов, схемы инструментов как общедоступный язык, инварианты агрегата как защита от ошибок модели. В конце — итог всей программы и мост к программе SDD.