Комбинация, которую не гонял ни один тест
1 августа 2012 года Knight Capital раскатила деплой на восемь серверов. Один из них новый код так и не подхватил. Одно это несовпадение — восемь серверов, новый код или старый, 256 возможных конфигураций, и ровно одна из них хоть когда-нибудь проверенная — разбудило в проде ветку кода, десять лет как мёртвую, и стоило фирме 440 миллионов долларов за 45 минут.
256 возможных конфигураций. Проверили ровно одну. 440 миллионов долларов — за 45 минут.
Сама по себе ни одна строчка того кода не была кривой. Баг был состоянием, которого никто не закладывал, комбинацией, которую не гонял ни один тест, — а версию поменьше ты уже выкатывал. Спиннер всё крутится поверх контента, который и так уже на экране. Тост с ошибкой выезжает над страницей, которая прекрасно загрузилась. Пустое состояние мелькает на кадр, прежде чем отрисуются данные, которые уже были на руках.
За всем этим — три поля: isLoading, isError и data, которое либо есть, либо нет. Между ними восемь комбинаций; четыре что-то значат — idle, loading, loaded, failed. Остальные четыре — баг: спиннер всё крутится, хотя данные уже вот они; загрузка и ошибка разом; рендер, который непонятно вообще в каком состоянии. Код обязан пережить все восемь — потому что тип утверждает, что все восемь существуют.
У этой формы есть имя: это старый возвращаемый тип useQuery. На эти грабли наступали так часто, что React Query взял и заменил булевы на единственное поле status (pending | error | success).
Взрыв состояний
useQuery · 3 булевых поля
Эти три поля сцеплены друг с другом, и большинство из восьми состояний — чистая бессмыслица, которую можно выкинуть подчистую. С фича-флагами всё наоборот: они независимы по задумке, поэтому каждая комбинация — это настоящий таймлайн, в котором твой код крутится в проде. Открой дашборд и посчитай флаги старше полугода, которые ты «всё собирался прибрать». Подожду.
А теперь перемножь их. Каждый флаг ветвит таймлайн — включён или выключен. Один флаг — два таймлайна. Два — четыре. Три — восемь. И так удваивается каждый раз.
Найди число своей команды — 20флагов1 048 576таймлайнов — и каждый из них путь, по которому твой код может пойти в проде.
Никто не тестирует миллион таймлайнов. QA подписывает ту дюжину, что ты включаешь осознанно, — тесты горят зелёным. Остальные просто есть: в проде, никем не перечисленные, ждут нужного пользователя, который откроет нужную страницу в нужном порядке. Это те самые 256 конфигураций Knight Capital, только слоем выше, — та же ловушка, теперь с твоими фича-флагами вместо восьми серверов.
И на баг в коде это редко похоже — ни краша, ни стектрейса, ни красного теста. Прошло всё, что ты написал; сломалась ровно та комбинация, которую ты не написал. Выглядит это как «клиент X видит не ту цену». Ты гоняешься за ним как за состоянием гонки, пока кто-нибудь не поднимет историю флагов и не увидит: вот эта конкретная тройка флагов не сходилась за всю жизнь системы, после пятничного релиза не сойдётся больше никогда — и прямо сейчас ломает ровно один инвойс. Этого состояния никто не закладывал. Каждый флаг делал ровно то, что обещал на тумблере. А вот комбинация оказалась таймлайном, которого не представлял себе ни один человек.
В универе мне про это не рассказывали. Тот баг, который ты не смог воспроизвести, — это обычно не ошибка в логике, а состояние, о котором ты забыл, ветка таймлайна, которую никто не собирался отращивать.
И дело не только во флагах. Есть одна невидимая штука, которая раздувает пространство состояний как ничто другое, — и ты потратил на её отладку больше времени в карьере, чем хотел бы: тот самый NullPointerException, то самое undefined is not a function, та единственная проверка, которую ты забыл на единственном пути, где она была важна.
Это null. Его же изобретатель, Тони Хоар, называет его своей ошибкой на миллиард долларов: он всунул его в систему типов в 1965-м «просто потому, что это было легко реализовать» — и следующие полвека смотрел, как тот порождает «бесчисленные ошибки, уязвимости и падения систем».
Вот почему он худший из всех. Булев флаг ты добавляешь осознанно и видишь его заранее. А null язык добавляет за тебя — тихо, почти к каждому полю сразу, превращая любой T в T | null. Это флаг, пришпиленный к каждому твоему значению: он удваивает состояния этого поля и умножается на все остальные. Форма с 15 необязательными полями — это 32 768 комбинаций «заполнено/пусто». (Твоя самая большая форма — ты её знаешь.) Модель пользователя с isAdmin, isVerified, isBanned, isDeleted — это 16 состояний, из которых осмысленны от силы четыре — и как минимум в одной базе где-то все четыре выставлены в true. Вебхуки, пришедшие не по порядку; ретрай поверх недописанной записи; сообщение, которое приходит дважды или не приходит вовсе, — и каждый раз форма одна и та же. Множество состояний, в которых код может оказаться, в разы больше того, которое он на самом деле обрабатывает.
И тестами отсюда не выберешься — математика против тебя. Knight — это ещё дешёвый случай; исследование 2015 года по продакшен-авариям в крупных распределённых системах показало, что примерно четверть уходит корнями в комбинации конфигурационных параметров, которые никто не тестировал, — та же ловушка, только на уровне инфраструктуры. Пространство состояний разрастается само собой; твоя способность его проверять растёт в лучшем случае линейно. И разрыв растёт каждый раз, когда кто-то выкатывает фичу.
TL;DR — две самые дешёвые подрезки. Большинство прод-багов — не кривые строчки, а состояния, которых никто не перечислил. Тестами отсюда не выберешься — комбинации взрываются, — но можно ужать пространство состояний так, чтобы плохих состояний просто не было:
- Размеченное объединение — тип, который всегда ровно одна из нескольких форм, а не их смесь: невозможные состояния даже не скомпилируются.
- Ограничение в базе — обеспечь то, что не может тип, там, где это не обойти.
Начни сегодня: возьми одну модель из булевой каши и схлопни её в объединение. Про покрывающие массивы, проверку моделей и почему спеку теперь дёшево писать — Часть 2.
Теперь ветки плодит ещё и ИИ
Всё большую долю твоего кода теперь пишет LLM, и пишет быстрее, чем поспевает ревью. Генерация масштабируется, вдумчивое чтение — нет. Ветки плодятся быстрее, чем кто-нибудь успеет проверить их руками, так что я перестал ставить на проверку. Проверку можно удешевить — затянуть петлю обратной связи, чтобы ошибки всплывали быстро, превратить свои договорённости в линт-правила, которые гоняет CI, — и это стоит сделать. Но самая дешёвая ветка для ревью — та, которой не может быть. Так что подрежь те ветки, которых вообще не должно быть, — сделай так, чтобы их нельзя было даже записать. Ветка, которую ИИ не может записать, — это баг, который он не может зашипить.
Подрезать — значит сделать плохую ветку невыразимой
Почти всё, что допускает тип, — недопустимо. Легальные состояния — маленький островок; то, что тип на самом деле разрешает, — целый океан. Мне до неловкого долго не доходило, чем на самом деле был мой собственный защитный код: гарды, комментарии «такого быть не должно», одна и та же валидация в пяти разных местах. Всё это нянчило комбинации, которые вообще не должны были быть представимы.
Лекарство — ужать океан, пока он не сойдётся с островком. Приёмам меня научили два эссе, каждое со своей стороны:
Сделай недопустимые состояния непредставимыми — Влашин: спроектируй саму форму так, чтобы у плохих состояний просто не было написания. Если состояние нельзя собрать, его нельзя ни передать, ни вернуть, ни сохранить. Парси, а не валидируй — Кинг: проверь входящие данные один раз, на границе, и верни тип со встроенным инвариантом, а дальше его несёт компилятор.
Оба подрезают на входе. А оттуда набор инструментов уходит глубже.

