Дисциплина проектирования программ. Скилл для opus и бэклог для sonnet
«Тестирование программ может служить для доказательства наличия ошибок, но никогда не докажет их отсутствия.»
— Edsger W. Dijkstra
Это практическая статья для вайб-кодера и джуна. Без академической мути. Цель — дать одну дисциплину проектирования, по которой opus проектирует программу, а sonnet получает готовый бэклог и реализует его в Claude Code.
Статья — концентрат четырёх разборов на codemonsters.team: структурное программирование, модульность, правильность программы, компонентные тесты.
Если ты дисциплинированно выполнишь то, что описано ниже, у тебя будет программа, которая работает правильно по построению. Не потому что её тщательно тестировали — а потому что её правильно спроектировали.
В этой главе рассмотрим:
- TL;DR — формула, которую надо запомнить
- Главное, что надо понять про тестирование
- Что такое модуль (без воды)
- Отделяй ввод-вывод от бизнес-логики
- Головной модуль — конспект всей программы
- Как модули общаются между собой. Сообщения
- Защищённое программирование. Контроль диапазонов
- Юнит-тест модуля. Чёткая формула
- Компонентный тест. Сколько их нужно
- Полный пример. Регистрация клиента
- Дисциплина: проектируем с opus, реализуем с sonnet
- Памятка-чеклист для проектирования
- Логика, которая держит всю конструкцию
TL;DR — формула, которую надо запомнить
Программа = дерево модулей. Каждый модуль — чёрный ящик с одним входом и одним выходом. На входе модуль проверяет диапазон допустимых значений. Модули логики (конструкторы доменных структур и чистые функции над ними) покрыты юнит-тестами, которые проверяют их контракт. Головной модуль, I/O-модули и ингресс-адаптеры — трубы, юнитами не тестируются: их корректность доказывает компонентный сценарий через реальный вход. Логика приложения покрыта юнит-тестами на 100%. Корректность доказана формально. Точка.
Всё остальное в статье — раскрытие этой формулы.
1. Главное, что надо понять про тестирование
Индустрия живёт в мифе: «больше тестов → надёжнее код». Это неправда. Доказано Дейкстрой ещё в 1972-м.
Тестирование показывает наличие ошибок. Никогда — их отсутствие.
Простая арифметика. Поле «возраст» типа int8 — 128 значений. Уже многовато для одного поля. Поле «ФИО» длиной до 100 кириллических символов — это 33¹⁰⁰ ≈ 7 × 10¹⁵¹ комбинаций. Чтобы перебрать все варианты на машине со скоростью миллиард тестов в секунду, нужно примерно 10¹²⁵ возрастов Вселенной. На одно поле.
Вывод: «протестировать всё» — невозможно физически. Значит цель тестирования — не «перебрать комбинации». Цель — проектировать так, чтобы корректность вытекала из конструкции, а тесты лишь фиксировали контракт.
Из этого вытекают три практических следствия:
- Качество не достигается тестированием. Качество достигается проектированием.
- Тест — это не «ловушка для бага». Тест — это исполняемая спецификация контракта.
- Тестов нужно ровно столько, сколько утверждений в спецификации. Не больше.
Запомни и не возвращайся.
2. Что такое модуль (без воды)
Модуль — это часть программы, которая делает одну задачу и делает её хорошо.
Может быть функцией, классом, пакетом, сервисом — в зависимости от масштаба. На уровне кода — обычно функция или класс. На уровне распределённой системы — отдельный сервис.
Жёсткие требования к модулю
Эти требования — не вкусовщина. Это то, без чего доказательство правильности рассыпается.
- Один вход и один выход. Один способ вызвать. Один способ вернуть результат. Никаких боковых выходов через исключения, глобальные переменные, скрытые эффекты.
- Одна функция. Преобразование исходных данных в результат. Если функцию модуля нельзя описать одной фразой — модуль слишком большой, делим.
- Чёрный ящик. У модуля есть имя и контракт. Внешний мир не знает, как модуль устроен внутри. Можно поменять реализацию — контракт не сдвинется.
- Контроль диапазона входных значений. Модуль обязан проверить, что вход допустим, до того как что-то делать. Невалидный вход → возврат ошибки как данных. Не упасть, не ронять процесс — вернуть ошибку.
- Возврат управления вызвавшему. Модуль всегда возвращает управление туда, откуда его позвали. Не прыгает в сторону, не делает «exit», не вызывает модули своего или верхнего уровня.
- Невелик. Ориентир — десятки строк, редко сотни. Если модуль разросся до 500 строк — там сидит несколько модулей, надо разделить.
- Без памяти о вызовах. Модуль не помнит, сколько раз его вызывали. Состояние — внешний параметр, а не скрытое внутреннее свойство.
- Жёсткое правило одного data-аргумента. На вход модуля приходит ровно одна доменная структура (
Command,Entity,RequestDTO) — либо ничего. Зависимости (*sql.DB, клиент брокера, часы, конфиг) — это не data, они инжектятся сбоку и в спецификации описываются отдельной строкойDependencies:. Если data-аргументов два или больше — стоп: завести доменную сущность, которая объединит эти аргументы, и добавить отдельный узел-конструкторNewT(...)для её сборки выше по пайпу.
Контракт модуля — это структура данных
Это ключевая мысль. Контракт — не «список параметров и возвращаемых значений в произвольной форме». Контракт — это именованная структура на входе и именованная структура на выходе.
[ Вход: структура Request ]
|
V
+-----------------------------+
| Module: createClient |
| 1. Проверить диапазон Req |
| 2. Преобразовать Req → Cli |
| 3. Вернуть Cli или Error |
+-----------------------------+
|
V
[ Выход: структура Result<Client, Error> ]
На псевдокоде:
Request {
lastName: String
firstName: String
patronymic: String
email: String
birthDateString: String
}
Client {
fullName: String
email: String
isEmailConfirmed: Boolean
birthDate: Date
}
createClient(req: Request) -> Result<Client, Error>
Тут уже видна вся пружина дисциплины: модуль createClient принимает Request (невалидированные данные снаружи), внутри проверяет диапазоны, возвращает либо валидированный Client, либо Error. Нет третьего пути.
3. Отделяй ввод-вывод от бизнес-логики
Это один из самых важных принципов. Без него ничего не работает.
В программе всегда есть три типа модулей:
- Адаптеры ввода-вывода. Разговаривают с внешним миром: HTTP-эндпоинты, БД, очереди, файловая система, консоль. Они только переносят байты туда-сюда, ничего не считают.
- Бизнес-логика. Чистые функции. Принимают структуру, возвращают структуру. Не лезут в БД, не пишут в консоль, не читают файлы. Никакого I/O.
- Головной модуль. Оркестрирует — принимает запрос от адаптера, прогоняет через цепочку бизнес-логики, отдаёт результат адаптеру.
Зачем это нужно. Бизнес-логика без I/O тестируется юнит-тестом за миллисекунды и без всякой инфраструктуры. Адаптеры тестируются один раз компонентным тестом против реальной зависимости. Логика и инфраструктура не перемешаны — каждое тестируется на своём уровне.
Автономный I/O-объект: каждая интеграция — собственный тип
Каждая внешняя зависимость заворачивается в автономный объект, который инкапсулирует свою зависимость. Головной модуль знает только методы объекта (его API), не его внутренние зависимости. Имя объекта по типу интеграции:
| Интеграция | Имя объекта | Скрытая зависимость |
|---|---|---|
| База данных | Store |
*sql.DB / connection pool |
| Внешний HTTP API | Client |
*http.Client + base URL |
| Брокер сообщений | Publisher / Consumer |
соединение брокера |
| Файловая система | FileStore |
*os.File / io.Writer |
В контракте I/O-модуля строка Input (data): — одно доменное сообщение, строка Dependencies: — прочерк (зависимость скрыта внутри объекта). В Deps головного модуля — поле типа Store / Client / Publisher, не сырой *sql.DB / *http.Client. Признак нарушения: сырая зависимость в зависимостях контракта или в head-модуле — значит, IO-объект не введён, надо вернуться и завести.
Эти объекты — трубы: внутри метода «взять доменное сообщение → вызвать внешнюю систему → вернуть результат или ошибку». Никаких условных ветвлений по данным, никаких трансформаций. Единственное допустимое ветвление — маппинг кодов ошибок внешней системы на доменные ошибки (SQLITE_BUSY → ErrDBLocked). Вся бизнес-логика живёт в модулях логики, не в I/O.
Картинка
+--------------------+
| HTTP Adapter (in) | <- I/O
+---------+----------+
|
V
+--------------------+
| Головной модуль | <- оркестрация
+---------+----------+
|
+---------------------+---------------------+
V V V
+----------------+ +-------------------+ +----------------+
| parseRequest | | createClient | | registerClient |
| (логика) | | (логика) | | (логика) |
+----------------+ +-------------------+ +-------+--------+
|
V
+------------------+
| DB Adapter | <- I/O
+------------------+
| Broker Adapter | <- I/O
+------------------+
Бизнес-логика в центре. Адаптеры по краям. Головной модуль склеивает.
4. Головной модуль — конспект всей программы
Дисциплина проектирования: по головному модулю должно быть видно всю логику программы. Открыл — прочитал — понял, что программа делает.
Псевдокод стиля «unix pipe» — самый компактный способ это записать:
| processRequest // распарсить аргументы → Request
| createClient // валидация + сборка → Client
| registerClient // адаптер: записать в БД
| publishEvent // адаптер: опубликовать событие
| printStatistics // адаптер: вывод
> handleError // выход на ошибку, единая точка
Все основные решения программы принимаются в головном модуле. Подчинённые модули не принимают решений за головной — они возвращают результат, головной решает, что делать дальше.
Правила вертикального управления:
- Головной зовёт нижние. Нижние не зовут верхние и не зовут соседей.
- Нижний возвращает результат вверх. Верхний решает.
- Один уровень — одна высота абстракции. Не мешай в одном модуле «поднять HTTP-сервер» и «вычислить возраст».
5. Как модули общаются между собой. Сообщения
Модули обмениваются структурами данных. Не объектами с поведением, не указателями на изменяемое состояние, не глобальными переменными — структурами.
Минимальный словарь типов сообщений
Это базовая палитра, которую дальше можно расширять. Описывай каждое сообщение явно.
| Тип сообщения | Когда использовать | Структура (псевдокод) |
|---|---|---|
Request |
Невалидированный вход извне | поля как пришли из адаптера |
Command |
Валидированный вход в бизнес-логику | только допустимые значения |
Entity / DTO |
Валидированный объект предметной области | поля + инварианты типа |
Result<T, Error> |
Возврат: успех с T или ошибка | success: T xor failure: Error |
Event |
Факт, который произошёл (для брокера/наблюдателей) | тип, время, полезная нагрузка |
Error |
Описание провала валидации/инфраструктуры | код, человеко-читаемое сообщение, контекст |
Правило: на каждое сообщение — отдельная структура с именем. Request, Client, RegistrationCommand, ClientRegisteredEvent — у каждого своё лицо. Не передавай между модулями Map<String, Object> — это путь в ад.
Result как способ возвращать ошибку
Не выбрасывай исключение в бизнес-логике. Возвращай ошибку как данные:
Result<T, Error> {
case Success(value: T)
case Failure(error: Error)
}
createValidEmail(raw: String) -> Result<Email, Error>
parseBirthDate(raw: String) -> Result<LocalDate, Error>
createClient(req: Request) -> Result<Client, Error>
Что это даёт. Сигнатура модуля становится самодокументирующейся: видно сразу, что метод может провалиться. Композиция модулей идёт через явный разбор результата — нет скрытых выходов.
Жёсткое правило записи контракта. В сигнатуре модуля всегда указываем оба типа-параметра: Result<ТипЗначения, ТипОшибки>. Не Result<Client>, а Result<Client, Error>. Не Result<String>, а Result<String, ValidationError>. Это не косметика — это часть контракта: читающий (человек, ИИ-агент, машина) сразу видит, чем именно модуль провалится, и у каждого Failure-пути в юнит-тестах появляется конкретное ожидаемое значение.
Исключения оставь для случаев, когда продолжать выполнение в принципе нельзя и контракт модуля не должен описывать такой исход:
- нарушение инварианта самой программы (баг разработчика — например, недостижимая ветка
default, в которую попали); - порча конфигурации на старте, без которой запуск бессмысленен;
- ошибки времени выполнения языка, которые ты не контролируешь (StackOverflow, OutOfMemory) — их кидает рантайм, а не ты.
Бизнес-сценарий «у клиента пустое имя», «БД недоступна», «дубликат email» — это не исключения. Это нормальные результаты типа Failure, описанные в контракте. Если их кидать исключениями — у вызывающего модуля исчезает информация о ветках в сигнатуре, и доказательство правильности рассыпается.
6. Защищённое программирование. Контроль диапазонов
Главная техника, благодаря которой программа становится правильной по построению.
Перед каждой операцией модуля — проверяем антецедент (что вход допустим). После операции — гарантируем консеквент (что выход в допустимом диапазоне).
Звучит академично, на практике — два простых правила:
- Антецедент. Первое, что делает модуль, — проверяет вход. Не подходит →
Result.failure(...). До бизнес-логики. - Консеквент. Модуль возвращает только то, что соответствует контракту. Если по логике это невозможно гарантировать — модуль спроектирован неправильно, проектируй дальше.
Пример: безопасное деление
divide(numerator: BigDecimal, denominator: BigDecimal) -> Result<BigDecimal, Error>:
if denominator == 0:
return Failure("Деление на ноль невозможно")
return Success(numerator / denominator)
Здесь явно проверен диапазон допустимых значений знаменателя. Никаких ArithmeticException, никаких сюрпризов. Модуль защищён.
Пример: валидация имени
createValidName(name: String, fieldName: String) -> Result<String, Error>:
if name is null or trim(name) is empty:
return Failure("Ошибка: " + fieldName + " не может быть пустым")
if not name.matches("^[А-Я][а-яА-Я\-]*$"):
return Failure("Ошибка: " + fieldName + " должно содержать только кириллицу и начинаться с заглавной буквы")
return Success(name)
Антецедент: непустая строка из кириллицы, начинающаяся с заглавной. Консеквент: на выходе всегда строка, удовлетворяющая антецеденту, или ошибка.
Если в каждом модуле логики программы антецедент и консеквент покрыты юнит-тестом — корректность программы доказана формально.
Это и есть тот самый формальный вывод, ради которого всё затевалось.
Конструктор подтипа вместо guard-функции
Когда инвариант проверяется не над сырым входом адаптера, а над уже валидной доменной сущностью с учётом внешнего факта (текущее время, подпись, статус другой сущности) — соблазн вставить в пайп guard-функцию вида checkX(entity) -> error. Это путь в спячку.
Guard ничего не закрепляет в типе: после checkSessionFresh(session, now) структура session не изменилась, и любой другой код может принять её без проверки. Шаг легко забыть, переставить или продублировать.
Правильный приём — завести подтип, несущий инвариант в типе:
loadSession(id) -> Session
NewFreshSession({session, now}) -> FreshSession <-- конструктор подтипа
doWork(fresh) -> WorkResult
FreshSession — отдельная доменная структура с неэкспортируемыми полями. NewFreshSession проверяет инвариант (now < session.ExpiresAt()) и возвращает либо FreshSession, либо доменную ошибку. Дальше по пайпу проходит только FreshSession, и в doWork нельзя случайно передать просроченную сессию — код не скомпилируется.
Это расширение «валидация — через конструкторы» с примитивов на доменные сущности. Любой инвариант над уже-валидной структурой, требующий учёта внешнего факта, оформляется как конструктор подтипа, а не как guard-функция. Эффект на формулу юнит-тестов — никакой: подтип считается так же, 1 happy + Σ ветки антецедента. Зато исчезает «висящий» guard, который ничего не вычисляет.
7. Юнит-тест модуля. Чёткая формула
Юнит-тест — это проверка контракта модуля логики. Не «проверить кучу случаев», а проверить, что:
- На граничных и типичных допустимых входах модуль возвращает ожидаемый
Success. - На каждой явной ветке невалидного входа модуль возвращает соответствующую
Failure.
Что юнитами тестируется, а что — нет
| Артефакт | Юнит-тест | Компонентный (Gherkin) |
|---|---|---|
| Конструктор доменной структуры | да, по формуле | косвенно, через happy path |
| Чистая функция логики | да, по формуле | косвенно, через happy path |
| Головной модуль slice'а | нет (труба; юнит = интеграционный тест) | да, happy path + все ветки ошибок I/O через сценарии отказа |
| I/O-модуль (Success-ветка) | нет | happy-path сценарий slice'а (если запись не дойдёт — Gherkin красный) |
| I/O-модуль (Failure-ветки) | нет | сценарий отказа того slice'а, к которому режим отказа привязан |
| Ингресс-адаптер (парсинг) | нет | happy + сценарии ошибок (через реальный HTTP/Broker/gRPC-вход) |
Жёсткое правило: головной модуль, I/O-модули и ингресс-адаптер юнитами не покрываются. Они трубы из уже протестированных частей. Юнит-тест над ними был бы интеграционным тестом (пайп собирает реальные зависимости), и сама попытка такой тест написать — сигнал, что их роль не понята. Их корректность доказывается компонентным сценарием через реальный вход slice'а.
Формула — для модулей логики
N_юнит_тестов(модуль логики) = 1 (happy path)
+ Σ (число явных веток в антецеденте)
+ Σ (число явных веток в консеквенте, если они есть)
Для createValidName это:
- 1 happy path (валидное имя проходит)
- 1 ветка: null/пустое
- 1 ветка: нарушение паттерна
Итого 3 юнит-теста. Не 30. Не 300. Три.
Никаких моков
Юнит-тесты работают только с реальными объектами: чистыми функциями и доменными структурами. Никаких mock-функций, stub-интерфейсов, fake-БД или func-полей в Deps ради подмены. Если для теста нужна внешняя зависимость — это не юнит, это компонентный тест, и он пишется в Gherkin против реальной БД/брокера в Docker. Единственное допустимое исключение — детерминированная инъекция времени (testClock), это стандартная идиома языка, а не подмена.
Признак, что модуль пытаются «по-юнитски» протестировать против I/O: в Deps появилось поле, которое в проде указывает на реальную зависимость, а в тесте — на заглушку. Это ловушка: даже если зависимость в тесте «честная» (in-memory SQLite), это уже не юнит, а маленький интеграционный тест. Удалять.
Что НЕ проверяет юнит-тест
- Реализацию (какие циклы, какие if-ы внутри). Только контракт.
- Внешние зависимости. Никакой БД, никакого HTTP, никакого Kafka. Это не юнит — это уже компонентный.
- Производительность. Это отдельный вид теста.
Покрытие 100% — это следствие, а не цель
Если каждый модуль логики имеет тесты на все ветки антецедента и happy path — покрытие логики будет 100%. Не потому что мы гнались за метрикой, а потому что иначе модуль спроектирован неправильно (есть ветка без проверки). Покрытие в I/O-модулях, головном модуле и адаптерах меряется не юнит-тестами, а тем, проходит ли компонентный сценарий happy path и сценарии всех режимов отказа.
8. Компонентный тест. Сколько их нужно
Компонентный тест проверяет контракт сервиса с внешним миром. Не внутренности, не валидацию, не бизнес-логику — только то, что видно через API.
Сценариев в компонентном тесте всего два класса:
- Штатное поведение. Один сценарий: валидный запрос → корректный ответ + ожидаемые эффекты на интеграциях.
- Отказ внешней связи. По сценарию на каждую различимую ветку обработки в адаптере. БД упала, брокер не отвечает — отдельный сценарий.
Формула
Для типового сервиса с PostgreSQL и Kafka — это 3 теста: happy path, отказ БД, отказ брокера. Не 30 и не 300.
Формат — Gherkin
Gherkin читают четверо: разработчик, аналитик, вайб-кодер, машина. Один артефакт — общий язык.
Feature: Регистрация клиента
Scenario: Успешная регистрация совершеннолетнего
Given сервис регистрации запущен
And БД доступна
And брокер сообщений доступен
When клиент отправляет POST /clients с валидными данными
Then ответ 201 Created с идентификатором клиента
And запись клиента сохранена в БД
And событие client_registered опубликовано в брокер
Scenario: Отказ БД при регистрации
Given сервис регистрации запущен
And БД недоступна
And брокер сообщений доступен
When клиент отправляет POST /clients с валидными данными
Then ответ 503 Service Unavailable
And событие client_registered не публиковалось
Это исполняемая спецификация. Прогоняется в Docker Compose с реальной БД и реальным брокером. Никаких моков на уровне компонента.
9. Полный пример. Регистрация клиента
Соберём всё в один сквозной пример. Задача: консольная программа, регистрирует клиента (ФИО, email, дата рождения), требует 18+, хранит в памяти.
Шаг 1. Схема иерархии модулей (top-down)
+------------------------+
| processRegistration | <-- головной модуль
+-----------+------------+
|
+-----------------+-----------+-----------+----------------+
V V V V
+-------------+ +---------------+ +----------------+ +---------------+
| processReq | | createClient | | registerClient | | printStats |
| (логика) | | (логика) | | (адаптер: БД) | | (адаптер: io) |
+-------------+ +-------+-------+ +----------------+ +---------------+
|
+-----------------+-----------+----------------+
V V V
+----------------+ +-------------------+ +-----------------+
| createFullName | | createValidEmail | | createValidBday|
+--------+-------+ +-------------------+ +--------+--------+
| |
+--------V--------+ +----------V----------+
| createValidName | | parseBirthDate |
| (3 раза: | | validateNotInFuture |
| ФИО) | | calculateAge |
+-----------------+ | createAdultAge |
+---------------------+
Шаг 2. Контракты модулей
Request {
lastName, firstName, patronymic, email, birthDateString : String
}
Client {
fullName: String, email: String, isEmailConfirmed: Boolean, birthDate: Date
}
Error {
message: String
}
Result<T, Error> = Success(T) | Failure(Error)
# логика
processRequest(args: String[]) -> Result<Request, Error>
createValidName(name, fieldName) -> Result<String, Error>
createFullName(last, first, patr) -> Result<String, Error>
createValidEmail(email) -> Result<String, Error>
parseBirthDate(s) -> Result<Date, Error>
validateBirthDateNotInFuture(d, clock) -> Result<Date, Error>
calculateAge(d, clock) -> Int
createAdultAge(age) -> Result<Int, Error>
createValidClientBirthDate(s, clock) -> Result<Date, Error>
createClient(req: Request, clock) -> Result<Client, Error>
# адаптеры (I/O)
registerClient(store, client) -> ()
printStatistics(store) -> ()
printError(message) -> ()
# головной
processRegistration(args, store, clock) -> ()
Шаг 3. Головной модуль (псевдокод)
processRegistration(args, store, clock):
requestResult = processRequest(args)
if requestResult.isFailure: printError(requestResult.error); return
clientResult = createClient(requestResult.value, clock)
if clientResult.isFailure: printError(clientResult.error); return
registerClient(store, clientResult.value)
printStatistics(store)
Видно всю программу за пять строчек. Это и есть «головной модуль — конспект всей программы».
Шаг 4. Юнит-тесты по формуле
Считаем только модули логики — конструкторы доменных структур и чистые функции. Адаптеры (registerClient, printStatistics, printError) и головной модуль (processRegistration) в таблицу юнит-тестов не попадают: они трубы, проверяются компонентным тестом.
createValidName: 3 теста (happy + null/empty + нарушение паттерна)
createFullName: 1 теста (happy; невалидные ветки уже покрыты в createValidName)
createValidEmail: 3 теста (happy + null/empty + нарушение паттерна)
parseBirthDate: 3 теста (happy + null/empty + кривой формат)
validateBirthDateNot..: 2 теста (happy + дата в будущем)
calculateAge: 1 тест (чистая функция от даты)
createAdultAge: 2 теста (happy + младше 18)
createValidClientBday: 1 тест (склейка; ветки уже покрыты ниже)
createClient: 1 тест (склейка; ветки уже покрыты)
processRequest: 2 теста (happy + мало аргументов)
Около 19 юнит-тестов на всю логику. Покрытие логики 100%.
Шаг 5. Компонентный тест
В этой консольной программе нет внешних зависимостей, поэтому компонентный тест один — happy path. Если бы появились БД и брокер — добавились бы по одному сценарию на отказ каждого.
10. Дисциплина: проектируем с opus, реализуем с sonnet
Теперь — самое практическое. Как это превратить в рабочий процесс с Claude.
Этап А. Проектирование — делает opus
Opus отвечает за архитектурное мышление. Его работа — проектная документация, по которой sonnet потом пишет код. Ничего не кодим, пока не закончен этап А.
Артефакты этапа А:
- Описание задачи в одну фразу. Что программа делает. Если в одну фразу не получается — задача слишком большая, режем.
- Псевдокод головного модуля каждого slice'а в стиле unix pipe. Главный сценарий — шагами. 5–10 строк, по строке на вызов модуля.
- Схема иерархии модулей. Дерево: ингресс-адаптер → головной → конструкторы доменных структур → чистая логика → автономные I/O-объекты. На каждом узле — имя модуля и одна фраза «что делает».
- Каталог сообщений. Все структуры данных, которыми обмениваются модули:
Request,Command,Entity,Event,Error. Доменные структуры — с неэкспортируемыми полями и единственным конструкторомNewT(...) -> (T, error). - Контракты модулей в жёстком шаблоне:
Сигнатура: имя(input: Type) -> Result<выход: Type, Error>(или(T, error)в Go)Input (data):— одна доменная структура, или Request DTO, или voidDependencies:—Store/Client/Publisher,clock.Clock, конфиг. Сырых*sql.DB,*http.Client, broker-conn — не должно быть (они инкапсулированы в автономный I/O-объект)Что делает:— одна фразаАнтецедент:— условия на inputКонсеквент:— что гарантирует на выходе (Success / Failure-классы)
- Карта автономных I/O-объектов. Каждая внешняя зависимость заворачивается в
Store/Client/Publisher/Consumer/FileStore. Головной модуль знает только методы объекта. - Граф вызовов модулей slice'а. Стрелки несут имя структуры. Сверка согласованности контрактов: тип на стрелке существует, имена совпадают, консеквент отправителя ⊆ антецеденту получателя, классы ошибок согласованы.
- Компонентные сценарии Gherkin для каждого slice'а (1 happy + по сценарию на каждый различимый режим отказа). Пишутся ДО проектирования модулей — это исполняемая спецификация, по которой сверяется дизайн.
- Таблица
## Gherkin-mappingв карточке каждого slice'а: каждый Then-шаг каждого сценария явно привязан к узлу графа (модулю) или к маппингу в адаптере. Если Then не находит узла — дизайн неполон, возврат к схеме модулей. - План тестирования. Юнит-тесты по формуле для модулей логики (конструкторы и чистые функции). Головной модуль, I/O и адаптер юнитами не покрываются — их зеленят компонентные сценарии.
Этап Б. Бэклог для sonnet
Из артефактов этапа А механически собирается бэклог тикетов для sonnet в Claude Code. Один тикет = один модуль + его юнит-тесты.
Шаблон тикета:
TICKET: реализовать модуль <name>
Контракт:
<name>(<input>: <Type>) -> Result<<output>: <Type>, Error>
Антецедент (что модуль требует на входе):
- <условие 1>
- <условие 2>
Консеквент (что модуль гарантирует на выходе):
- на Success: <условие>
- на Failure: <классы ошибок>
Зависимости (какие модули вызывает):
- <module A>
- <module B>
Юнит-тесты (по формуле 1 + ветки антецедента):
- happy path: <вход> -> Success(<выход>)
- <ветка 1>: <вход> -> Failure("<сообщение>")
- <ветка 2>: <вход> -> Failure("<сообщение>")
Definition of Done:
- модуль реализован согласно контракту
- все юнит-тесты зелёные
- покрытие модуля 100% по строкам и веткам
- модуль не лезет в I/O (если он логика) или изолирует I/O в одном месте (если он адаптер)
Sonnet читает тикет, пишет модуль, пишет тесты. Никакой «творческой свободы» — контракт жёстко задан, ветки заданы, тесты заданы. Sonnet делает то, что хорошо умеет: аккуратно превращает спецификацию в код.
Порядок реализации в бэклоге
Нисходящий проект — восходящая реализация. Сначала листья (модули без зависимостей), потом узлы выше, в конце — головной модуль и компонентный тест.
1. createValidName # лист
2. createValidEmail # лист
3. parseBirthDate # лист
4. validateBirthDateNot.. # лист
5. calculateAge # лист
6. createAdultAge # лист
7. createFullName # узел: 3x createValidName
8. createValidClientBday # узел: parse + validate + adult
9. processRequest # узел: парсинг
10. createClient # узел: всё вместе
11. registerClient (адап) # I/O
12. printStatistics (адап)# I/O
13. printError (адап) # I/O
14. processRegistration # головной
15. component test (Gherkin: 1 happy)
11. Памятка-чеклист для проектирования
Когда садишься проектировать новую программу или сервис, прогоняй по списку. Это финальный фильтр перед тем, как отдавать бэклог в Claude Code.
Структура
- [ ] Задача формулируется в одну фразу
- [ ] Есть псевдокод головного сценария в стиле unix pipe (5–10 строк)
- [ ] Есть схема иерархии модулей нисходящим способом
- [ ] Есть блок-схема (или эквивалент) головного модуля
Модули
- [ ] Каждый модуль делает одну функцию, выражаемую одной фразой
- [ ] Каждый модуль имеет один вход и один выход
- [ ] Каждый модуль возвращает управление вызвавшему
- [ ] Модуль не вызывает свой уровень и выше (только ниже)
- [ ] Модуль не помнит истории своих вызовов
- [ ] Модуль невелик (десятки строк, редко сотни)
- [ ] На входе каждого модуля — ровно одна data-структура (доменная сущность / Request / void); зависимости вынесены отдельной строкой
Dependencies: - [ ] Доменные структуры с неэкспортируемыми полями и единственным конструктором
NewT(...) -> (T, error) - [ ] Все основные решения принимает головной модуль
- [ ] Инварианты, требующие учёта внешнего факта (время, статус, подпись), несутся через подтип, а не через guard-функцию
I/O
- [ ] Бизнес-логика отделена от I/O
- [ ] Каждая внешняя зависимость инкапсулирована в автономный объект
Store/Client/Publisher/Consumer/FileStore - [ ] В
Dependencies:контрактов и вDepsголовного модуля нет сырых*sql.DB,*http.Client, broker-conn — только объекты-обёртки - [ ] I/O-модуль — труба: один внешний вызов, никаких ветвлений по данным, только маппинг кодов ошибок
- [ ] Один I/O-модуль = одна внешняя зависимость + один режим работы (чтение или запись, не «и-и»)
- [ ] Бизнес-логика не делает I/O напрямую
Контракты
- [ ] Все сообщения между модулями — именованные структуры
- [ ] У каждого модуля логики описан антецедент
- [ ] У каждого модуля логики описан консеквент
- [ ] Ошибки возвращаются как данные через Result, а не через исключения
- [ ] В каждой сигнатуре Result указаны оба типа-параметра:
Result<ТипЗначения, ТипОшибки>
Тесты
- [ ] Компонентные сценарии Gherkin для каждого slice'а написаны ДО проектирования модулей (1 happy + по сценарию на каждый различимый режим отказа)
- [ ] Таблица
## Gherkin-mappingв карточке каждого slice'а: каждый Then-шаг привязан к узлу графа или маппингу адаптера - [ ] У каждого модуля логики есть юнит-тесты по формуле
1 + ветки антецедента - [ ] Логика покрыта юнит-тестами на 100%
- [ ] Головной модуль, I/O-модули и ингресс-адаптер юнитами не тестируются — их зеленят компонентные сценарии
- [ ] Никаких моков, stub-интерфейсов и func-полей в
Depsради подмены — только реальные объекты - [ ] Юнит-тест не использует БД/HTTP/брокеры (только чистые функции и доменные структуры)
- [ ] Компонентный тест поднимает реальные зависимости в Docker Compose
12. Логика, которая держит всю конструкцию
Если каждый из принципов выше выполнен, цепочка получается такая:
- Каждый модуль логики контролирует диапазон входных значений → его выход всегда в допустимом диапазоне.
- Каждый модуль логики покрыт юнит-тестом, проверяющим контракт → корректность модуля доказана.
- Доменные структуры создаются только через конструктор, инварианты несутся через подтипы → невалидное состояние неконструктивно (его нельзя создать).
- Программа — это композиция модулей, каждый из которых корректен → корректность программы вытекает из корректности модулей.
- I/O инкапсулирован в автономные объекты-трубы → бизнес-логика остаётся чистой, эффекты доказываются компонентным сценарием через реальный вход.
- Головной модуль виден целиком и состоит из вызовов корректных модулей → корректность головного очевидна и проверяется компонентным тестом контракта с внешним миром.
- Логика приложения покрыта юнит-тестами на 100% → формальное доказательство правильности программы построено.
Это и есть «правильная программа по построению». Не магия, не мифология — скучная инженерная дисциплина.
Вайб-кодеру и джуну с этим жить легко: правила жёсткие, шагов конкретное число, на каждом шаге понятно, что делать. Opus думает по этим правилам и проектирует. Sonnet читает спецификацию и реализует. Ты управляешь процессом, не путаясь в деталях.
Источники
- Linger R. C., Mills H. D., Witt B. I. Structured Programming: Theory and Practice. Addison-Wesley, 1979.
- Dijkstra E. W. A Discipline of Programming. Prentice-Hall, 1976.
- Wirth N. Systematic Programming: An Introduction. Prentice-Hall, 1973.
- Hughes J. K., Michtom J. I. A Structured Approach to Programming. Prentice-Hall.
- Meyer B. Applying "Design by Contract". IEEE Computer, 1992.
- Adzic G. Specification by Example. Manning, 2011.
- ISTQB Foundation Level Syllabus. Component Testing.
- Morev M. Структурирование программ. codemonsters.team, 2025.
- Morev M. Модульность программы. codemonsters.team, 2025.
- Morev M. Правильность программы. codemonsters.team, 2025.
- Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.