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

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() замість ланцюжка.

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

Докладніше в документації: 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