PHP

Refactoring v malých krocích: Jak měnit živý kód a nic nerozbít?

Branch by abstraction, parallel change a postupné vytahování kódu z controlleru. Jak refaktorovat Symfony a React kód tak, aby šel každý commit rovnou na produkci.

// obsah 8
  1. 01 Co je refactoring v malých krocích?
  2. 02 Jak funguje branch by abstraction?
  3. 03 Jak funguje parallel change?
  4. 04 Co je mikro-strangler?
  5. 05 Jaké testy refactoring přežijí?
  6. 06 Kdy refactoring rozdělit nejde?
  7. 07 Shrnutí
  8. 08 Zdroje

Byl pátek odpoledne, když mi zavolal kolega: „Hele, mám problém s PR. Můžeš se podívat?“ Sdílel obrazovku. GitHub ukazoval pull request otevřený čtrnáct dní. V záhlaví: „Refactoring EmailService – přechod na nový mailer.“ Ve statistice změn: 47 změněných souborů, +2 340 a -1 876 řádků. A 83 konfliktů.

Kód, který PR měnil, se mezitím aktivně vyvíjel (na main za ty dva týdny přibyly tři nové funkce a všechny se starého maileru dotýkaly). Každý den, kdy PR zůstal otevřený, konfliktů přibývalo. A protože šlo o 47 souborů, nedalo se v nich prakticky zorientovat a ověřit, že merge nic nerozbije. Conflict markery v PHP šablonách, conflict markery v definicích Symfony služeb… Každý merge byl sázka naslepo.

Nakonec PR zavřeli bez mergnutí a vrátili se k původnímu kódu. „Přehodnotí se to příště, až bude čas.“

Problém přitom nebyl v tom kódu. Refactoring se tu prostě dělal jako samostatný projekt bokem, a ne průběžně při běžné práci.

Pokud se vám líp kouká, než čte, je k článku i video. Má zhruba třináct minut.

Co je refactoring v malých krocích?

Martin Fowler v knize Refactoring definuje refactoring jako změnu vnitřní struktury kódu, která nezmění jeho pozorovatelné chování, ale udělá ho srozumitelnějším a levnějším na další úpravy. Nové funkce ani opravy chyb do toho nepatří.

„V malých krocích“ k tomu přidává jednu podmínku: každý commit musí být zelený a nasaditelný hned, ne až po dokončení celé práce. Kdybych musel uprostřed odejít domů, poslední commit by mohl klidně jít na produkci a nic by se nerozbilo.

To je zásadní rozdíl proti big-bang refactoringu (dlouhému branchi, kde kód žije v polostavu, testy procházejí jen lokálně a kolizí s main každým dnem přibývá). Big-bang většinou končí jedním ze dvou způsobů. Buď ho po pár dnech nervů u integrace dotáhnete, nebo branch zavřete a řeknete si, že „to nebylo prioritní“. (O rozhodování, jestli vůbec refaktorovat, nebo rovnou přepsat, jsem psal v článku Rewrite nebo refactor?.)

Proč big-bang tak často selhává?

Čas a drift. Refactoring trvá déle, než se čekalo (to je klasika). Jenže byznys mezitím vývoj nezastaví. Po pár týdnech vypadá branch jako jiný projekt a merge je pak spíš pátrání, co se kde mezitím změnilo.

Review, které nic nezachytí. PR se 47 soubory nikdo pořádně nezreviewuje. Reviewer po pár stranách přestane sledovat logiku a dá „Approve“, nebo PR týdny ignoruje, protože ví, že poctivá recenze je práce na celý den. Malý PR s pěti soubory si někdo přečte klidně ještě týž den.

Pozdní zpětná vazba. Při malých krocích selžou testy hned po prvním commitu, ne až za několik týdnů. Když první krok odkryje nečekanou závislost nebo špatně pochopené rozhraní, opravíte to hned. Na big-bang branchi na to přijdete až po týdnech a oprava je pak klidně o týden práce navíc.

