Middle: питання на співбесіді з теми «Рефакторинг і зв'язність»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Зв'язність (coupling) - наскільки модулі залежать один від одного. Чим вона сильніша, тим більше зміна в одному модулі тягне зміни в інших. До неї прагнуть низької.
Зчеплення (cohesion) - наскільки елементи всередині модуля пов'язані спільною метою. До нього прагнуть високого: усе в класі працює на одну задачу.
Девіз: low coupling, high cohesion.
Ознаки сильної зв'язності:
- клас знає внутрішню будову іншого:
$order->customer->address->city->region->name(порушення «закону Деметри»); - спільний змінюваний глобальний стан;
- зміна формату даних в одному модулі ламає кілька інших;
- неможливо протестувати клас, не піднявши половину застосунку;
- модулі імпортують один одного по колу.
Ознаки слабкого зчеплення:
- клас
Utils/Helperз не пов'язаними функціями; - методи класу використовують різні, непересічні набори полів;
- щоб зробити одну зміну, доводиться правити п'ять різних місць - логіку однієї задачі розмазано.
Як зменшують зв'язність:
- залежати від інтерфейсів, а не конкретних класів;
- спілкуватися через події, коли модулю-джерелу не потрібен результат;
- передавати дані (DTO), а не цілі об'єкти з усіма зв'язками;
- приховувати внутрішню будову:
$order->shippingRegion()замість ланцюжка.
Баланс: нульової зв'язності не буває - модулі мусять взаємодіяти. Мета - щоб залежності були явними, спрямованими в один бік (від деталей до бізнес-логіки) і стабільними. Надмірне розщеплення (абстракції й події скрізь) теж має ціну: логіку однієї дії важко простежити.
Проблема: однаковий 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 (фікус-«душитель», що поступово обвиває й замінює дерево) - поступова заміна: нова система росте навколо старої, забираючи функціональність частинами, доки стара не стане непотрібною.
Як це виглядає:
- фасад/маршрутизатор перед системами - проксі, балансувальник, маршрути застосунку - вирішує, яка система обробляє запит;
- обрати шматок - бажано цінний і відносно відокремлений (наприклад, каталог чи звіти);
- реалізувати його в новій системі й перенаправити відповідні запити;
- повторювати, доки старої системи не лишиться;
- вимкнути стару систему.
┌──────────────► нова система (каталог, кошик)
запити ──► маршрутизатор
└──────────────► стара система (решта)
Варіант у межах одного застосунку (наприклад, міграція з самописного PHP на Laravel): Laravel приймає всі запити, нові маршрути обробляє сам, а для невідомих - передає старому коду (fallback-маршрут, що підключає старий фронт-контролер). Модуль за модулем код переїжджає в Laravel.
Що важливо:
- спільні дані - найскладніше. Варіанти: обидві системи працюють з однією базою (простіше, але зв'язує їх), синхронізація подіями, поступова міграція таблиць з шаром сумісності;
- кожен крок дає цінність і може йти в продакшен - немає «року без релізів»;
- можливість відкату: якщо нова частина має проблеми, маршрутизатор повертає трафік на стару;
- порівняння результатів: для критичних частин - запуск обох реалізацій паралельно й порівняння відповідей (shadow traffic) перед перемиканням;
- доводити до кінця: найчастіша проблема - «тимчасово» дві системи на роки. Потрібен план вимкнення старої.
Пов'язані прийоми: Branch by Abstraction - та сама ідея всередині коду (абстракція перед старою реалізацією, нова реалізація за прапорцем), feature flags для поступового перемикання.
Докладніше в документації: Мартін Фаулер: Strangler Fig Application