Убей булевы флаги
Хрестоматийный пример. Никто не проектирует такое нарочно. Три булевых поля жизненного цикла нарастают по одному за раз: isDraft в этом квартале, isArchived через два, и каждое добавляет тот, кто не глядел на два других (и кто с тех пор перешёл в другую команду):
interface Order {
isDraft: boolean
isPublished: boolean
isArchived: boolean
}Три булевых флага. Восемь возможных состояний, и ✗ бессмыслица. Остальные пять — опубликован и черновик разом, архив и черновик и опубликован — бессмыслица, с которой код всё равно обязан возиться, потому что тип утверждает, что они существуют.
Отказываться от булевых совсем не обязательно. Тест, которым пользуюсь я, — Мэтта Покока: плохие булевы хранят состояние; хорошие — выводятся из него. Хранимый isPublished, который ты проставляешь руками, — это болезнь; а const isPublished = status === "published" — нормально: он вычисляемый, а значит ничему противоречить не может.
Лекарство — размеченное объединение: тип, который говорит «это значение — ровно одна из этих форм и никогда не смесь». Оно есть в каждом крупном языке; разнится лишь то, насколько трудно компилятор даёт в нём ошибиться. (Нет Sorbet в твоём Rails-проекте? Раздел про ограничения базы ниже — это та же подрезка, которая работает на любом стеке.)
// TypeScript
type Order =
| { status: "draft"; content: string }
| { status: "published"; content: string; publishedAt: Date }
| { status: "archived"; content: string; archivedAt: Date }Восемь состояний → три, и null ушли вместе с ними. В булевой версии publishedAt приходилось делать nullable: настоящая дата у опубликованных заказов, null у черновиков — и ничто не мешало черновику таскать шальной таймстамп, а опубликованному заказу остаться вовсе без него. Объединение выкидывает поле из каждой формы, которой оно не положено, так что publishedAt живёт только у Published. Ни nullable-колонки, про которую забудешь, ни недопустимой комбинации, ни ошибки на миллиард, которую надо проверять. А «опубликован-и-черновик разом» никогда и не имел формы, в которой мог бы жить. (Нет под рукой объединения? Версия на уровень ниже — язык, где null включается явно: Option в Rust, ? в Kotlin, strictNullChecks в TypeScript, — и отсутствие значения становится случаем, который компилятор заставляет назвать.)
TypeScript, Python и Rust делают это жёстким стопом на этапе компиляции. Go и C# обеспечивают структуру, но недопустимое значение отвергают лишь в рантайме; доказательства исчерпываемости на компиляции нет. И эта разница важна, когда объединение растёт:
Та же подрезка — и для того спиннера из начала: опиши загрузку как одно из idle | loading | error | data — и состоянию «грузится и упало разом» просто негде жить: не ловится в рантайме, а вообще не собирается.
А когда ты делаешь по нему switch, компилятор заставляет разобрать каждый случай:
// TypeScript — пропусти случай, и оно не скомпилируется
function render(order: Order) {
switch (order.status) {
case "draft": return renderDraft(order.content)
case "published": return renderPublished(order.content, order.publishedAt)
case "archived": return renderArchived(order.content, order.archivedAt)
}
}Добавь четвёртый статус — и оно перестанет компилироваться, пока ты его не разберёшь. Rust обеспечивает ту же исчерпываемость жёсткой ошибкой компиляции; assert_never в Python и T.absurd в Sorbet ловят это на этапе проверки типов; Go и C# лишь предупреждают или кидают исключение в рантайме (исчерпываемость иерархии классов не доказывает ни один из них).
И та же идея работает на уровне базы. SQL слабее системы типов, зато применяется на каждой записи, каждым клиентом и навсегда:
▶То же правило как таблица Postgres
-- PostgreSQL — база отказывается хранить недопустимые состояния
CREATE TABLE orders (
id UUID PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived')),
content TEXT NOT NULL,
published_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
-- Тот же инвариант, что и в размеченном объединении, но на уровне хранения.
-- archived отбрасывает `published_at`, потому что архивировать можно из draft
-- (который не публиковался) — поэтому и объединение не несёт его на `archived`;
-- оба слоя согласованы. Если бы архив всегда шёл после публикации — держи на обоих.
CHECK (
(status = 'draft' AND published_at IS NULL AND archived_at IS NULL) OR
(status = 'published' AND published_at IS NOT NULL AND archived_at IS NULL) OR
(status = 'archived' AND published_at IS NULL AND archived_at IS NOT NULL)
)
);Твои TypeScript-типы кончаются на сетевой границе. А база — нет, и CHECK тоже. Даже если какой-нибудь сервис на другом языке забудет правило, ограничение его поймает. К этому мы ещё вернёмся.
Добавляешь четвёртый статус? Компилятор ткнёт в каждое место, которое надо поправить, — и код не соберётся, пока не разберёшь их все.
Объединение схлопывает состояния одного поля. Но некоторые правила связывают два независимых поля — инвойс со status: voided, у которого payment_status почему-то всё ещё succeeded, — и ни один отдельный тип не сделает такую комбинацию непредставимой, потому что каждое поле по отдельности легально. Тут нужна подрезка, которая видит оба поля сразу.
Подрезка, которую не обойдёт ни одно приложение
Все подрезки до сих пор живут в твоём коде, а значит, кончаются ровно в тот миг, когда данные пересекают границу, которой ты не владеешь: JSON, который только что принял API, строка, записанная другим сервисом, сообщение из Kafka. Про них твой тип Order не гарантирует ничего.
Тому, что обеспечивают все эти подрезки, есть название — инвариант: та единственная фраза, которой твои данные не должны противоречить. «Удалённая строка не возвращается». «Возвращённый заказ не открывается заново». Баги — это нарушенные инварианты. Пространство, которое забору нужно оборонять, может быть астрономическим, а сам забор при этом остаётся крошечным: доказательство безопасности Paxos 2021 года перелопатило миллиарды кандидатов в инварианты и нашло, что правило, которое всё пришпиливает, умещается в горстку термов. Вопрос только в том, где ты его обеспечиваешь, от самого слабого к самому сильному:

