Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Рефакторинг і зв'язність

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

15 питань

Code smell («запах коду») - ознака, що в коді, ймовірно, є проблема дизайну. Сам по собі не баг - код працює, - але його важко читати, змінювати й тестувати.

Найпоширеніші:

  • Довгий метод. Метод на 100+ рядків, який робить кілька речей. Лікується виділенням методів з іменами, що пояснюють намір.
  • Великий клас («God object»): тисячі рядків, десятки залежностей. Розділити за відповідальностями.
  • Дублювання: однакова логіка в кількох місцях - зміну доведеться робити скрізь, і одне місце точно забудуть.
  • Довгий список параметрів: createUser($name, $email, $phone, $role, $team, $sendEmail, $isAdmin). Об'єкт-параметр (DTO) чи кілька методів.
  • Прапорець-параметр: render(true) - незрозуміло без заглядання всередину. Два методи чи іменований аргумент.
  • Одержимість примітивами: гроші як float, email як string, статус як магічний рядок. Value objects і enum.
  • Заздрість до функцій (feature envy): метод постійно звертається до даних іншого класу - можливо, він має жити там.
  • Розгалуження за типом: однаковий switch ($type) у кількох місцях - кандидат на поліморфізм.
  • Магічні числа: if ($status === 3), sleep(86400).
  • Коментар, що пояснює заплутаний код, - часто краще переписати код так, щоб пояснення стало зайвим.

Як з ними працювати: запах - привід придивитися, а не вимога негайно переписувати. Рефакторять тоді, коли код доводиться змінювати: «правило бойскаута» - залишити код трохи кращим, ніж знайшли.

Частину запахів знаходять інструменти: PHPStan/Larastan, PHP Mess Detector, метрики складності в IDE.

Докладніше в документації: Code smells

Рефакторинг - зміна структури коду без зміни поведінки. Без тестів неможливо переконатися, що поведінка справді не змінилася. Тому перший крок - створити страховку.

1. Тести-характеристики (characterization tests). Не «як має працювати», а «як працює зараз», включно з дивацтвами. Викликати код з різними входами, записати результати й зафіксувати їх у тестах.

it('рахує знижку так само, як до рефакторингу', function (array $order, int $expected) {
    expect(DiscountCalculator::calculate($order))->toBe($expected);
})->with([
    [['total' => 1000, 'vip' => false], 0],
    [['total' => 1000, 'vip' => true], 100],
    [['total' => 5000, 'vip' => false], 250],
]);

2. Тести на верхньому рівні, якщо код важко тестувати окремо: feature-тест HTTP-ендпоінту, що перевіряє відповідь і зміни в базі. Грубо, але надійно.

3. Маленькі кроки. Перейменувати змінну, виділити метод, перенести метод - по одній зміні, з прогоном тестів після кожної. Невдалий крок легко відкотити.

4. Автоматичні рефакторинги IDE та Rector. «Rename», «Extract Method», «Move» у PhpStorm змінюють усі посилання коректніше, ніж ручні правки. Rector автоматизує масові зміни.

5. Окремо рефакторинг, окремо нові функції. Не змішувати в одному коміті: якщо щось зламалося, видно, що саме.

6. Статичний аналіз (PHPStan/Larastan) ловить зламані виклики й типи, яких тести можуть не покрити.

Чого не робити: «великого переписування» без тестів - найризикованішого сценарію. Краще поступово: нова структура поруч зі старою, поступове перенесення (патерн «Strangler Fig»), старий код видаляється, коли на нього вже ніхто не посилається.

Докладніше в документації: Як рефакторити

Рефакторинг - зміна внутрішньої структури коду без зміни його поведінки. «Виділити метод» (Extract Method / Extract Function) - найчастіший з них: фрагмент коду переноситься в окремий метод з назвою, що пояснює навіщо він.

До:

public function checkout(Cart $cart, User $user): Order
{
    // перевіряємо, що всі товари в наявності
    foreach ($cart->items as $item) {
        if ($item->product->stock < $item->qty) {
            throw new OutOfStock($item->product);
        }
    }

    // рахуємо суму зі знижкою
    $total = $cart->items->sum(fn ($i) => $i->product->price * $i->qty);
    if ($user->isVip()) {
        $total = (int) round($total * 0.9);
    }

    return Order::create(['user_id' => $user->id, 'total' => $total]);
}

Після:

public function checkout(Cart $cart, User $user): Order
{
    $this->ensureInStock($cart);

    return Order::create(['user_id' => $user->id, 'total' => $this->totalFor($cart, $user)]);
}

private function ensureInStock(Cart $cart): void { /* ... */ }
private function totalFor(Cart $cart, User $user): int { /* ... */ }

Коли виділяти:

  • коментар пояснює, що робить блок коду - назва методу може замінити коментар;
  • метод не вміщується на екран чи має кілька рівнів вкладеності;
  • той самий фрагмент повторюється в кількох місцях;
  • змішано рівні абстракції: бізнес-кроки поруч із деталями (цикли, форматування, SQL);
  • блок потрібно тестувати окремо.

Як робити безпечно:

  1. переконатися, що є тести (або написати їх для поточної поведінки);
  2. виділити метод - автоматично в IDE (PhpStorm: Extract Method), щоб не помилитися з параметрами й поверненим значенням;
  3. дати назву за наміром («чи в наявності», «сума зі знижкою»), а не за реалізацією («цикл по товарах»);
  4. запустити тести.

Коли виділення не допомагає: якщо виділеному фрагменту потрібно передати шість параметрів і повернути три значення - це ознака, що код хоче стати окремим класом (Extract Class), а не методом.

Зворотний рефакторинг - Inline Method - коли метод-обгортка нічого не пояснює і лише ускладнює читання.

Докладніше в документації: Каталог рефакторингів: Extract Function

Код читають набагато частіше, ніж пишуть. Перейменування - найдешевший рефакторинг з найбільшим ефектом на читабельність: назва, що точно описує сутність, позбавляє потреби читати реалізацію.

Ознаки поганих назв:

$data = $this->get($id);         // які дані? що «отримати»?
$flag = true;                    // який прапорець?
$tmp, $res, $arr, $obj;          // назви за типом, а не за змістом
function process($x) {}          // «обробити» - що саме?
$d = 30;                         // 30 чого? днів? хвилин?

Як краще:

$invoice = $this->findInvoice($invoiceId);
$isRegisteredForDiscounts = true;
function sendOverdueReminders(Collection $invoices): void {}
$gracePeriodInDays = 30;

Правила, що допомагають:

  • за наміром, а не за реалізацією: activeSubscribers(), а не getUsersWhereStatusIsOneAndPaidIsTrue();
  • булеві значення - питанням: isPaid, hasAccess, canPublish, shouldRetry;
  • методи - дієсловом: calculateTotal(), sendInvoice(); класи й змінні - іменником;
  • одиниці виміру в назві, якщо тип їх не передає: timeoutSeconds, priceCents, sizeInBytes;
  • мова предметної області: якщо бізнес каже «замовлення», «відвантаження», «повернення» - у коді Order, Shipment, Refund, а не Item, Process, Operation;
  • послідовність: не fetch/get/retrieve/load для того самого типу дії в різних місцях;
  • довжина пропорційна області видимості: $i у короткому циклі - нормально; змінна, що живе в усьому класі, - описова назва.

Перейменування безпечне з інструментами: IDE (Rename у PhpStorm) змінює всі використання, включно з рядковими посиланнями й документацією; статичний аналіз (PHPStan) і тести ловлять пропущене.

Пастки:

  • публічні API (маршрути, поля JSON, назви колонок, події) - перейменування ламає клієнтів і потребує міграції чи періоду сумісності;
  • рядкові посилання (config('services.old_name'), назви в Blade, $model->getAttribute('field')) IDE може не знайти;
  • магічні методи й динамічні виклики - перевіряти тестами.