Pokud řešíte migraci celé aplikace, mrkněte na Strangler Fig v PHP. Tenhle článek je o stejném principu, jen o úroveň níž (na jedné třídě nebo modulu).

Jak funguje branch by abstraction?

Branch by abstraction se hodí na refactoring, který trvá dny až týdny. Na jeden commit je to moc, a dlouhý branch přitom nechcete. Mezi kód, který danou třídu používá, a její implementaci vložíte rozhraní (interface) a implementaci pod ním pak vyměníte. Volající kód si ničeho nevšimne.

Název je trochu zavádějící, nejde o git branch. Obě implementace žijí vedle sebe přímo na main, proto se technika používá hlavně v trunk-based developmentu, kde jsou dlouhé feature branche problém. Termín zavedl Paul Hammant a techniku popsal Martin Fowler na svém blogu.

Kdy dává smysl: chcete přepsat komponentu, kterou používá víc míst v kódu, a přechod nejde udělat v jediném commitu. Typicky výměna externí závislosti (mailer, HTTP klient, cache adaptér), přepis implementace repository nebo migrace na jinou ORM strategii.

Kdy smysl nedává: když výměnu zvládnete v jednom malém commitu, interface kvůli tomu zavádět nemusíte. Branch by abstraction se vyplatí, když refactoring trvá víc než pár dní a jinak byste museli kód nechat týdny ležet v samostatné větvi.

Příklad: výměna maileru v pěti krocích

Výchozí stav. LegacyMailer se používá ve dvanácti třídách a OrderProcessor je jedna z nich. Všechny ho dostávají přímo v konstruktoru. LegacyMailer závisí na staré SMTP knihovně, kterou chcete vyměnit za Symfony Mailer.

// PŘED: přímá závislost na konkrétní třídě
final class OrderProcessor
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly LegacyMailer $mailer,  // ← závislost na implementaci
    ) {}

    public function process(OrderId $id): void
    {
        $order = $this->orders->find($id);
        // ... byznys logika ...
        $this->mailer->send(
            to: $order->customerEmail(),
            subject: 'Potvrzení objednávky',
            body: $this->renderConfirmation($order),
        );
    }
}

Krok 1: zavedete interface. Jeden malý commit, žádná změna chování.

interface MailerInterface
{
    public function send(string $to, string $subject, string $body): void;
}

LegacyMailer rozhraní implementuje beze změny logiky, jen přibude implements:

final class LegacyMailer implements MailerInterface
{
    public function send(string $to, string $subject, string $body): void
    {
        // stávající implementace beze změny
    }
}

A rovnou v services.yaml řeknete, která implementace se za rozhraním skrývá. Dokud je jen jedna, autowiring si poradí i bez toho, ale jakmile ve třetím kroku přibude druhá, kontejner by nevěděl, kterou vzít:

# services.yaml: za rozhraním je zatím stará implementace
App\Contracts\MailerInterface: '@App\Infrastructure\Mail\LegacyMailer'

Krok 2: všechna místa volání přejdou na interface. OrderProcessor a dalších jedenáct tříd teď v konstruktoru dostává MailerInterface, ne LegacyMailer. Kód je nasaditelný, chování je stejné, jen přibyla vrstva. A každou třídu jde commitnout zvlášť.

// PO kroku 2: závisí na rozhraní, ne na implementaci
final class OrderProcessor
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly MailerInterface $mailer,  // ← interface
    ) {}

    public function process(OrderId $id): void
    {
        $order = $this->orders->find($id);
        // ... byznys logika beze změny ...
        $this->mailer->send(
            to: $order->customerEmail(),
            subject: 'Potvrzení objednávky',
            body: $this->renderConfirmation($order),
        );
    }
}

Krok 3: napíšete SymfonyMailer implements MailerInterface. Testy pro novou implementaci vznikají spolu s ní. Stará implementace pořád běží na produkci, takže systém není ani chvíli v polostavu.

