Skip to content

Дисциплина проектирования программ. Скилл для opus и бэклог для sonnet

«Тестирование программ может служить для доказательства наличия ошибок, но никогда не докажет их отсутствия.»

— Edsger W. Dijkstra

Это практическая статья для вайб-кодера и джуна. Без академической мути. Цель — дать одну дисциплину проектирования, по которой opus проектирует программу, а sonnet получает готовый бэклог и реализует его в Claude Code.

Статья — концентрат четырёх разборов на codemonsters.team: структурное программирование, модульность, правильность программы, компонентные тесты.

Если ты дисциплинированно выполнишь то, что описано ниже, у тебя будет программа, которая работает правильно по построению. Не потому что её тщательно тестировали — а потому что её правильно спроектировали.

В этой главе рассмотрим:

TL;DR — формула, которую надо запомнить

Программа = дерево модулей. Каждый модуль — чёрный ящик с одним входом и одним выходом. На входе модуль проверяет диапазон допустимых значений. Модули логики (конструкторы доменных структур и чистые функции над ними) покрыты юнит-тестами, которые проверяют их контракт. Головной модуль, I/O-модули и ингресс-адаптеры — трубы, юнитами не тестируются: их корректность доказывает компонентный сценарий через реальный вход. Логика приложения покрыта юнит-тестами на 100%. Корректность доказана формально. Точка.

Всё остальное в статье — раскрытие этой формулы.

1. Главное, что надо понять про тестирование

Индустрия живёт в мифе: «больше тестов → надёжнее код». Это неправда. Доказано Дейкстрой ещё в 1972-м.

Тестирование показывает наличие ошибок. Никогда — их отсутствие.

Простая арифметика. Поле «возраст» типа int8 — 128 значений. Уже многовато для одного поля. Поле «ФИО» длиной до 100 кириллических символов — это 33¹⁰⁰ ≈ 7 × 10¹⁵¹ комбинаций. Чтобы перебрать все варианты на машине со скоростью миллиард тестов в секунду, нужно примерно 10¹²⁵ возрастов Вселенной. На одно поле.

Вывод: «протестировать всё» — невозможно физически. Значит цель тестирования — не «перебрать комбинации». Цель — проектировать так, чтобы корректность вытекала из конструкции, а тесты лишь фиксировали контракт.

Из этого вытекают три практических следствия:

  1. Качество не достигается тестированием. Качество достигается проектированием.
  2. Тест — это не «ловушка для бага». Тест — это исполняемая спецификация контракта.
  3. Тестов нужно ровно столько, сколько утверждений в спецификации. Не больше.

Запомни и не возвращайся.

2. Что такое модуль (без воды)

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

Может быть функцией, классом, пакетом, сервисом — в зависимости от масштаба. На уровне кода — обычно функция или класс. На уровне распределённой системы — отдельный сервис.

Жёсткие требования к модулю

