Капстоун II: инкрементальный рефакторинг
Выполни рефакторинг из части 1 целиком. Накрой модуль характеризационными тестами, затем делай по одному маленькому обратимому ходу на коммит под зелёным — выделение, объекты-значения, полиморфизм, порт хранилища — не пряча правку поведения в рефакторинг.
В части 1 ты прочитал запутанный модуль OrderProcessor, назвал его запахи и записал план: выделить переплетённые методы, дать Money и id заказа настоящие типы, убить switch по paymentType полиморфизмом и спрятать базу за порт. План верен. План — это ещё и то место, где большинство инженеров губит модуль: они пытаются сделать всё сразу, в одной гигантской ветке, и через неделю у них наполовину переписанный файл, который больше не сходится с тестами и который нельзя ни выкатить, ни откатить.
Этот урок — про исполнение: та же безопасная последовательность — накрой тестами, затем делай по одному маленькому обратимому ходу на коммит, никогда не меняя поведение посреди шага — применённая от первого характеризационного теста до инвертированной зависимости хранилища. Навык здесь не в каком-то одном ходе; это ритм, который держит модуль выкатываемым на каждом коммите, пока он преображается у тебя под руками.
После этого урока ты можешь выполнить настоящий рефакторинг от начала до конца без переписывания «большим взрывом»: писать характеризационные тесты, чтобы зафиксировать текущее поведение, прежде чем трогать структуру; выстраивать ходы так, чтобы каждый был маленьким, обратимым и зелёным; конкретно выполнять ключевые ходы на TypeScript — выделение метода/класса, введение объектов-значений, замену switch по типу полиморфизмом, инверсию зависимости хранилища за портом — по одному коммиту на ход; и объяснить, почему вшивание правки поведения в коммит рефакторинга уничтожает твою способность изолировать регрессию.
Сначала сеть: характеризационные тесты фиксируют, что модуль делает сегодня, ещё до того как ты поймёшь почему. Нетестированный легаси не рефакторят, читая его пристальнее. Его фиксируют. Характеризационный тест утверждает текущее наблюдаемое поведение модуля — не то поведение, которое ты бы хотел, — так что любой последующий структурный ход, меняющий вывод, мгновенно делает тест красным.
// characterize.test.ts — написан ДО любого структурного изменения.
// Мы пока не доверяем округлению или арифметике комиссии; мы фиксируем то, что код ДЕЛАЕТ.
test("card order total with tax and fee", () => {
const out = processOrder({ items: [{ price: 1000, qty: 2 }], paymentType: "card" });
expect(out.total).toBe(2070); // 2000 + 5% налога + 2% комиссии карты, как есть
expect(out.currency).toBe("USD");
});
test("invoice order skips the card fee", () => {
const out = processOrder({ items: [{ price: 999, qty: 3 }], paymentType: "invoice" });
expect(out.total).toBe(3147); // что бы он сейчас ни считал — зафиксируй это
});Число 2070 может кодировать баг. И это нормально — ты сейчас его не чинишь, ты его фиксируешь. Характеризационный тест, закрепляющий неверное значение, всё равно делает свою работу: он гарантирует, что твой рефакторинг сохраняет поведение. Баг почини позже, отдельным коммитом, когда структура станет достаточно чистой, чтобы чинить безопасно. Ни одной структурной правки не происходит, пока сеть не зелёная.
Выделяй под зелёным, по одному ходу на коммит — и пиши сообщение, которое говорит «refactor». Когда сеть на месте, первые ходы — дешёвые механические из каталога. Вытащи переплетённые блоки processOrder в именованные методы, затем подними связный кластер в класс. Каждый — свой коммит; набор зелёный до и после каждого.
// commit "refactor: extract calcTax / calcFee" — поведение идентично
function calcTax(subtotal: number): number { return Math.round(subtotal * 0.05); }
function calcFee(subtotal: number, paymentType: string): number {
return paymentType === "card" ? Math.round(subtotal * 0.02) : 0;
}
// commit "refactor: extract OrderProcessor class" — то же тело, новый дом
class OrderProcessor {
process(order: OrderInput): OrderResult {
const subtotal = order.items.reduce((s, i) => s + i.price * i.qty, 0);
const tax = calcTax(subtotal);
const fee = calcFee(subtotal, order.paymentType);
return { total: subtotal + tax + fee, currency: "USD" };
}
}Ничего хитрого здесь нет, и в этом весь смысл. Это сохраняющие поведение ходы; зелёная сеть доказывает это после каждого. Если тест краснеет, ты откатил один маленький коммит, а не неделю работы — радиус поражения любой ошибки ровно один ход.
Введи объекты-значения, чтобы убрать одержимость примитивами — поведение всё ещё не меняется. Модуль передаёт сырые number для денег и сырые string для id; это одержимость примитивами, и именно поэтому логика округления и валюты протекла повсюду. Заверни их. Ход всё ещё рефакторинг: Money обязан вычислять те же числа, что уже зафиксировала сеть.
// commit "refactor: introduce Money value object"
class Money {
private constructor(readonly cents: number, readonly currency: "USD") {}
static usd(cents: number): Money { return new Money(Math.round(cents), "USD"); }
add(o: Money): Money { return Money.usd(this.cents + o.cents); }
percent(p: number): Money { return Money.usd(Math.round(this.cents * p)); }
}
// commit "refactor: introduce OrderId" — типизированный id, больше никаких голых string
class OrderId { private constructor(readonly value: string) {}
static of(v: string): OrderId { return new OrderId(v); } }Поскольку Money.usd округляет ровно там же, где это делал старый код, зафиксированное 2070 по-прежнему держится — тест остаётся зелёным. Выигрыш в том, что округление теперь живёт в одном месте, а не выводится заново на каждом месте вызова, — именно это удешевляет следующее изменение. Принципиально, что ты не «починил» округление, раз уж был там; если округление неверно, это отдельный, более поздний, меняющий поведение коммит.
Замени switch по типу платежа полиморфизмом, затем инвертируй зависимость хранилища за портом. Остаются два структурных хода, и они — высокоценные. Сначала switch по paymentType, расползающийся по модулю, становится маленькой иерархией стратегий — Open/Closed: новый тип платежа — это новый класс, а не правка switch в пяти файлах.
// commit "refactor: replace paymentType switch with PaymentMethod polymorphism"
interface PaymentMethod { fee(subtotal: Money): Money; }
class Card implements PaymentMethod { fee(s: Money) { return s.percent(0.02); } }
class Invoice implements PaymentMethod { fee(s: Money) { return Money.usd(0); } }
const methods: Record<string, PaymentMethod> = { card: new Card(), invoice: new Invoice() };Затем инвертируй зависимость хранилища. OrderProcessor сейчас import-ит конкретный db; это стрелка в неправильную сторону. Определи порт OrderRepository, которым владеет процессор, и сделай базу адаптером, его реализующим, — высокоуровневая политика перестаёт зависеть от низкоуровневой детали.
// commit "refactor: introduce OrderRepository port"
interface OrderRepository { save(id: OrderId, result: OrderResult): Promise<void>; }
// адаптер живёт на краю; процессор зависит от интерфейса, а не от `db`
class PostgresOrderRepository implements OrderRepository { /* старый SQL, перенесён */ }Оба хода сохраняют поведение: те же комиссии, те же сохранённые строки, та же зелёная сеть. Они меняют лишь кто от кого зависит.
Один ход в изоляции: switch по платежу становится полиморфизмом — без изменения ни одной комиссии. Раньше тип протекал по модулю как stringly-typed switch, о котором процессору приходилось знать:
// до — Open/Closed нарушен: новый тип правит этот switch в каждом месте, где он встречается
function feeFor(subtotal: number, paymentType: string): number {
switch (paymentType) {
case "card": return Math.round(subtotal * 0.02);
case "invoice": return 0;
case "wire": return 1500; // фикс $15
default: throw new Error("unknown payment type");
}
}После — каждое правило владеет своим классом, а процессор ищет метод по ключу:
// после — commit "refactor: replace paymentType switch with polymorphism"
interface PaymentMethod { fee(subtotal: Money): Money; }
class Card implements PaymentMethod { fee(s: Money) { return s.percent(0.02); } }
class Invoice implements PaymentMethod { fee(s: Money) { return Money.usd(0); } }
class Wire implements PaymentMethod { fee(_: Money) { return Money.usd(1500); } }
const methods: Record<string, PaymentMethod> = {
card: new Card(), invoice: new Invoice(), wire: new Wire(),
};
function methodFor(type: string): PaymentMethod {
const m = methods[type];
if (!m) throw new Error("unknown payment type"); // ТА ЖЕ ошибка, сохранена намеренно
return m;
}Комиссии побайтово идентичны: карта по-прежнему 2%, invoice по-прежнему 0, wire по-прежнему фикс $15, сообщение об ошибке неизвестного типа без изменений — поэтому характеризационная сеть остаётся зелёной, и это выкатывается как чистый рефакторинг. Отдача — в следующем изменении: добавить crypto — это новый класс Crypto плюс одна запись в map, с нулём правок процессора. Сопротивляться надо соблазнительному «два в одном» — «раз уж я здесь, комиссия wire должна быть 1%». Это правка поведения; она идёт в свой коммит, сверху, после того как этот станет зелёным и закоммиченным, чтобы, если регрессия комиссии всплывёт позже, git bisect мог указать ровно на одну причину.
▸Почему это работает
Зачем накрывать модуль сетью до ходов, которые ты и так знаешь как верные? Потому что «я знаю, что это сохраняет поведение» — ровно та уверенность, что выкатывает регрессии. Выделение выглядит тривиально — пока у старого processOrder не оказывается тонкого off-by-one в округлении комиссии, который твоё «более чистое» выделение молча исправляет, меняя итог по invoice в проде. С характеризационным тестом, фиксирующим 3147, твоё чистое выделение краснеет в тот миг, когда расходится, и ты видишь непреднамеренную правку поведения прежде, чем она уйдёт с твоей машины. Ценность сети наивысшая именно на тех ходах, в которых ты увереннее всего, потому что их ты сделаешь быстрее всего и проверишь меньше всего. Нет сети — нет рефакторинга, только переписывание на авось.
▸Частая ошибка
Два режима провала, которые губят такой капстоун. Переписывание «большим взрывом»: ты открываешь ветку, меняешь всё сразу, и через три дня у тебя прекрасный новый модуль, который не проходит старые тесты и который никто не может отревьюить или откатить — ты заменил известную-несовершенную систему неизвестной, и единственный путь вперёд — ещё больше «большого взрыва». Контрабандная правка поведения: посреди рефакторинга ты «чинишь» округление или подкручиваешь комиссию wire внутри коммита выделения. Работает, набор зелёный, ты пушишь. Через недели всплывает финансовая регрессия, git bisect приземляется на refactor: extract OrderProcessor, и теперь этот коммит содержит и структурный ход, и правку поведения — bisect не может сказать какой именно, а гарантия сохранения поведения, позволявшая ревьюерам бегло его пробежать, исчезла. Правило, предотвращающее оба: коммит — это либо рефакторинг (структура меняется, поведение зафиксировано), либо правка поведения (поведение меняется, структура зафиксирована) — никогда оба, и никогда всё сразу.
Посреди выделения класса OrderProcessor ты замечаешь, что округление налога ошибается на цент, и чинишь это в том же коммите 'refactor: extract OrderProcessor'. Набор зелёный. Почему senior-ревьюер заблокирует это?
Инкрементальный рефакторинг — это последовательность, а не событие. Ты сначала накрываешь модуль характеризационными тестами — фиксируя текущее поведение, со всеми багами, — чтобы любой структурный ход, меняющий вывод, краснел на твоей машине, а не в проде. Затем делаешь по одному маленькому обратимому ходу на коммит под зелёным: выделяешь методы и класс, вводишь Money и типизированный OrderId, чтобы убрать одержимость примитивами, заменяешь switch по paymentType полиморфизмом, чтобы новые типы были новыми классами, а не правками switch, и инвертируешь зависимость хранилища за портом OrderRepository, чтобы высокоуровневая политика перестала зависеть от низкоуровневой базы. Каждый из этих ходов сохраняет поведение, доказан зелёным до и после, закоммичен с сообщением refactor:, которое ревьюер может бегло пробежать. Два способа всё испортить — это переписывание «большим взрывом», которое меняет известную систему на неоткатываемую неизвестную, и контрабандная правка поведения, которая сплавляет исправление с коммитом рефакторинга и уничтожает git bisect. Держи рефакторинг и поведение строго раздельно, двигайся маленькими шагами, коммить часто — и запутанный модуль из части 1 становится чистым кодом, который ни разу не был сломан по пути туда.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.