Krok 4: přepnete alias v DI kontejneru. Jedna řádka v services.yaml. Tohle je jediná změna, která ovlivní chování za běhu, a když se něco pokazí, vrátíte ji zpátky jedním commitem:

# services.yaml: přepnutí implementace bez dotyku volajícího kódu
App\Contracts\MailerInterface: '@App\Infrastructure\Mail\SymfonyMailer'

(Pokud chcete jít ještě opatrněji, můžete přepínat postupně přes feature flag, třeba nejdřív jen u potvrzení objednávek. Jak na feature flagy, jsem psal u Strangler Fig.)

Krok 5: LegacyMailer smažete. Pět kroků, každý šel na produkci zvlášť a ostatní vývoj mezitím normálně pokračoval. A každý jde zreviewovat za pár minut.

Pasti

Příliš obecný interface. Pokud MailerInterface dostane metody jako sendHtml(), sendTemplate() a sendBatch(), protože „jednou to možná budeme potřebovat“, je to klasický YAGNI. Interface navrhujte jen pro to, co opravdu potřebujete teď.

Migrace, která se nikdy nedokončí. Viděl jsem projekty, kde LegacyMailer a NewMailer existovaly souběžně roky. Nový vývojář nevěděl, co použít, a ADR z roku 2022 tvrdilo, že se legacy smaže „brzy“. Branch by abstraction funguje jen s jasným koncem (DI se přepne a legacy implementace se smaže). Bez posledního kroku máte jen napůl hotový refactoring se dvěma implementacemi natrvalo.

Jak funguje parallel change?

Parallel change (někdy taky expand/contract) je technika pro změnu rozhraní (API endpointu, props komponenty, signatury metody, databázového sloupce), když nemůžete změnit všechny, kdo ho používají, najednou. Popsal ji Danilo Sato na Fowlerově blogu. Má tři fáze:

Expand. Přidáte nové rozhraní vedle starého. Funguje obojí.

Migrate. Místa, která rozhraní používají, přepínáte jedno po druhém. Dávkujte podle toho, co se dá najednou zreviewovat.

Contract. Staré rozhraní smažete. Pokud máte typy (TypeScript, PHPStan), ohlídají za vás, že nic starého nezůstalo.

Každá fáze jde na produkci zvlášť. Dokud jste ve fázi migrate, je návrat triviální, protože starý kód pořád existuje.

Klasický příklad je přejmenování sloupce v databázi. Přidáte nový sloupec display_name, zapisujete do obou a existující data do něj dopočítáte jednorázovou migrací. Pak přepnete čtení na nový sloupec a starý smažete. Aplikace tak nikdy nezávisí na sloupci, který neexistuje.

Příklad: přejmenování prop v React komponentě

Výchozí stav. Komponenta UserBadge přijímá prop userName a používá se na třiceti místech v aplikaci.

// PŘED: prop userName v komponentě i na všech místech volání
interface UserBadgeProps {
  userName: string;
  avatarUrl?: string;
}

export function UserBadge({ userName, avatarUrl }: UserBadgeProps) {
  return (
    <div className="user-badge">
      {avatarUrl && <img src={avatarUrl} alt={userName} />}
      <span>{userName}</span>
    </div>
  );
}

Chcete ji přejmenovat na displayName, protože to líp odpovídá doménovému jazyku. userName zní jako login, ne jako jméno zobrazované v UI.

Fáze 1: expand. Komponenta přijme obojí, nové má přednost:

// EXPAND: obě props fungují, displayName má přednost
interface UserBadgeProps {
  displayName?: string;
  /** @deprecated použijte displayName */
  userName?: string;
  avatarUrl?: string;
}

export function UserBadge({ displayName, userName, avatarUrl }: UserBadgeProps) {
  const name = displayName ?? userName ?? '';

  return (
    <div className="user-badge">
      {avatarUrl && <img src={avatarUrl} alt={name} />}
      <span>{name}</span>
    </div>
  );
}