Корисне правило: якщо назву важко придумати, часто проблема не в назві, а в коді - метод чи клас робить забагато різного.

Докладніше в документації: Каталог рефакторингів: Rename Variable

DRY (Don't Repeat Yourself) - кожне знання в системі має мати одне джерело. Дублювання шкодить, бо зміну доводиться вносити в кількох місцях, і рано чи пізно одне з них пропускають.

Але не всяке схоже код - дублювання знання. Два фрагменти можуть виглядати однаково випадково, а змінюватися з різних причин:

// валідація реєстрації
'name' => ['required', 'string', 'max:255'],

// валідація назви товару
'name' => ['required', 'string', 'max:255'],

Це не одне знання: правила для імені користувача й назви товару завтра розійдуться. Спільна функція nameRules() їх штучно зв'яже.

«Неправильна абстракція» (Сенді Метц) - типовий сценарій:

  1. програміст бачить повтор і виносить код у спільну функцію чи базовий клас;
  2. з'являється новий випадок, що «майже підходить» - додають параметр;
  3. ще один випадок - ще параметр, умова всередині;
  4. через рік абстракція має прапорці $isAdmin, $skipValidation, $legacyMode, і ніхто не розуміє, як вона працює, а змінювати страшно, бо вона використовується скрізь.

Висновок Метц: «Дублювання значно дешевше за неправильну абстракцію». Якщо абстракція обросла параметрами й умовами, вигідніше повернути код назад (вбудувати в місця використання) і заново подивитися, що справді спільне.

Практичні правила:

  • правило трьох: перший раз - пишемо, другий - терпимо дублювання, третій - виносимо абстракцію. До третього разу видно, що справді спільне;
  • дублюється знання чи текст? Якщо обидва місця змінюватимуться разом з тієї самої причини - це знання, його варто об'єднати;
  • ознаки поганої абстракції: булеві параметри, що вмикають гілки; if ($type === ...) усередині «спільного» коду; коментарі «для X не викликати»;
  • дублювання в тестах часто корисне: тест, який читається сам по собі без переходів у допоміжні функції, - кращий тест.

WET («Write Everything Twice») і AHA («Avoid Hasty Abstractions») - жартівливі назви того самого принципу: не поспішати з абстракціями, доки не стане зрозуміло, яка вона має бути.

Докладніше в документації: Сенді Метц: The Wrong Abstraction

Зв'язність (coupling) - наскільки модулі залежать один від одного. Чим вона сильніша, тим більше зміна в одному модулі тягне зміни в інших. До неї прагнуть низької.

Зчеплення (cohesion) - наскільки елементи всередині модуля пов'язані спільною метою. До нього прагнуть високого: усе в класі працює на одну задачу.

Девіз: low coupling, high cohesion.

Ознаки сильної зв'язності:

  • клас знає внутрішню будову іншого: $order->customer->address->city->region->name (порушення «закону Деметри»);
  • спільний змінюваний глобальний стан;
  • зміна формату даних в одному модулі ламає кілька інших;
  • неможливо протестувати клас, не піднявши половину застосунку;
  • модулі імпортують один одного по колу.

Ознаки слабкого зчеплення:

  • клас Utils/Helper з не пов'язаними функціями;
  • методи класу використовують різні, непересічні набори полів;
  • щоб зробити одну зміну, доводиться правити п'ять різних місць - логіку однієї задачі розмазано.

Як зменшують зв'язність:

  • залежати від інтерфейсів, а не конкретних класів;
  • спілкуватися через події, коли модулю-джерелу не потрібен результат;
  • передавати дані (DTO), а не цілі об'єкти з усіма зв'язками;
  • приховувати внутрішню будову: $order->shippingRegion() замість ланцюжка.

Баланс: нульової зв'язності не буває - модулі мусять взаємодіяти. Мета - щоб залежності були явними, спрямованими в один бік (від деталей до бізнес-логіки) і стабільними. Надмірне розщеплення (абстракції й події скрізь) теж має ціну: логіку однієї дії важко простежити.

Докладніше в документації: Coupling

Проблема: однаковий switch за типом повторюється в багатьох місцях. Новий тип - зміни в кожному з них, і одне точно забудуть.

function fee(Payment $p): int
{
    return match ($p->method) {
        'card' => (int) ($p->amount * 0.025),
        'bank_transfer' => 500,
        'cash' => 0,
    };
}
// і ще схожі match для label(), isRefundable(), icon()...

Варіант 1 - enum з методами (PHP 8.1+). Добре, коли поведінка невелика й залежить лише від типу:

enum PaymentMethod: string
{
    case Card = 'card';
    case BankTransfer = 'bank_transfer';
    case Cash = 'cash';

    public function fee(int $amount): int
    {
        return match ($this) {
            self::Card => (int) ($amount * 0.025),
            self::BankTransfer => 500,
            self::Cash => 0,
        };
    }
}

Знання про тип зібране в одному місці, а match без default змусить обробити новий випадок - інакше UnhandledMatchError, а статичний аналізатор попередить ще раніше.

Варіант 2 - поліморфізм (класи за спільним інтерфейсом). Коли в кожного типу своя складна логіка чи залежності:

interface PaymentProcessor
{
    public function fee(int $amount): int;
    public function refund(Payment $payment): void;
}

final class CardProcessor implements PaymentProcessor { /* ... */ }

Вибір реалізації - один раз, у фабриці чи контейнері. Далі код викликає методи без розгалужень.

Коли switch нормальний: він трапляється в одному місці (наприклад, у фабриці, що створює потрібний об'єкт), варіантів мало і їхній список стабільний. Поліморфізм заради одного if - зайве ускладнення.

Докладніше в документації: Replace Conditional with Polymorphism

Довгий список параметрів - code smell: виклик важко читати, легко переплутати порядок аргументів однакового типу, а кожна зміна сигнатури зачіпає всі місця виклику.

$this->createInvoice($customerId, $amount, 'UAH', $dueDate, true, false, null, 'uk');
// що означають true, false і null?

Варіанти лікування:

1. Об'єкт параметрів (Introduce Parameter Object) - параметри, що завжди йдуть разом, об'єднуються в клас:

final readonly class InvoiceData
{
    public function __construct(
        public int $customerId,
        public Money $amount,
        public DateTimeImmutable $dueDate,
        public bool $sendByEmail = true,
        public bool $isDraft = false,
        public ?string $note = null,
        public string $locale = 'uk',
    ) {}
}

$this->createInvoice(new InvoiceData(
    customerId: $customer->id,
    amount: Money::uah(125050),
    dueDate: now()->addDays(14)->toImmutable(),
));

Іменовані аргументи PHP 8 роблять створення читабельним, значення за замовчуванням прибирають шум, а readonly гарантує незмінність. Часто в такий об'єкт перетікає й логіка (валідація, обчислення), - він стає об'єктом-значенням, а не просто контейнером.

2. Іменовані аргументи без нового класу - для рідкісних викликів з кількома необов'язковими параметрами:

$this->createInvoice($customerId, $amount, dueDate: $date, isDraft: true);

3. Передавати весь об'єкт замість його частин (Preserve Whole Object): calculateShipping($order) замість calculateShipping($order->weight, $order->country, $order->city, $order->express).

4. Прибрати булеві прапорці (Remove Flag Argument): createInvoice(..., isDraft: true) часто означає два різні методи - createDraftInvoice() і issueInvoice().

5. Перенести параметри в конструктор: залежності (сервіси, конфігурація), що передаються в кожен виклик, - у конструктор класу через впровадження залежностей.

Ознака, що проблема глибша: метод приймає багато параметрів, бо робить забагато. Тоді спершу розділити метод (Extract Method/Class), а вже потім дивитися на параметри.

У Laravel-проєктах такі об'єкти часто будують з Form Request ($request->toDto()) чи використовують spatie/laravel-data, що поєднує DTO з валідацією й перетворенням з/у масив.

Докладніше в документації: Каталог рефакторингів: Introduce Parameter Object

Одержимість примітивами (Primitive Obsession) - доменні поняття представлені «голими» рядками, числами й масивами: email - string, гроші - float, телефон - string, діапазон дат - два окремі DateTime.

function transfer(string $fromIban, string $toIban, float $amount, string $currency): void

Проблеми:

  • валідація розкидана - кожне місце, що приймає IBAN, мусить перевіряти формат (і одне колись забуде);
  • легко переплутати аргументи однакового типу ($toIban, $fromIban) - компілятор не допоможе;
  • неявні правила (гроші не можна додавати в різних валютах, сума не від'ємна) повторюються чи пропускаються;
  • поведінка розповзається по допоміжних функціях (formatPhone(), normalizeEmail()).

Ліки - об'єкт-значення (Value Object):

final readonly class Money
{
    private function __construct(public int $cents, public Currency $currency) {}

    public static function of(int $cents, Currency $currency): self
    {
        if ($cents < 0) {
            throw new InvalidArgumentException('Сума не може бути від\'ємною');
        }
        return new self($cents, $currency);
    }

    public function add(self $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new CurrencyMismatch();
        }
        return new self($this->cents + $other->cents, $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->cents === $other->cents && $this->currency === $other->currency;
    }
}

function transfer(Iban $from, Iban $to, Money $amount): void

Властивості об'єкта-значення:

  • валідний за побудовою: створити некоректний Iban неможливо - перевірка в одному місці;
  • незмінний (readonly): операції повертають новий об'єкт;
  • рівність за значенням, а не за посиланням;
  • поведінка поруч з даними: $money->add(), $email->domain(), $phone->formatted().

У Laravel: власні касти (CastsAttributes) перетворюють колонки бази на об'єкти-значення й назад:

protected function casts(): array
{
    return ['price' => MoneyCast::class, 'email' => EmailCast::class];
}

Енуми PHP - теж ліки від одержимості примітивами для фіксованих наборів значень (статуси, ролі, валюти).

Де зупинитися: не кожен рядок потребує класу. Об'єкт-значення окупається, коли з поняттям пов'язані правила (валідація, операції, форматування) і воно передається між шарами застосунку.

Докладніше в документації: Refactoring.Guru: Primitive Obsession

Повне переписування з нуля («великий вибух») - один з найризикованіших проєктів: старою системою треба користуватися, поки пишеться нова; нова довго не дає жодної цінності; функції старої системи, про які всі забули, виявляються в останній момент; а дата перемикання постійно зсувається.

Strangler Fig (фікус-«душитель», що поступово обвиває й замінює дерево) - поступова заміна: нова система росте навколо старої, забираючи функціональність частинами, доки стара не стане непотрібною.

Як це виглядає:

  1. фасад/маршрутизатор перед системами - проксі, балансувальник, маршрути застосунку - вирішує, яка система обробляє запит;
  2. обрати шматок - бажано цінний і відносно відокремлений (наприклад, каталог чи звіти);
  3. реалізувати його в новій системі й перенаправити відповідні запити;
  4. повторювати, доки старої системи не лишиться;
  5. вимкнути стару систему.
            ┌──────────────► нова система (каталог, кошик)
запити ──► маршрутизатор
            └──────────────► стара система (решта)

Варіант у межах одного застосунку (наприклад, міграція з самописного PHP на Laravel): Laravel приймає всі запити, нові маршрути обробляє сам, а для невідомих - передає старому коду (fallback-маршрут, що підключає старий фронт-контролер). Модуль за модулем код переїжджає в Laravel.

Що важливо:

  • спільні дані - найскладніше. Варіанти: обидві системи працюють з однією базою (простіше, але зв'язує їх), синхронізація подіями, поступова міграція таблиць з шаром сумісності;
  • кожен крок дає цінність і може йти в продакшен - немає «року без релізів»;
  • можливість відкату: якщо нова частина має проблеми, маршрутизатор повертає трафік на стару;
  • порівняння результатів: для критичних частин - запуск обох реалізацій паралельно й порівняння відповідей (shadow traffic) перед перемиканням;
  • доводити до кінця: найчастіша проблема - «тимчасово» дві системи на роки. Потрібен план вимкнення старої.

Пов'язані прийоми: Branch by Abstraction - та сама ідея всередині коду (абстракція перед старою реалізацією, нова реалізація за прапорцем), feature flags для поступового перемикання.

Докладніше в документації: Мартін Фаулер: Strangler Fig Application

Технічний борг - метафора Ворда Каннінгема: швидке, неідеальне рішення - як позика. Ви отримуєте швидкість зараз, але платите відсотки: кожна наступна зміна в цьому коді коштує дорожче. Якщо борг не гасити, відсотки з'їдають усю швидкість команди.

Борг буває різним (квадрант Мартіна Фаулера):

  • Свідомий і обачний: «випускаємо зараз, бо дедлайн, і знаємо, що треба буде переробити». Нормальне бізнес-рішення.
  • Свідомий і необачний: «на дизайн немає часу» - постійно.
  • Несвідомий і обачний: «тепер ми розуміємо, як це треба було зробити» - природний наслідок навчання.
  • Несвідомий і необачний: команда просто не знає, як краще.

Як керувати:

  • Робити видимим. Задачі в трекері з описом наслідків, а не «відрефакторити X»: «додавання нового способу оплати займає 3 дні замість пів дня через Y».
  • Говорити мовою бізнесу. «Рефакторинг» не продається; «зменшимо кількість інцидентів у платежах» і «пришвидшимо релізи нових інтеграцій» - продаються.
  • Гасити постійно, а не «колись». Частина ємності кожного спринту, правило бойскаута при роботі в коді, а не окремий «спринт рефакторингу» раз на рік.
  • Пріоритезувати за відсотками. Найдорожчий борг - у коді, який часто змінюється. Заплутаний модуль, якого ніхто не чіпав три роки, може почекати. Аналіз «hotspots» (частота змін × складність файлів з історії Git) показує, де борг коштує найбільше.
  • Не накопичувати нового непомітно: код-рев'ю, статичний аналіз у CI, тести як умова злиття.

Найнебезпечніше - коли борг не усвідомлюють: команда сповільнюється, оцінки ростуть, баги множаться, а причину шукають у людях, а не в коді.

Докладніше в документації: Technical Debt

Bounded context (обмежений контекст) - поняття з DDD: межа, всередині якої терміни й модель мають одне чітке значення. У великій системі одне слово означає різне в різних частинах.

«Товар» у каталозі - опис, фото, характеристики. На складі - залишки, комірка, вага. У бухгалтерії - собівартість і податкова група. Спроба зробити одну модель Product для всіх трьох дає клас на сотні полів, який змінюють усі команди й ніхто не розуміє повністю.

Ідея: кожен контекст має свою модель того, що йому потрібно. Контексти пов'язані через ідентифікатори й явні контракти, а не через спільні таблиці й класи.

Як ділити моноліт на модулі (модульний моноліт):

  1. Знайти межі за мовою й бізнес-процесами: де змінюється значення термінів, які частини змінюються разом, які команди відповідають за що.
  2. Модуль = каталог з публічним API: app/Billing, app/Catalog, app/Shipping. Інші модулі звертаються лише до публічного інтерфейсу (сервіси, події), а не до внутрішніх моделей і таблиць.
  3. Зв'язок між модулями - через події (OrderPlaced → Billing створює рахунок) або явні виклики фасаду модуля.
  4. Свої таблиці для модуля. Запити JOIN через межу модуля - сигнал, що межа неправильна або потрібна копія даних.
  5. Автоматична перевірка меж: архітектурні тести (Pest arch(), Deptrac) забороняють Catalog імпортувати внутрішні класи Billing.

Чому модульний моноліт, а не одразу мікросервіси: межі спершу майже завжди визначають неточно. Перенести код між модулями - рефакторинг; між мікросервісами - міграція даних, нові API й розподілені транзакції. Добре відокремлений модуль легко винести в сервіс пізніше, коли для цього з'явиться справжня причина: незалежне масштабування, окрема команда, інший цикл релізів.

Докладніше в документації: Bounded Context

Великі зміни (заміна ORM-шару, платіжного провайдера, пошукового рушія, переписування ключового модуля) в окремій гілці Git на тижні - класична пастка: гілка відстає від main, злиття стає болісним, а результат не перевірений у продакшені до самого кінця.

Branch by Abstraction - «гілкування» всередині коду замість гілки в Git:

  1. ввести абстракцію перед частиною, що змінюється, і перевести на неї всіх клієнтів - поки з єдиною реалізацією, старою:
interface SearchEngine
{
    public function search(SearchQuery $query): SearchResults;
}

final class DatabaseSearch implements SearchEngine { /* наявний код */ }
  1. поступово писати нову реалізацію поруч - у main, маленькими комітами, з тестами:
final class MeilisearchSearch implements SearchEngine { /* нова */ }
  1. перемикати клієнтів на нову реалізацію - через конфігурацію чи feature flag, частинами:
$this->app->bind(SearchEngine::class, fn () => Feature::active('new-search')
    ? app(MeilisearchSearch::class)
    : app(DatabaseSearch::class));
  1. видалити стару реалізацію, а за потреби - і саму абстракцію, якщо вона більше не потрібна.

Переваги:

  • код завжди в робочому стані і постійно інтегрується - немає великого злиття;
  • зміни потрапляють у продакшен поступово і можуть бути вимкнені миттєво;
  • паралельна робота: інша команда продовжує розробку, не чекаючи на завершення рефакторингу;
  • перевірка на реальних даних: нову реалізацію можна ввімкнути для частини користувачів чи запускати «в тіні» й порівнювати результати.

Інструменти:

  • feature flags - у Laravel - Pennant (Feature::active()), з поступовим ввімкненням для відсотка користувачів;
  • контейнер - перемикання реалізацій без змін у клієнтах;
  • тести на контракт абстракції - однаковий набір тестів для старої й нової реалізацій.

Що може піти не так:

  • абстракція, «зліплена» зі старої реалізації, - нова не вкладається в неї. Абстракцію варто проєктувати від потреб клієнтів, а не від методів старого класу;
  • прапорці, які ніхто не прибирає - після завершення переходу їх треба видалити разом зі старим кодом;
  • дані: якщо реалізації по-різному зберігають дані, потрібна міграція чи подвійний запис на перехідний період.

Пов'язане: Strangler Fig - та сама ідея на рівні систем і маршрутизації запитів, Branch by Abstraction - на рівні коду всередині застосунку.

Докладніше в документації: Мартін Фаулер: Branch By Abstraction

Ручний рефакторинг великої кодової бази повільний і схильний до помилок. Інструменти беруть на себе механічну частину.

Rector - автоматичні перетворення коду за правилами:

// rector.php
return RectorConfig::configure()
    ->withPaths([__DIR__.'/app', __DIR__.'/tests'])
    ->withPhpSets()                       // сучасний синтаксис для версії PHP з composer.json
    ->withPreparedSets(deadCode: true, codeQuality: true, typeDeclarations: true);
vendor/bin/rector process --dry-run   # показати зміни
vendor/bin/rector process             # застосувати

Що він робить: оновлює синтаксис (властивості конструктора, match, readonly, енуми), додає типи, прибирає мертвий код, переводить між версіями фреймворків (набори для Laravel - пакет driftingly/rector-laravel). Кожне правило - детерміноване перетворення AST, тож результат передбачуваний на тисячах файлів.

PHPStan / Larastan - статичний аналіз: знаходить помилки типів, виклики неіснуючих методів, неправильні аргументи без запуску коду. Larastan додає розуміння Laravel (магія Eloquent, фасади, контейнер).

Baseline - як впровадити аналіз у старий проєкт:

vendor/bin/phpstan analyse --generate-baseline

Усі наявні помилки записуються у phpstan-baseline.neon і ігноруються. Новий код перевіряється за повними правилами - нові помилки не додаються, а базову лінію поступово зменшують. Те саме вміють Psalm і Rector (пропуск окремих правил чи шляхів).

Як це вбудувати в процес:

  • CI: PHPStan, Pint (стиль), Rector в режимі --dry-run і тести на кожен pull request - злиття блокується при помилках;
  • рівень аналізу піднімати поступово: рівень PHPStan 0 → 5 → 8 → max, з baseline на кожному кроці;
  • рефакторинг окремими комітами: механічні зміни Rector не змішувати з бізнес-змінами - рев'ю тоді простіше;
  • тести перед масовими змінами - статичний аналіз не ловить зміну поведінки.

Чого інструменти не роблять: не вирішують, як розділити відповідальності, які абстракції потрібні, як назвати поняття. Вони прибирають механічну роботу, звільняючи час на архітектурні рішення.

Пастка baseline: він може перетворитися на «смітник», куди складають нові помилки, щоб пройти CI. Варто стежити, щоб кількість записів лише зменшувалася.

Докладніше в документації: Документація Rector

У будь-якому великому проєкті поганого коду більше, ніж часу на його виправлення. Рефакторити «все погане» неможливо - і не потрібно: складний модуль, який ніхто не змінює роками, нікому не заважає.

Аналіз гарячих точок (hotspots, ідея Адама Торнгілла «Код як місце злочину») поєднує два виміри:

  • складність коду - довжина, вкладеність, цикломатична складність файлу чи методу;
  • частота змін - скільки разів файл змінювали за останні місяці (з історії Git).

Гаряча точка = складний код, який часто змінюють. Саме там зосереджені витрати часу команди й помилки: кожна зміна складного файлу повільна й ризикована, і робиться вона часто.

# найчастіше змінювані файли за рік
git log --since="1 year ago" --name-only --format="" | sort | uniq -c | sort -rn | head -20

Перетин цього списку з найскладнішими файлами (за даними PHPStan, phpmetrics, PhpStorm) дає короткий список кандидатів.

Чому це краще за інтуїцію:

  • пріоритет за впливом: покращення файлу, який змінюють щотижня, окупається швидко;
  • аргумент для бізнесу: «80% змін за квартал зачіпали ці три файли, і в них 60% багів» - зрозуміліше, ніж «код поганий»;
  • виявляє приховані проблеми: часто гаряча точка - «божественний» клас (OrderService на 3000 рядків), через який проходить половина змін.

Додаткові сигнали з історії Git:

  • зв'язок змін (change coupling): файли, які майже завжди змінюються разом, - прихована залежність чи неправильно розділена відповідальність;
  • знання авторів: файли, які змінювала одна людина, - ризик для команди («bus factor»);
  • частота виправлень: коміти з «fix» у тих самих файлах.

Як діяти з гарячою точкою:

  • рефакторити поступово, разом з функціональними змінами - правило бойскаута: залишити файл трохи кращим після кожної зміни;
  • спершу тести для поведінки, яку змінюють найчастіше;
  • розділяти великий клас на менші за відповідальностями - щоб зміни розподілилися;
  • виміряти після: чи зменшилися складність і кількість змін, що зачіпають файл.

Інструменти: CodeScene (комерційний), git log з простими скриптами, phpmetrics, SonarQube - для оцінки складності.

Докладніше в документації: CodeScene: гарячі точки