В предыдущем уроке мы разобрали, когда SDD не стоит брать: быстро меняющиеся требования, enterprise-легаси с кросс-командными зависимостями. Это правда — но не приговор. Проблема не в самом SDD, а в попытке применить его сразу и везде. В большую кодовую базу SDD входит инкрементально — и именно эту механику мы сейчас разберём.
Главная проблема: спека уже есть — только она невидима
В greenfield-проекте спека предшествует коду. В легаси всё наоборот: код написан, тесты (если есть) описывают поведение частично, а настоящие требования живут в головах разработчиков, в старых тикетах Jira и в комментариях к PR трёхлетней давности. Это «спек-долг» (spec debt) — накопленная невыраженная спецификация.
Попытка написать полную спеку на кодовую базу из 500k строк заранее обречена: займёт месяцы, устареет до завершения и встретит сопротивление команды. Нужна другая стратегия.
Три стратегии входа
Все три применяются параллельно — выбор зависит от типа задачи.
Стратегия «острова»: новые фичи — всегда по спеке
Самый простой вход. Любой новый модуль или новая фича пишется по SDD-воркфлоу с нуля: requirements.md → design.md → tasks.md. Старый код не трогаем.
Эффект накапливается быстро: через три-четыре спринта в кодовой базе уже есть несколько «островов» с документированным поведением. Команда видит разницу между «островами» и легаси — и постепенно хочет расширять покрытие.
Стратегия «сейма»: фиксируем контракты на границах
«Сейм» (seam) — граница в коде, через которую можно вставить спеку без переписывания модуля целиком. Хорошие кандидаты: публичные API, HTTP-эндпоинты, интерфейсы между сервисами, точки интеграции с внешними системами.
Спека для сейма — это контракт: что модуль принимает, что возвращает, какие инварианты соблюдает. Внутренняя реализация за сеймом не описывается — только наблюдаемое поведение.
# Контракт: PaymentService.processPayment()
WHEN авторизованный пользователь вызывает processPayment с валидными данными
AND сумма не превышает лимит транзакции
THEN сервис возвращает transactionId в течение 5 секунд
::widget{id="rc-2"}
## Acceptance Criteria
- [ ] При сумме выше лимита — PaymentLimitExceeded, транзакция не создаётся
- [ ] Повторный вызов с тем же requestId возвращает тот же transactionId
- [ ] Тайм-аут шлюза обрабатывается как GATEWAY_TIMEOUT, не как успехТакая спека полезна даже без рефакторинга: агент понимает контракт и не нарушает его при добавлении кода вокруг.
Стратегия «ретроспеки»: фиксируем поведение перед рефактором
Перед рефакторингом модуля пишем спеку на его текущее поведение. Это инвертированный SDD: не «спека → код», а «код → спека → рефакторинг».
Процесс:
1. Читаем существующие тесты — переводим в EARS-нотацию (помните шаблон «WHEN … THE SYSTEM SHALL …» из урока про анатомию спеки).
2. Разговариваем с людьми, которые писали или поддерживали этот код, — вытаскиваем неявные ограничения.
3. Проверяем спеку на граничных кейсах и сверяем с реальным поведением кода.
4. Добавляем acceptance criteria для неочевидных случаев.
После этого рефакторинг идёт под защитой спеки: если новый код нарушает зафиксированное поведение — это видно сразу.
flowchart TD
A[Новая задача] --> B{Новый модуль или фича?}
B -- Да --> C[Стратегия острова]
B -- Нет --> D{Рефакторинг существующего?}
D -- Да --> E[Стратегия ретроспеки]
D -- Нет --> F{Граница между модулями?}
F -- Да --> G[Стратегия сейма]
F -- Нет --> H[Уточните тип задачи]Практический воркфлоу для команды
Шаг 1 — Выбираем точку входа. Берём компонент с наибольшим количеством изменений за последние 90 дней. High churn = максимальная боль от неясного поведения, поэтому здесь спека даст быструю отдачу.
Шаг 2 — Ретроспека втроём. Разработчик + тестировщик + продакт или аналитик. Каждый видит разный срез требований — только вместе они дают полную картину.
Шаг 3 — Любая новая задача в этом компоненте начинается со спеки. Не «потом дообновим»: сначала проверяем, покрывает ли существующая спека нужное поведение; если нет — дописываем до реализации.
Шаг 4 — Расширяемся на соседние компоненты. Начинаем с тех, которые чаще всего взаимодействуют с первым.
К пятому спринту SDD становится нормой для нового кода, ретроспека постепенно покрывает высокорисковые легаси-области.
Частые ловушки
«Исторический контекст» как неявное требование. В легаси часто встречается логика, смысл которой непонятен без пятилетней истории: «здесь умножаем на 0.97, потому что в 2019-м был специфический договор с клиентом». При ретроспеке такие места должны стать явными — как минимум в виде комментария, как максимум в виде requirement с контекстом.
Конфликт между спекой и тестами. Иногда тесты проверяют поведение, которое было ошибкой, а не фичей. Ретроспека вскрывает это противоречие. Не спешите «исправлять» спеку под тесты — сначала выясните, какое поведение является правильным.
Scope creep при ретроспекинге. Начали с одного модуля, заметили связанный, захотели описать и его — через неделю пишете спеку на половину системы. Ограничивайте ретроспеку жёстко по периметру: один модуль, один контракт за раз.
Признаки, что SDD приживается
- PR на новую фичу всё чаще содержат
requirements.mdбез напоминания - Ревьюеры спрашивают «а где это в спеке?» вместо «а зачем это вообще нужно?»
- Онбординг новых разработчиков на старые модули идёт быстрее — спека даёт контекст, который раньше передавался только устно
- Агент реже делает «неожиданные» допущения в новом коде
На кодовую базу с двухлетней историей уйдёт 3–6 месяцев, чтобы SDD стал нормой для нового кода. Но начало — одна спека, один компонент — даёт результат уже в первый спринт.
Где и как хранить спеки в легаси-проекте
Вопрос не риторический: если спеки лежат в Confluence, через полгода они расходятся с кодом, и никто об этом не узнает до очередного инцидента. Если в Google Docs — ещё хуже, потому что там нет diff и review. Единственная рабочая схема на практике: спеки живут в репозитории рядом с кодом.
Co-location — основной принцип
Спека модуля хранится в той же директории, что и его код. Это означает, что она попадает в тот же PR, ревьюится теми же людьми и версионируется вместе с реализацией. Если кто-то меняет поведение — изменение в спеке видно в diff автоматически.
Для большого монорепо или проекта с несколькими сервисами структура выглядит примерно так:
repository/
├── services/
│ ├── payment/
│ │ ├── src/
│ │ │ └── PaymentService.ts
│ │ ├── tests/
│ │ └── specs/ # <-- спеки рядом с сервисом
│ │ ├── requirements.md # WHAT: бизнес-требования в EARS-нотации
│ │ ├── design.md # HOW: архитектурные решения
│ │ ├── contracts/
│ │ │ ├── processPayment.md # контракт на каждый публичный метод
│ │ │ └── refundPayment.md
│ │ └── decisions/
│ │ └── 001-idempotency.md # ADR: почему выбрали именно этот подход
│ └── notifications/
│ ├── src/
│ └── specs/
│ ├── requirements.md
│ └── contracts/
│ └── sendEmail.md
├── shared/
│ └── specs/ # межсервисные контракты и глобальные инварианты
│ ├── error-codes.md
│ └── auth-contract.md
└── docs/
└── sdd-guide.md # как команда ведёт спеки (один файл, не wiki)Что кладём в каждый файл
requirements.md — только бизнес-требования. Без деталей реализации. Сюда пишем EARS-шаблоны и acceptance criteria. Этот файл читается продактом — если он требует знания кода, чтобы понять его, что-то пошло не так.
design.md — архитектурные решения: какие паттерны использованы, почему именно они, какие альтернативы отброшены. Не «как работает код», а «почему он устроен именно так».
contracts/ — по одному файлу на каждый публичный интерфейс или HTTP-эндпоинт. Именно здесь живут контракты в стиле «сейма» из предыдущего раздела. Удобно, когда нужно быстро найти спеку конкретного метода, не читая весь requirements.md.
decisions/ — Architecture Decision Records (ADR). Нумерованные файлы: 001-..., 002-.... Здесь фиксируем «исторический контекст» — те самые умножить на 0.97 из-за договора 2019 года. Формат простой: контекст → рассмотренные варианты → принятое решение → последствия.
Naming conventions и дисциплина
Несколько правил, которые реально работают:
- Файлы спек — только Markdown. Никаких
.docx, которые нельзя нормально сравнить в PR. - Директория всегда называется
specs/, неspec/, неdocumentation/, неdocs/внутри сервиса. Единообразие важнее личных предпочтений. - Контракты называются по методу или эндпоинту:
processPayment.md,POST-orders.md. Легко искать. - ADR только добавляются, никогда не редактируются. Если решение изменилось — пишем новый ADR со ссылкой на старый.
Линтинг и CI
Чтобы спеки не протухали молча, можно добавить в CI простую проверку: если изменился файл в src/ без изменений в specs/ — пайплайн выдаёт предупреждение (не ошибку на старте, иначе команда начнёт обходить). Пример для GitHub Actions:
- name: Check spec coverage
run: |
changed_src=$(git diff --name-only origin/main | grep 'src/')
changed_specs=$(git diff --name-only origin/main | grep 'specs/')
if [ -n "$changed_src" ] && [ -z "$changed_specs" ]; then
echo "::warning::Изменён код в src/, но specs/ не обновлены. Проверьте, нужна ли обновлённая спека."
fiЭто не блокирует merge, но создаёт видимость: ревьюер видит предупреждение и задаёт вопрос.
Confluence и другие wiki — когда они уместны
Совсем отказываться от wiki не нужно. Confluence хорошо подходит для: онбординг-материалов, высокоуровневых описаний домена, диаграмм, которые активно обсуждаются. Но эти документы должны ссылаться на спеки в репозитории, а не дублировать их. Правило простое: если документ меняется вместе с кодом — он в репо. Если он меняется по другим причинам (бизнес-контекст, процессы команды) — он в wiki.