Commitnete. Produkce funguje, všech třicet míst pořád používá userName a nic se nerozbilo. JSDoc @deprecated navíc dá IDE vědět, že userName je na odchodu (vývojáři ho uvidí přeškrtnutý).

Fáze 2: migrate. Místa volání postupně přepínáte na displayName a po každé dávce commitnete. PR jsou malé a review je snadné, protože jde jen o mechanickou záměnu props:

// Postupná migrace míst volání po dávkách
<UserBadge displayName={user.displayName} avatarUrl={user.avatar} />

Fáze 3: contract. Všechna místa jsou přepnutá, takže userName z interface smažete:

// CONTRACT: čisté rozhraní, jen displayName
interface UserBadgeProps {
  displayName: string;
  avatarUrl?: string;
}

export function UserBadge({ displayName, avatarUrl }: UserBadgeProps) {
  return (
    <div className="user-badge">
      {avatarUrl && <img src={avatarUrl} alt={displayName} />}
      <span>{displayName}</span>
    </div>
  );
}

TypeScript ohlídá, že žádný userName nezůstal. Kdyby někde zůstal, kompilace selže.

Proč ne prostě najít a nahradit?

Uvnitř jednoho repa to klidně zvládne i rename v IDE za pár minut. Práce to moc není, horší je, co se mezitím stane jinde. Když někdo v jiném PR přidá jedenatřicáté místo se userName, po merge vám spadne TypeScript na main. U parallel change se to stát nemůže, starý prop funguje až do úplného konce. Vyplatí se proto hlavně tam, kde na stejném kódu dělá hodně lidí najednou, nebo kde komponentu používají i jiné projekty.

U REST API s externími klienty je to ještě důležitější. Starý parametr name a nový displayName existují souběžně, klienti migrují svým tempem a starý parametr smažete, až monitoring ukáže nulový provoz. Nemusíte s nikým koordinovat release a nerozbijete klienta, který update nestihl.

Co je mikro-strangler?

Strangler Fig na úrovni celé aplikace jsem popsal dřív. Stejný princip ale funguje i na úrovni jedné třídy a já mu říkám mikro-strangler.

Situace: OrderController má 1 500 řádků. Byznys logika, prezentační logika, validace vstupu, přímé dotazy do databáze… klasický God Object. Přepsat ho najednou je přesně ten big-bang problém ze začátku článku.

Mikro-strangler na to jde jinak. Nenahrazujete celý controller, ale postupně z něj vytahujete jeden use case za druhým. Controller existuje dál, jen se vyprazdňuje.

Krok za krokem

Krok 0: testy na endpoint. Pokud controller testy nemá, napište si nejdřív pár funkčních testů (WebTestCase), které hlídají, co z endpointu leze ven. Bez nich neověříte, že se po extrakci chování nezměnilo.

Krok 1: najděte jednu odpovědnost. Ne celý controller, jen jeden use case. Třeba „vytvoření objednávky“ je 150 řádků uprostřed souboru. Poznáte ho podle toho, že logika drží pohromadě (pracuje s jednou skupinou dat a má jasný vstup a výstup).

Krok 2: vytáhněte ho do nové třídy. Název je důležitý. Ne OrderHelper nebo OrderUtils, ale PlaceOrderUseCase. Jméno popisuje záměr (co se stane), ne typ (co to je). Třída je final, s dědičností nepočítáte.

// Vytažený use case: controller ho teď jen volá
final class PlaceOrderUseCase
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly MailerInterface $mailer,
        private readonly InventoryService $inventory,
    ) {}

    public function execute(PlaceOrderCommand $command): OrderId
    {
        $order = Order::place(
            customerId: $command->customerId,
            items: $command->items,
        );

        $this->inventory->reserve($order->items());
        $this->orders->save($order);
        $this->mailer->send(
            to: $command->customerEmail,
            subject: 'Potvrzení objednávky',
            body: $this->renderConfirmation($order),
        );

        return $order->id();
    }

    private function renderConfirmation(Order $order): string
    {
        // renderovací logika, dříve v controlleru
        return "Vaše objednávka {$order->id()->toString()} byla přijata.";
    }
}