▶Весь спектр — таблицей
| Механизм | Когда проверяется | Что при нарушении |
|---|---|---|
| Комментарии / доки | Никогда | Ничего |
| Runtime-assert | В точке вызова, иногда | Падение, в идеале в деве |
| Тесты | На CI | Сборка падает (для кейсов, что ты написал) |
| Линтеры | На линте | PR падает (для паттернов, что ты закодировал) |
| Типы TypeScript | На компиляции, стираются в рантайме | Сборка падает, но обходится через as any и заканчивается на сетевой границе |
| Сильные типы (Rust, Haskell) | На компиляции, обойти труднее | Сборка падает раньше, а unsafe — это opt-in, а не запасной выход |
| Валидация схемы (Zod, Pydantic) — библиотека, что проверяет входящие данные по объявленной форме | На границе системы, в рантайме | Отклонить вход; при успехе тип несёт инвариант дальше |
| Ограничения базы данных | На каждой записи, каждым клиентом, навсегда | INSERT/UPDATE отклонён — единственный слой, который не обойдёт ни одно приложение |
Большинство команд недоиспользует базу как слой инвариантов. Я и сам так годами. А это, наверное, самый мощный слой, какой у тебя есть.

Каждое ограничение убирает целый класс плохих состояний. И, в отличие от твоих типов, оно держит на каждой записи, от каждого клиента, навсегда, неважно, какой сервис что забыл:
NOT NULLустраняет состояние.UNIQUEустраняет класс дубликатных состояний.FOREIGN KEYустраняет состояния с висячими ссылками.CHECK (status IN ('active', 'done', 'archived'))схлопывает неограниченное текстовое поле до трёх легальных значений.
▶Почему CHECK, а не нативный ENUM?
Менять набор значений остаётся обычной миграцией. Postgres ENUM умеет только ADD VALUE; а удалить или переставить значение значит пересоздать весь тип и каждую колонку на нём. text + CHECK — компромисс, на который идёт большинство команд на масштабе: одна только схема GitLab несёт 35 enum-whitelist-проверок, делающих ровно ту же работу, что и ENUM.
И команды на серьёзном масштабе уже так живут:
Схема GitLab объявляет 2 419 ограничений
CHECK— из них 82 проверки исключающей дизъюнкции («ровно одна из этих колонок задана» — половина размеченного объединения на стороне базы).
И это поставлено на поток: штатный хелпер миграций add_multi_column_not_null_constraint превращает «ровно одна из этих задана» в однострочник, к которому тянется любой инженер. Вот так и выглядит кодовая база, когда она всерьёз держит базу за слой обеспечения инвариантов.
А вот что зацепило меня, когда я начал считать: у твоей модели уже есть спека того, какие комбинации легальны. Просто она размазана по двум слоям, которые между собой не разговаривают.
Один слой — это куча условных валидаций, которые накапливает любая нетривиальная модель (validates … if:, validate, условные коллбэки). В app/models у GitLab их 173, у Mastodon — 72. И это буквально таблица решений: предикаты — это параметры, а правила срабатывают на комбинациях, которые никто не перечисляет. Вот только эти валидации расходятся с реальностью: это код, про который надо помнить, а update_column / upsert_all / insert_all обходят их целиком.
Другой слой — это CHECK и частичные индексы выше, которые разойтись не могут, потому что ограничение и есть обеспечение. Получается, легальное пространство состояний наполовину объявлено в слое, который гниёт, и наполовину — в слое, который гнить не может. А баги живут в зазоре между ними.
▶Ещё два инструмента: частичные уникальные индексы и EXCLUDE
Частичный уникальный индекс — идиоматичный способ обеспечить кардинальность конечного автомата на уровне хранения:
CREATE UNIQUE INDEX one_active_subscription_per_customer
ON subscriptions (customer_id)
WHERE status = 'active';После этого сама база наотрез откажется когда-либо хранить две одновременно активные подписки для одного клиента. Ни гонка, ни прикладной баг, ни сервис на другом языке это состояние уже не создадут. И это тоже не хитрый трюк: схема GitLab несёт 163 таких частичных уникальных индекса с привязкой к состоянию (WHERE status = ...), которые по всей кодовой базе держат «не больше одной строки в этом состоянии».
Ограничение EXCLUDE обобщает ту же идею на диапазоны и пересечения. Представь бронирование переговорок:
-- классу операторов `=` нужно расширение btree_gist:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (
room_id WITH =,
during WITH &&
);Каждая система бронирования заново изобретает «никаких дважды забронированных переговорок» в прикладном коде — плохо, с гонками, которые база просто отказалась бы допустить.
А если хочешь понять, во что обходится отсутствующее ограничение, самый чистый случай — Robinhood, 2020–21. Приложение показывало клиентам ложные отрицательные балансы и наставило по ним 84 100 ошибочных маржин-коллов, схлопотав рекордный штраф FINRA в $70 млн за причинённый «значительный вред». Knight был из комбинаторных — 256 конфигураций, проверена одна. Robinhood — из тех, где не хватает инварианта: «никогда не показывай пользователю баланс, который не подтверждён реестром» — это правило, и не обеспечивал его ни один слой стека.
Вот подрезка, которую типом не сделаешь: она держала даже тогда, когда весь прикладной код — валидация, гард, пересчёт — врал. Валидация схемы на границе помогает; ограничение базы помогает сильнее, потому что не полагается на то, что приложение право.
Поэтому я перестал выбирать какой-то один правильный механизм. Складывай их стопкой. Каждый слой в той таблице срезает пространство состояний понемногу; вместе они пришпиливают систему к чему-то близкому к «достижимы только валидные состояния».
Подрезка на входе