Эти требования — не вкусовщина. Это то, без чего доказательство правильности рассыпается.

  1. Один вход и один выход. Один способ вызвать. Один способ вернуть результат. Никаких боковых выходов через исключения, глобальные переменные, скрытые эффекты.
  2. Одна функция. Преобразование исходных данных в результат. Если функцию модуля нельзя описать одной фразой — модуль слишком большой, делим.
  3. Чёрный ящик. У модуля есть имя и контракт. Внешний мир не знает, как модуль устроен внутри. Можно поменять реализацию — контракт не сдвинется.
  4. Контроль диапазона входных значений. Модуль обязан проверить, что вход допустим, до того как что-то делать. Невалидный вход → возврат ошибки как данных. Не упасть, не ронять процесс — вернуть ошибку.
  5. Возврат управления вызвавшему. Модуль всегда возвращает управление туда, откуда его позвали. Не прыгает в сторону, не делает «exit», не вызывает модули своего или верхнего уровня.
  6. Невелик. Ориентир — десятки строк, редко сотни. Если модуль разросся до 500 строк — там сидит несколько модулей, надо разделить.
  7. Без памяти о вызовах. Модуль не помнит, сколько раз его вызывали. Состояние — внешний параметр, а не скрытое внутреннее свойство.
  8. Жёсткое правило одного data-аргумента. На вход модуля приходит ровно одна доменная структура (Command, Entity, Request DTO) — либо ничего. Зависимости (*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. Отделяй ввод-вывод от бизнес-логики

Это один из самых важных принципов. Без него ничего не работает.

В программе всегда есть три типа модулей:

  1. Адаптеры ввода-вывода. Разговаривают с внешним миром: HTTP-эндпоинты, БД, очереди, файловая система, консоль. Они только переносят байты туда-сюда, ничего не считают.
  2. Бизнес-логика. Чистые функции. Принимают структуру, возвращают структуру. Не лезут в БД, не пишут в консоль, не читают файлы. Никакого I/O.
  3. Головной модуль. Оркестрирует — принимает запрос от адаптера, прогоняет через цепочку бизнес-логики, отдаёт результат адаптеру.

Зачем это нужно. Бизнес-логика без 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. Защищённое программирование. Контроль диапазонов

Главная техника, благодаря которой программа становится правильной по построению.

Перед каждой операцией модуля — проверяем антецедент (что вход допустим). После операции — гарантируем консеквент (что выход в допустимом диапазоне).

Звучит академично, на практике — два простых правила:

  1. Антецедент. Первое, что делает модуль, — проверяет вход. Не подходит → Result.failure(...). До бизнес-логики.
  2. Консеквент. Модуль возвращает только то, что соответствует контракту. Если по логике это невозможно гарантировать — модуль спроектирован неправильно, проектируй дальше.

Пример: безопасное деление

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. Юнит-тест модуля. Чёткая формула

Юнит-тест — это проверка контракта модуля логики. Не «проверить кучу случаев», а проверить, что:

  1. На граничных и типичных допустимых входах модуль возвращает ожидаемый Success.
  2. На каждой явной ветке невалидного входа модуль возвращает соответствующую 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.

Сценариев в компонентном тесте всего два класса:

  1. Штатное поведение. Один сценарий: валидный запрос → корректный ответ + ожидаемые эффекты на интеграциях.
  2. Отказ внешней связи. По сценарию на каждую различимую ветку обработки в адаптере. БД упала, брокер не отвечает — отдельный сценарий.

Формула

N_компонентных = 1 + Σ (число различимых веток в адаптере i)

Для типового сервиса с 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 потом пишет код. Ничего не кодим, пока не закончен этап А.

Артефакты этапа А:

  1. Описание задачи в одну фразу. Что программа делает. Если в одну фразу не получается — задача слишком большая, режем.
  2. Псевдокод головного модуля каждого slice'а в стиле unix pipe. Главный сценарий — шагами. 5–10 строк, по строке на вызов модуля.
  3. Схема иерархии модулей. Дерево: ингресс-адаптер → головной → конструкторы доменных структур → чистая логика → автономные I/O-объекты. На каждом узле — имя модуля и одна фраза «что делает».
  4. Каталог сообщений. Все структуры данных, которыми обмениваются модули: Request, Command, Entity, Event, Error. Доменные структуры — с неэкспортируемыми полями и единственным конструктором NewT(...) -> (T, error).
  5. Контракты модулей в жёстком шаблоне:
    • Сигнатура: имя(input: Type) -> Result<выход: Type, Error> (или (T, error) в Go)
    • Input (data): — одна доменная структура, или Request DTO, или void
    • Dependencies:Store / Client / Publisher, clock.Clock, конфиг. Сырых *sql.DB, *http.Client, broker-conn — не должно быть (они инкапсулированы в автономный I/O-объект)
    • Что делает: — одна фраза
    • Антецедент: — условия на input
    • Консеквент: — что гарантирует на выходе (Success / Failure-классы)
  6. Карта автономных I/O-объектов. Каждая внешняя зависимость заворачивается в Store/Client/Publisher/Consumer/FileStore. Головной модуль знает только методы объекта.
  7. Граф вызовов модулей slice'а. Стрелки несут имя структуры. Сверка согласованности контрактов: тип на стрелке существует, имена совпадают, консеквент отправителя ⊆ антецеденту получателя, классы ошибок согласованы.
  8. Компонентные сценарии Gherkin для каждого slice'а (1 happy + по сценарию на каждый различимый режим отказа). Пишутся ДО проектирования модулей — это исполняемая спецификация, по которой сверяется дизайн.
  9. Таблица ## Gherkin-mapping в карточке каждого slice'а: каждый Then-шаг каждого сценария явно привязан к узлу графа (модулю) или к маппингу в адаптере. Если Then не находит узла — дизайн неполон, возврат к схеме модулей.
  10. План тестирования. Юнит-тесты по формуле для модулей логики (конструкторы и чистые функции). Головной модуль, 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. Логика, которая держит всю конструкцию

Если каждый из принципов выше выполнен, цепочка получается такая:

  1. Каждый модуль логики контролирует диапазон входных значений → его выход всегда в допустимом диапазоне.
  2. Каждый модуль логики покрыт юнит-тестом, проверяющим контракт → корректность модуля доказана.
  3. Доменные структуры создаются только через конструктор, инварианты несутся через подтипы → невалидное состояние неконструктивно (его нельзя создать).
  4. Программа — это композиция модулей, каждый из которых корректен → корректность программы вытекает из корректности модулей.
  5. I/O инкапсулирован в автономные объекты-трубы → бизнес-логика остаётся чистой, эффекты доказываются компонентным сценарием через реальный вход.
  6. Головной модуль виден целиком и состоит из вызовов корректных модулей → корректность головного очевидна и проверяется компонентным тестом контракта с внешним миром.
  7. Логика приложения покрыта юнит-тестами на 100% → формальное доказательство правильности программы построено.

Это и есть «правильная программа по построению». Не магия, не мифология — скучная инженерная дисциплина.

Вайб-кодеру и джуну с этим жить легко: правила жёсткие, шагов конкретное число, на каждом шаге понятно, что делать. Opus думает по этим правилам и проектирует. Sonnet читает спецификацию и реализует. Ты управляешь процессом, не путаясь в деталях.

Источники

  1. Linger R. C., Mills H. D., Witt B. I. Structured Programming: Theory and Practice. Addison-Wesley, 1979.
  2. Dijkstra E. W. A Discipline of Programming. Prentice-Hall, 1976.
  3. Wirth N. Systematic Programming: An Introduction. Prentice-Hall, 1973.
  4. Hughes J. K., Michtom J. I. A Structured Approach to Programming. Prentice-Hall.
  5. Meyer B. Applying "Design by Contract". IEEE Computer, 1992.
  6. Adzic G. Specification by Example. Manning, 2011.
  7. ISTQB Foundation Level Syllabus. Component Testing.
  8. Morev M. Структурирование программ. codemonsters.team, 2025.
  9. Morev M. Модульность программы. codemonsters.team, 2025.
  10. Morev M. Правильность программы. codemonsters.team, 2025.
  11. Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.