Krok 3: controller deleguje. Zkrátil se o 150 řádků, zbytek zůstal beze změny.

// OrderController: deleguje na use case, sám byznys logiku nemá
public function create(#[MapRequestPayload] PlaceOrderRequest $request): Response
{
    $orderId = $this->placeOrder->execute(new PlaceOrderCommand(
        customerId: $request->customerId,
        customerEmail: $request->customerEmail,
        items: $request->items,
    ));

    return $this->redirectToRoute('order_detail', ['id' => $orderId->toString()]);
}

(Command se sestaví v controlleru. Use case tak o HTTP a třídě Request vůbec neví.)

Krok 4: commit. Controller pořád existuje a má 1 350 řádků. Use case jde ale otestovat samostatně (bez HTTP stacku, bez Symfony Kernelu, prostě zavoláte execute()). Chování se nezměnilo, jen se přesunul kód.

Krok 5: opakujte. CancelOrderUseCase, UpdateOrderItemsUseCase, ConfirmPaymentUseCase… Každý z nich je zase malý commit, který jde rovnou na produkci, a k tomu pár nových testů.

Po pár takových kolech zbyde z OrderControlleru tenká vrstva o pár desítkách řádků, která jen předává požadavky dál. Můžete ho nechat, nebo rozdělit na malé single-action controllery. Byznys logika v něm každopádně už není.

Jedno varování: pokud v controlleru najdete tři různé domény (katalog, objednávky, zákazníci), s vytahováním nespěchejte. Nejdřív je potřeba nakreslit hranice mezi doménami, jinak jen přesunete chaos z jednoho souboru do tří.

Pasti mikro-strangleru

Vytažené třídy bez jasných hranic. Největší riziko: vytvoříte PlaceOrderUseCase, OrderHelper, OrderManager a OrderService a nikdo neví, co kam patří, protože všechny dělají „nějak objednávky“. Pravidlo: odpovědnost každé třídy jde popsat jednou větou bez slova „a“. Pokud ji neumíte pojmenovat bez „Helper“ nebo „Manager“, hranice nejsou jasné a extrakce musí počkat.

Extrakce bez testů je jen přesun problému. Když vytáhnete kód z netestovaného controlleru a testy nenapíšete ani potom, máte jen špagety kód v jiném souboru. Pořadí je proto: funkční testy na endpoint, extrakce a pak unit testy pro novou třídu.

Jaké testy refactoring přežijí?

Bez testů se refactoring v malých krocích dělat nedá. Potřebujete ale testy, které strukturální změny přežijí, aniž byste je museli přepisovat.

Zní to paradoxně, ale špatně napsané testy refactoring spíš brzdí. Při každém přesunu metody zčervenají, vývojář je opraví, časem jim přestane věřit a příště je prostě přeskočí („testy jsou červené, ale to nevadí, kód funguje“). A pak už žádnou záchrannou síť nemáte.

Špatný test: mock na všechno

Takhle vypadá test, který jde refactoringu spíš proti. Testuje PlaceOrderUseCase a hlídá, co se v něm uvnitř volá:

// BAD: test hlídá, jak use case pracuje uvnitř, ne co udělá
public function testPlaceOrderSendsEmail(): void
{
    $orders = $this->createMock(OrderRepository::class);
    $orders->expects($this->once())->method('save');   // ← kontroluje interní volání

    $mailer = $this->createMock(LegacyMailer::class);  // ← konkrétní třída v mocku
    $mailer->expects($this->once())
        ->method('send')
        ->with(
            $this->equalTo('[email protected]'),
            $this->stringContains('Potvrzení'),
            $this->anything(),
        );

    $useCase = new PlaceOrderUseCase(
        $orders,
        $mailer,
        $this->createStub(InventoryService::class),
    );

    $useCase->execute(new PlaceOrderCommand(
        customerId: new CustomerId('42'),
        customerEmail: '[email protected]',
        items: [new OrderItem('SKU-1', 2)],
    ));
}

Tenhle test přestane fungovat nejpozději v pátém kroku branch by abstraction, kdy LegacyMailer smažete. A to i přesto, že zákazník dostane e-mail úplně stejně jako dřív. Horší ale je, že hlídá, jak use case pracuje (že zavolal save() právě jednou a e-mail poslal přes send()), ne co se stalo. Spadne i ve chvíli, kdy začnete třeba posílat e-maily přes frontu, i když výsledek pro zákazníka bude pořád stejný.

Dobrý test: ověřuje výsledek

// GOOD: test ověřuje výsledek, ne vnitřek.
// InMemoryOrderRepository, InMemoryMailer a AlwaysAvailableInventory jsou
// jednoduché in-memory implementace příslušných rozhraní (pár řádků
// ve složce tests/Doubles/).
public function testPlaceOrderSendsConfirmationEmail(): void
{
    $orders = new InMemoryOrderRepository();
    $mailer = new InMemoryMailer();

    $useCase = new PlaceOrderUseCase(
        orders: $orders,
        mailer: $mailer,
        inventory: new AlwaysAvailableInventory(),
    );

    $orderId = $useCase->execute(new PlaceOrderCommand(
        customerId: new CustomerId('42'),
        customerEmail: '[email protected]',
        items: [new OrderItem('SKU-1', 2)],
    ));

    self::assertNotNull($orders->find($orderId));
    self::assertCount(1, $mailer->sent);
    self::assertSame('[email protected]', $mailer->sent[0]['to']);
    self::assertStringContainsString('Potvrzení', $mailer->sent[0]['subject']);
}

Tenhle test přežije přesun kódu mezi metodami, výměnu LegacyMailer za SymfonyMailer i to, v jakém pořadí se uvnitř co volá. Hlídá jen to, že se objednávka uložila a e-mail odešel. In-memory implementace je pár řádků a na rozdíl od mocku nehlídá, jak se volá, ale co z toho vypadlo:

final class InMemoryMailer implements MailerInterface
{
    /** @var list<array{to: string, subject: string, body: string}> */
    public array $sent = [];

    public function send(string $to, string $subject, string $body): void
    {
        $this->sent[] = ['to' => $to, 'subject' => $subject, 'body' => $body];
    }
}

A když chcete ověřit i samotné napojení na Symfony Mailer, má na to Symfony v testech aserce assertEmailCount() a getMailerMessage() (s MAILER_DSN=null://null). To už je ale integrační test na jiné úrovni.

Praktická pravidla pro testy, které refactoring přežijí

Nemockujte konkrétní třídy, které se budou měnit. createMock(LegacyMailer::class) vám spadne s prvním přejmenováním. Když už mock, tak rozhraní, a ještě líp jednoduchá in-memory implementace.

Use cases s in-memory závislostmi, value objecty zvlášť. Use case testujte s reálnými nebo in-memory implementacemi závislostí. Value objecty (OrderId, Money, Email) testujte izolovaně. Nemají závislosti a unit test je pro ně přesně ta správná granularita.

Testujte hranici, ne vnitřek. Use case má vstup (command) a výstup (výsledek nebo side efekt). Testujte, co vstoupí a co vyjde. V jakém pořadí se uvnitř volají metody a kolikrát se sáhne do repository, je implementační detail. Když ho testujete, bude bolet každý refactoring.

CI musí být zelené po každém commitu. Pokud celá testovací sada trvá deset minut, nechte ji běžet na CI a před pushem pusťte jen rychlou vrstvu (syntaxe, typechecker, pár klíčových integračních testů). Commit, který prošel jen lokálně, není nasaditelný.

Kdy refactoring rozdělit nejde?

Ne každý refactoring jde rozdělit na malé kroky. Někdy mezistav sice existovat může, ale je zbytečně drahý, a je lepší to poznat předem, ne uprostřed práce.

Parallel change počítá s tím, že starý a nový stav můžou chvíli žít vedle sebe. Kde to bolí: výměna databázového enginu (třeba PostgreSQL za MongoDB), kde byste po dobu migrace museli zapisovat do dvou úplně odlišných úložišť. Nebo samotný skok na novou major verzi frameworku. Deprecations se dají odstranit postupně (Symfony na to má celou deprecation vrstvu), ale ten poslední commit s novou verzí v composer.json je pořád jeden.

Praktický test: „Jak bude systém fungovat, když starý a nový kód poběží souběžně?“ Pokud na to neumíte odpovědět, čeká vás buď big-bang s co nejmenším rozsahem, nebo Strangler Fig na úrovni aplikace, kde provoz rozdělí proxy vrstva.

Jak minimalizovat big-bang, když je nevyhnutelný?

Rozsah. Striktně ohraničit. Jen migrace schématu, nic jiného (žádné nové funkce v tom samém PR). Čím víc toho do big-bang PR přibalíte, tím víc se toho může sesypat.

Načasování. Nasazení mimo špičku (noc, víkend, okno s nejnižším provozem). Migrace, která zamkne tabulku na pět minut, je v pátek odpoledne úplně jiný problém než v neděli ráno.

Rollback plán. Než začnete, musíte vědět, jak se vrátit: záloha databáze, reverzní migrační skript, feature flag pro přepnutí. Napsaný v popisu PR, ne v hlavě.

Smoke testy. Po nasazení automaticky ověřte kritické scénáře: přihlášení, objednávku, platbu. Je rozdíl, jestli na chybu přijdete tři minuty po nasazení, nebo až za dvě hodiny přes support.

Past: „malý“ refactoring, který nabaluje

Viděl jsem to vícekrát. Vývojář začne s „malým“ refactoringem a přejmenuje entitu User na Customer. Pak zjistí, že přejmenování táhne i změnu REST API (URL, JSON klíče), a tím pádem i mobilní aplikaci, která má vlastní release přes App Store. A staré verze aplikace budou lidi používat ještě měsíce. Najednou je z přejmenování entity tříměsíční koordinace přes tři týmy.

Přitom nejjednodušší řešení bývá API vůbec neměnit. Entita se uvnitř klidně jmenuje Customer a ven pořád posíláte user, od toho je API samostatný kontrakt. A pokud ho změnit opravdu musíte, pak jedině přes parallel change, jinak staré verze aplikace rozbijete.

Jak to poznat předem: když „malý“ refactoring sahá zároveň do kódu, API, klienta i databáze, je nejspíš větší, než se zdá. Zastavte se, nakreslete si rozsah a rozhodněte se vědomě, ne za chodu.

Shrnutí

Všechny tři techniky (branch by abstraction, parallel change i mikro-strangler) stojí na stejném principu: každý krok musí být zelený a jít na produkci. A testy, které hlídají výsledek a ne vnitřek, vám to dovolí.

Když se můžete kdykoliv zastavit a nasadit, refactoring přestane být projekt a stane se běžnou součástí práce. Technický dluh pak nesplácíte jednou za rok ve vyhrazeném „refactoring sprintu“, ale průběžně, po malých splátkách. A malé PR si navíc někdo opravdu přečte, kdežto čtyřicet sedm souborů se většinou jen orazítkuje.

Zdroje

Michal Katuščák
Michal Katuščák

Navrhuji a vyvíjím aplikace nad Symfony, zajímám se o architekturu softwaru. Žiju v Českých Budějovicích.