Это то, что Алексис Кинг назвала «парси, а не валидируй»: проверь один раз на границе и верни тип со встроенным инвариантом, а не булево, которое всё, что ниже по коду, обязано перепроверять заново:
▶Парсинг против валидации, в коде
// Валидация: проверь, понадейся, повтори везде
function isValidOrder(input: unknown): boolean {
/* ... */
}
function processOrder(input: unknown) {
if (!isValidOrder(input)) throw new Error("bad")
// input всё ещё `unknown`. Будешь проверять снова. И снова.
}
// Парсинг: проверь один раз, дальше инвариант несёт тип
const result = OrderSchema.safeParse(rawJson)
if (result.success) {
const order: Order = result.data // ← инвариант теперь живёт в типе
processOrder(order) // Ниже — ни одной проверки. За это отвечает компилятор.
}Zod, Pydantic, io-ts, Valibot — всё это один и тот же трюк: на входе рантайм-инвариант, на выходе инвариант уровня типов. Платишь цену один раз, на входе, — а дальше компилятор обеспечивает его до конца жизни программы.
Начни подрезать
Две подрезки. Одну сделай сегодня.
Сегодня. Найди одну модель с тремя или больше статусными булевыми полями или со строкой status без CHECK за ней. Замени булевы поля размеченным объединением или добавь ограничение. Одно плохое состояние, которое больше нельзя записать. Это самая дешёвая подрезка из всех, и ты её почувствуешь.
На этой неделе. Возьми кросс-полевой инвариант, который твоё приложение подразумевает, но нигде не обеспечивает — «отменённый инвойс никогда не оплачен», «у активной подписки есть клиент», — и затолкай его в базу: CHECK, частичный уникальный индекс, EXCLUDE. Подрезка, которую не объедет ни один сервис.
Эти две подрезки разом удаляют большинство плохих состояний. А для тех комбинаций, что уцелели, — пространства, слишком большого, чтобы вытеснить его типами; последовательности, которую никто не перечислит; бага в 3 ночи, что прошёл все тесты, — есть слой верификации: Часть 2 — покрывающие массивы, проверка моделей и баг, что переживает любой тест.
Подрежь таймлайн. По одной подрезке за раз.
Источники
▶Источники и что почитать
- Алексис Кинг, Parse, Don't Validate (2019)
- Ярон Мински, Effective ML (Jane Street, доклады Effective ML на CUFP середины 2000-х и пост «Effective ML Revisited») — версия «сделай недопустимые состояния непредставимыми» от сообщества OCaml, на десятилетие раньше F#-разбора
- Скотт Влашин, Making Illegal States Unrepresentable (F# for Fun and Profit) — популярный пересказ эпохи F#/TypeScript
- Крис Крайко, Making Illegal States Unrepresentable in TypeScript
- Дэвид Харел, Statecharts: A Visual Formalism for Complex Systems (Science of Computer Programming, 1987) — источник иерархических и параллельных состояний, от которых происходит XState
- Тони Хоар, Null References: The Billion Dollar Mistake (QCon London 2009) — изобретатель null-ссылки о том, почему каждое nullable-поле — это состояние, которое ты не собирался добавлять
- Даг Севен, Knight Capital — A DevOps Cautionary Tale
- SEC, In the Matter of Knight Capital Americas LLC (Order 34-70694, 2013) — первоисточник про убыток в $440 млн и штраф в $12 млн
- CNBC, Robinhood to pay $70 million for outages and misleading customers (2021) — приказ FINRA за примером с отсутствующим инвариантом
- Тяньинь Сюй и др., Hey, You Have Given Me Too Many Knobs! (FSE 2015) — неправильная конфигурация как ведущая причина продакшен-аварий
- Бен Мозли, Питер Маркс, Out of the Tar Pit (2006) — канонический довод, что состояние — главный источник сложности; «каждый добавленный бит состояния удваивает число возможных состояний»