Blog
Dlaczego pełne przepisanie aplikacji często tworzy większe ryzyko niż stopniowa refaktoryzacja
Pełne przepisanie obiecuje czystą architekturę, ale wymaga ponownego odkrycia reguł biznesowych, integracji i wyjątków produkcyjnych. Stopniowa refaktoryzacja ogranicza ryzyko, wymieniając jedną granicę naraz.
Przepisanie aplikacji zaczyna się od niepełnej specyfikacji
Działająca aplikacja PHP często zawiera wiedzę biznesową, której nie ma w dokumentacji: dawną umowę z klientem, wyjątek w imporcie, kontrolę uprawnień dodaną po incydencie. Wymiana kodu wymaga ustalenia, co zrobić z tym zachowaniem. Samo przeniesienie klas do nowszego frameworka nie rozwiązuje tego problemu.
Nie oznacza to, że każdą historyczną regułę należy zachować. Część nadal jest potrzebna, inne są obejściami albo błędami. Najpierw warto oddzielić zachowanie wymagane przez biznes od tego, które ma się zmienić.
Co powinna obejmować wycena przepisania systemu
Niewspierany framework, silnie powiązane moduły czy zawodne wdrożenia to rozsądne powody, by rozważyć wymianę aplikacji. Nowa implementacja może usunąć ograniczenia, których obchodzenie jest kosztowne. Wycena musi jednak uwzględniać także poznanie wymagań, odtworzenie integracji i uprawnień, migrację danych, sprawdzenie raportów, szkolenie użytkowników oraz przygotowanie procedur awaryjnych.
Stare i nowe rozwiązanie mogą przez pewien czas działać równolegle. Oznacza to czasem poprawki w dwóch miejscach, synchronizację, dodatkowe środowiska i większy zakres wiedzy potrzebnej do utrzymania. Koszt i czas tego etapu zależą od zakresu oraz planu przełączenia. Wstrzymanie wszystkich nowych funkcji ani ciągłe opóźnienie nowego systemu nie są nieuniknione. Modernizacja stopniowa też kosztuje: tymczasowe adaptery i kod migracji potrzebują opiekunów oraz planu usunięcia.
Zacznijmy od wyliczenia ceny zamówienia
Poniżej znajduje się hipotetyczny fragment aplikacji Symfony lub Laravel. Typy takie jak Order i Money należą do przykładowego projektu; pominięte metody i zależności nie są API frameworka. Klasy readonly użyte dalej wymagają PHP 8.2 lub nowszego.
final class LegacyOrderPriceCalculator
{
public function calculate(Order $order): Money
{
$total = $order->lineTotal();
if ($order->customer()->usesHistoricContract()) {
$total = $total->subtract(
$this->historicDiscount($order),
);
}
if (
$order->country() === 'DE'
&& $order->wasImportedBeforeTaxMigration()
) {
return $this->legacyGermanTaxCalculation(
order: $order,
amount: $total,
);
}
return $this->standardTaxCalculation(
order: $order,
amount: $total,
);
}
}Historyczny rabat jest odejmowany przed wejściem w którąkolwiek gałąź obliczeń podatku. Gałąź dla Niemiec pokazuje fikcyjny wyjątek w starym systemie, a nie rzeczywistą niemiecką zasadę podatkową. Metody obliczeniowe celowo pominięto.
Przed wymianą kodu trzeba ustalić, których zamówień dotyczą poszczególne ścieżki, jak działa zaokrąglanie i czy dawne dokumenty muszą dać się odtworzyć. Osoby odpowiedzialne za proces powinny wskazać, które reguły nadal obowiązują. Nowa implementacja nie powinna po cichu zastępować niejasnej reguły inną.
Testy charakteryzujące zapisują zachowanie, nie dowodzą jego poprawności
Test charakteryzujący utrwala obecny wynik, aby refaktoryzacja nie zmieniła go niezauważenie. Pierwszy przykład dotyczy importowanego zamówienia:
it('zachowuje wynik dla importowanego zamówienia', function (): void {
$order = OrderBuilder::new()
->forCountry('DE')
->importedBeforeTaxMigration()
->withNetAmount('199.95')
->build();
$result = $this->calculator()->calculate($order);
expect($result->currency())->toBe('EUR')
->and($result->amount())->toBe('237.94');
});Drugi obejmuje klienta z historyczną umową:
it('zachowuje wynik dla dawnej umowy', function (): void {
$order = OrderBuilder::new()
->forHistoricContractCustomer()
->withNetAmount('1000.00')
->build();
$result = $this->calculator()->calculate($order);
expect($result->amount())->toBe('950.00');
});Kwoty 237.94 i 950.00 są hipotetycznymi wartościami odniesienia, nie zweryfikowanymi wynikami księgowymi klienta. Nie da się ich wyprowadzić z tego fragmentu: brakuje metod obliczeniowych i domyślnych ustawień danych testowych. W rzeczywistym teście OrderBuilder oraz calculator() są narzędziami projektu, a dane muszą jednoznacznie określać właściwe zasady podatkowe, walutę, rabat i zaokrąglenia.
Warto zapisać zaobserwowane zachowanie, wyjaśnić zaskakujące wyniki z osobą odpowiedzialną za proces i oddzielić testy zachowania dotychczasowej logiki od testów zatwierdzonej zmiany biznesowej. Przejście tych dwóch testów nie dowodzi poprawności wszystkich cen.
Migracja danych musi uwzględniać także bieżące zmiany
W danych historycznych mogą występować niespójne wartości null, duplikaty, brakujące powiązania lub pola, których znaczenie zmieniało się z czasem. To obszary do sprawdzenia, nie lista wad każdej bazy. Ręcznie poprawiony adres może być bardziej wiarygodny niż nowy wynik parsera, a dwa podobne rekordy klientów mogą opisywać różne osoby.
Dlatego sama transformacja nie wystarcza. Potrzebne są sprawdzenie kompletności, uzgodnienie rozbieżności, kontrola powiązań i istotnych sum oraz ustalona obsługa niejednoznacznych rekordów.
Expand-and-contract oddziela dodanie zgodnych wstecznie struktur od późniejszego usunięcia zbędnych. Migracja adresów może przebiegać tak:
Etap Warunek przejścia dalej
Dodanie struktur Stary kod nadal działa
Uzupełnianie danych Dawne rekordy przetwarzane partiami
Uzgodnienie danych Rozliczone zmiany bieżące i wyjątki
Przełączenie odczytu Nowe dane spełniają kryteria odbioru
Usunięcie starych Nikt już ich nie odczytuje ani nie zapisuje
W okresie przejściowym: określ właściciela każdego zapisu.Kolumny dopuszczające null mają sens tylko wtedy, gdy model pozwala na brak wartości. Zmiana schematu może też blokować tabele lub obciążać bazę. Trzeba zaplanować rzeczywistą operację na danych, nie tylko wdrożenie aplikacji.
Podwójny zapis jest jedną z możliwości, nie obowiązkiem. Lepsza może być jedna ścieżka zapisu z adapterem zgodności, przechwytywanie zmian albo zaplanowana przerwa w zapisie. Jeśli aktualizowane są obie reprezentacje, częściowe awarie i równoległe zmiany wymagają zasad utrzymania spójności oraz uzgadniania różnic. Migracja przesuwająca kursor według ID nie wróci automatycznie do wcześniejszego rekordu zmienionego później.
Ten serwis ilustruje przetwarzanie ograniczonej partii. Pominięto konstruktor oraz deklaracje pól customers i legacyAddressParser:
final class CustomerAddressMigrationService
{
// Pominięto konstruktor i pola zależności.
public function migrateBatch(
int $afterId,
int $limit,
): MigrationBatchResult {
if ($afterId < 0 || $limit < 1) {
throw new \InvalidArgumentException(
'Nieprawidłowy kursor lub rozmiar partii.',
);
}
$customers = $this->customers
->findLegacyAddressBatch(
afterId: $afterId,
limit: $limit,
);
foreach ($customers as $customer) {
if ($customer->hasStructuredAddress()) {
continue;
}
$address = $this->legacyAddressParser
->parse($customer->legacyAddress());
$customer->setStructuredAddress($address);
}
$this->customers->saveAll($customers);
return MigrationBatchResult::fromCustomers($customers);
}
}findLegacyAddressBatch() musi tu zwracać załadowaną partię uporządkowaną według stabilnego, unikalnego, całkowitoliczbowego ID, z warunkiem id > afterId i limitem. MigrationBatchResult::fromCustomers() powinno wyznaczać postęp na podstawie ostatniego przejrzanego rekordu, także pominiętego przez pętlę, oraz rozpoznawać pustą partię.
Warunek pominięcia zapobiega ponownemu wykonaniu jednej transformacji, ale nie dowodzi aktualności ani poprawności adresu. saveAll() również nie oznacza automatycznie zatwierdzenia transakcji. To metody projektu, których kontrakty muszą określać zapis, odpowiedzialność za transakcję i obsługę błędów. Punkt wznowienia należy utrwalić dopiero po zatwierdzeniu odpowiadających mu zapisów. Bezpieczne ponowienie wymaga ponadto ochrony przed równoległym nadpisaniem oraz sposobu naprawy lub uzgodnienia częściowo wykonanej pracy. Sam fragment nie gwarantuje idempotencji ani możliwości bezpiecznego wznowienia migracji.
Wymieniaj funkcję systemu za przydatnym kontraktem
Przy wycenie zamówienia niewielki interfejs należący do aplikacji może dać wywołującym stabilną zależność:
interface CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult;
}Adapter udostępnia dotychczasową implementację przez ten kontrakt:
final class LegacyCustomerPricingAdapter
implements CustomerPricingInterface
{
public function __construct(
private readonly LegacyPricingManager $legacy,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$legacyResult = $this->legacy->calculate(
customerId: $customer->getId(),
lines: $order->lines(),
);
return PriceResult::fromLegacyResult($legacyResult);
}
}Granica pomaga tylko wtedy, gdy wywołujący rzeczywiście używają interfejsu, zamiast omijać go i sięgać do starego menedżera. PriceResult::fromLegacyResult() musi zachować uzgodnioną walutę, precyzję i znaczenie wyniku. Sama zgodność z interfejsem nie zapewnia równoważności obliczeń.
Nowa implementacja realizuje ten sam kontrakt. Poniższy szkielet jawnie zgłasza błąd, ponieważ algorytm pozostaje poza przykładem:
final class ModernCustomerPricingService
implements CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
throw new \LogicException(
'Nowy kalkulator nie jest jeszcze zaimplementowany.',
);
}
}Tego szkieletu nie należy wybierać we wdrożonej aplikacji. Najpierw trzeba zaimplementować i sprawdzić obliczenie.
Branch by Abstraction i Strangler Fig działają na innych granicach
Branch by Abstraction pozwala utrzymywać implementacje za wspólnym kontraktem, stopniowo przenosić wywołujących na ten kontrakt i wprowadzać zamiennik. Schemat pokazuje zależności i implementacje, nie kolejność wykonania metod:
Wywołujący zależą od: CustomerPricingInterface
Implementacje kontraktu:
LegacyCustomerPricingAdapter
ModernCustomerPricingService
Wybór:
konfiguracja wdrożenia -> wstrzykiwanie zależności
kontekst klienta -> CustomerPricingFactoryWybór podczas działania aplikacji przydaje się, gdy polityka migracji rzeczywiście zależy od klienta lub żądania:
final class CustomerPricingFactory
{
public function __construct(
private readonly LegacyCustomerPricingAdapter $legacy,
private readonly ModernCustomerPricingService $modern,
private readonly PricingMigrationPolicy $policy,
) {
}
public function forCustomer(
Customer $customer,
): CustomerPricingInterface {
if ($this->policy->usesModernPricing($customer)) {
return $this->modern;
}
return $this->legacy;
}
}Fabryka wybiera implementację, a interfejs określa kontrakt obliczenia. Jeśli konfiguracja wdrożenia ustala ten sam wybór dla wszystkich, zwykłe wstrzykiwanie zależności zazwyczaj wystarcza. Stopniowe przełączanie klientów wymaga też spójnego kierowania wywołań i zgodnych danych. Cofnięcie flagi nie wystarczy, jeśli stary kod nie odczyta nowych zapisów.
Strangler Fig zastępuje części aplikacji na granicy routingu lub fasady. Na przykład obsługę GET /api/orders/{id} można stopniowo przenosić do nowego serwisu odczytu, zachowując publiczny punkt końcowy. Wewnętrzny kontrakt zapytania może wyglądać tak:
interface OrderDetailsQueryInterface
{
public function get(
int $orderId,
string $locale,
): ?OrderDetailsDto;
}null oznacza tu brak zamówienia, nie decyzję o uprawnieniach. Obie ścieżki muszą zachować kontrolę dostępu i publiczny kontrakt odpowiedzi. Branch by Abstraction dotyczy wewnętrznej granicy zależności, a Strangler Fig — stopniowego zastępowania funkcji aplikacji. Można je łączyć, ale nie są to dwie nazwy tego samego. To uznane techniki, nie pomysły autorstwa GiSoft; źródła znajdują się na końcu.
Porównuj obliczenia bez podwajania działań biznesowych
Dla obliczenia bez skutków ubocznych uruchomienie obu implementacji może ujawnić różnice przed zmianą oficjalnego wyniku. Potrzebne są równoważne kopie danych wejściowych, dane referencyjne i zasady zaokrąglania. Inaczej zmiana kursu waluty lub modyfikacja współdzielonego obiektu może wyglądać jak błąd implementacji.
Ten synchroniczny serwis diagnostyczny zwraca oficjalny wynik, jeśli oba obliczenia i raportowanie zakończą się pomyślnie:
final class ComparingCustomerPricingService
implements CustomerPricingInterface
{
public function __construct(
private readonly CustomerPricingInterface $official,
private readonly CustomerPricingInterface $candidate,
private readonly PricingComparisonReporter $reporter,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$officialResult = $this->official->calculate(
customer: $customer,
order: $order,
);
$candidateResult = $this->candidate->calculate(
customer: $customer,
order: $order,
);
if (!$officialResult->equals($candidateResult)) {
$this->reporter->reportDifference(
customerId: $customer->getId(),
official: $officialResult,
candidate: $candidateResult,
);
}
return $officialResult;
}
}equals() wymaga porównania zgodnego z domeną: waluty, kwot, zaokrągleń i istotnych składników wyniku, zamiast tożsamości obiektów czy arbitralnej tolerancji dla liczb zmiennoprzecinkowych. Implementacje nie mogą modyfikować współdzielonych danych wejściowych ani wyników.
Ten kod nie izoluje awarii. Wyjątek nowego kalkulatora, długie obliczenie lub błąd raportowania nadal mogą przerwać albo opóźnić żądanie. Użycie produkcyjne wymaga jawnych zasad obsługi tych awarii oraz limitów wykonania. Jeśli potrzebna jest izolacja, lepsze może być osobne zadanie porównujące na utrwalonym zestawie danych wejściowych, z własnymi zasadami dostarczania i limitami zasobów. Sam fakt, że nowy wynik nie trafia do użytkownika, nie czyni dodatkowego obliczenia obojętnym.
Reporter otrzymuje potencjalnie wrażliwe dane klientów i cen. Należy ograniczyć ich zakres, dostęp i czas przechowywania. Pełne zrzuty obiektów nie są potrzebne.
Decyzja o płatności pokazuje granicę między obliczeniem a działaniem:
final readonly class PaymentDecision
{
public function __construct(
public bool $allowed,
public string $reason,
public Money $amount,
) {
}
}Obie implementacje mogą wyliczyć decyzję. Płatność powinna wykonać wyłącznie uprawniona ścieżka oficjalna, po istniejących kontrolach dostępu i reguł biznesowych. Samo allowed nie jest uprawnieniem. Powtórne dostarczenie zadania lub niejednoznaczny timeout dostawcy nadal wymagają trwałego śledzenia operacji i odpowiedniej obsługi idempotencji. Porównanie dwóch wyników tego nie zapewnia.
Ta sama zasada dotyczy wiadomości, ruchów magazynowych i zwrotów. Ponadto readonly nie zapewnia niezmienności obiektu Money przechowywanego w polu — ten typ musi ją zapewniać sam.
Jawnie ustal odpowiedzialność za zapis danych
Granica repozytorium może pozwolić serwisom biznesowym używać stabilnego modelu mimo zmian sposobu przechowywania:
interface CustomerRepositoryInterface
{
public function get(int $id): Customer;
public function save(Customer $customer): void;
}Adapter mapuje ten model na dawną reprezentację danych:
final class LegacyCustomerRepository
implements CustomerRepositoryInterface
{
public function __construct(
private readonly LegacyCustomerGateway $gateway,
private readonly LegacyCustomerMapper $mapper,
) {
}
public function get(int $id): Customer
{
$row = $this->gateway->findRequired($id);
return $this->mapper->toDomain($row);
}
public function save(Customer $customer): void
{
$this->gateway->save(
$this->mapper->toLegacyData($customer),
);
}
}W tym przykładowym kontrakcie get() wymaga ustalonego sposobu zgłaszania wyjątku dla brakującego rekordu, a save() nie określa momentu zatwierdzenia transakcji. Gateway i mapper to zależności projektu, nie API Doctrine.
Mapowanie musi uwzględniać pola należące do innych procesów i chronić przed zapisem nieaktualnego stanu. W okresie współistnienia trzeba ustalić właściciela każdego pola. Osobny model domenowy i interfejs repozytorium mają sens tam, gdzie usuwają rzeczywiste ograniczenie migracji, a nie jako obowiązkowe warstwy dla każdej encji.
Chroń publiczne API niezależnie od implementacji
Wewnętrzny OrderDetailsDto można mapować na publiczną odpowiedź PublicOrderDto. Taki podział pomaga nie przenosić zmian magazynowania danych do API:
final readonly class PublicOrderDto
{
/**
* @param list<PublicOrderLineDto> $lines
*/
public function __construct(
public int $id,
public string $number,
public string $status,
public string $currency,
public array $lines,
public string $createdAt,
) {
}
}Typ string dla createdAt nie narzuca formatu daty. PHPDoc opisuje elementy listy na potrzeby analizy statycznej, ale nie waliduje danych wejściowych.
Niewielki test odpowiedzi również jest przydatny:
it('zwraca wymagane pola publicznej odpowiedzi', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/orders/1001');
self::assertResponseIsSuccessful();
$payload = json_decode(
(string) $client->getResponse()->getContent(),
true,
512,
JSON_THROW_ON_ERROR,
);
expect($payload)
->toBeArray()
->toHaveKeys([
'id',
'number',
'status',
'currency',
'lines',
'createdAt',
]);
});Fragment Pest zakłada konfigurację opartą na Symfony WebTestCase, znane zamówienie testowe oraz wymagane uwierzytelnienie. Sprawdza udaną odpowiedź, poprawny JSON i obecność kluczy. Nie zabezpiecza całego kontraktu.
Testy zgodności mogą też wymagać dokładnych kodów statusu, typów wartości, dopuszczalności null, formatów dat, odmowy dostępu, odpowiedzi błędów, kolejności, paginacji oraz pól tłumaczonych. Dotychczasowy kontrakt pozostaje punktem odniesienia, chyba że zmianę jawnie uzgodniono z jego odbiorcami.
Typy i testy architektury mają konkretne granice
PHPStan pomaga wykrywać niezgodności na granicach adapterów. Poniżej znajdują się fragmenty deklaracji metod interfejsów, nie samodzielne funkcje. Pierwszy określa listę obiektów reprezentujących wiersze danych w projekcie:
/**
* @return list<LegacyCustomerRow>
*/
public function findLegacyCustomersForMigration(
int $afterId,
int $limit,
): array;Kontrakt mappera może zamiast tego opisywać strukturę tablicy:
/**
* @return array{
* id: int,
* email: string,
* legacy_status: string|null,
* created_at: string
* }
*/
public function toLegacyData(Customer $customer): array;Mapper nadal musi poprawnie walidować i interpretować dane źródłowe. Dokładne adnotacje pomagają w analizie, ale nie dowodzą, że historyczny status otrzymał właściwe znaczenie biznesowe. Sprawdzanie własnych zasad architektury wymaga też odpowiedniej konfiguracji PHPStan lub dodatkowych reguł.
Testy architektury Pest mogą zapisać wybrane ograniczenia zależności:
arch('rdzeń nie zależy od starej infrastruktury')
->expect('App\Core')
->not->toUse('App\Infrastructure\Legacy');
arch('kontrolery API nie używają EntityManagerInterface')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('rdzeń nie zależy od SDK dostawcy')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');Przestrzenie nazw są przykładowe; Vendor\ExternalSdk jest nazwą zastępczą. Reguły należy dostosować do rzeczywistego kodu i zainstalowanej wersji narzędzi architektonicznych Pest. Reguła kontrolerów zabrania bezpośredniej zależności od EntityManagerInterface, nie każdego sposobu dostępu do bazy. Reguła SDK wyklucza zależność tylko z App\Core; nie dowodzi, że wszystkie użycia SDK mieszczą się w infrastrukturze. Dynamiczne pobieranie usług i pośrednie skutki nadal wymagają przeglądu.
Porównuj całą ścieżkę odczytu, nie tylko liczbę zapytań
Poniższe liczby dla GET /api/en/products są całkowicie hipotetyczne. Pokazują możliwy kompromis, nie benchmark GiSoft:
Miara Obecna Nowa
Zapytania SQL 8 3
Czas bazy danych 75 ms 110 ms
Serializacja 60 ms 190 ms
P95 czasu odpowiedzi 280 ms 430 ms
Pamięć 42 MB 118 MBWiększe złączenia, nadmierna hydratacja lub kosztowniejsze mapowanie mogą zniwelować korzyść z mniejszej liczby zapytań. Pokazanych czasów składowych nie należy dodawać, aby wyliczyć P95: ten percentyl opisuje rozkład czasów całych żądań.
Porównanie wymaga podobnych danych, rozmiarów odpowiedzi, struktury ruchu, współbieżności, stanu pamięci podręcznej i środowiska. Poza typową ścieżką trzeba zmierzyć reprezentatywne przypadki historyczne, a akceptowalny poziom błędów i opóźnień ustalić przed wdrożeniem.
Ogranicz zakres zmian wspieranych przez AI
Asystent programistyczny może pomóc wydzielić adapter lub zmienić wywołujących. Wiarygodnie wyglądający kod nadal może jednak zmienić znaczenie null, gałąź rabatową albo pole API. Przegląd powinien dotyczyć rzeczywistego zachowania, nie tylko czytelności różnic w kodzie.
Dobre zadanie wskazuje kontrakty publiczne i biznesowe, odsyła do testów charakteryzujących i ogranicza zmianę do jednej granicy. Brakujące testy warto dodać przed zmianą zachowania, które mają chronić. Zmiany zapytań wymagają pomiarów, a testy i analiza statyczna powinny obejmować istotne zależności oraz wywołujących, nie automatycznie tylko edytowane pliki.
Zmiany logiki finansowej, uprawnień lub schematu danych wymagają zatwierdzenia przez odpowiedzialną osobę. Trzeba zapisać, co sprawdzono, a co pozostaje niepewne. Wsparcie AI nie zastępuje tej decyzji.
Kiedy warto poważnie rozważyć pełne przepisanie
Nowa aplikacja może być lepszym wyborem, gdy zasadniczo zmienił się produkt, niewielki system jest dobrze poznany albo obecna platforma nie spełnia ważnych wymagań operacyjnych przy akceptowalnym koszcie. Z kolei nieznane reguły i trudna migracja danych mogą znacząco podnieść koszt pozornie czystego startu. Żadna z tych okoliczności nie rozstrzyga sprawy samodzielnie.
Zamiast punktacji „tak/nie” przydają się pytania:
- Co musi pozostać zgodne? Świadomie mniejszy zamiennik to inny projekt niż obietnica odtworzenia wszystkiego.
- Gdzie można oddzielić zmianę? Przydatne granice sprzyjają etapowaniu, lecz ich utworzenie też kosztuje.
- Jak przenieść dane i zapis? Trudne współistnienie komplikuje oba podejścia, a jednorazowe przełączenie skupia ryzyko.
- Co organizacja potrafi utrzymać i zweryfikować? Liczą się ludzie, wymagania regulacyjne, odbiór użytkowników, bieżący rozwój i możliwości odtworzenia działania.
Kopia bazy nie jest kompletnym planem wycofania zmiany. Jej przywrócenie może usunąć późniejsze zapisy lub pozostawić niespójność z wykonanymi już działaniami zewnętrznymi. Przed przełączeniem trzeba przećwiczyć właściwą procedurę odtworzenia albo naprawy i określić kryteria odbioru.
Od czego zacząć w praktyce
Wybierz jedną ważną, ograniczoną funkcję systemu i opisz jej zachowanie wspólnie z programistami oraz właścicielami procesu. Zabezpiecz potrzebne przypadki, ustal odpowiedzialność za dane i wprowadź tylko granicę potrzebną do wymiany. Sprawdź nową implementację w ograniczonym zakresie, z jawnymi kryteriami odbioru i procedurą awaryjną.
Wnioski z tej próby powinny wpłynąć na dalszy plan. Obok migracji trzeba planować zwykły rozwój, a kod przejściowy usuwać po wycofaniu jego użytkowników i zależności danych. Mały krok ma wartość wtedy, gdy odpowiada na istotne pytanie; nie jest celem samym w sobie.
Modernizacja powinna zachować wymagane zachowanie biznesowe i integralność danych, a jednocześnie ułatwiać bezpieczne zmiany. Czasem prowadzi to do stopniowej wymiany, czasem do nowej aplikacji. Decyzję warto oprzeć na wiedzy o konkretnym systemie, nie na upodobaniu do starego lub nowego kodu.
Źródła
Poniższe materiały opisują nazwane techniki migracji i składnię narzędzi. Przykłady zamówień i adresów w artykule są ilustracyjne, nie opisują zweryfikowanego wdrożenia u klienta.
- Martin Fowler, Branch by Abstraction: https://martinfowler.com/bliki/BranchByAbstraction.html
- Martin Fowler, Strangler Fig: https://martinfowler.com/bliki/StranglerFigApplication.html
- Danilo Sato, Parallel Change (expand-and-contract): https://martinfowler.com/bliki/ParallelChange.html
- PHPStan, typy w PHPDoc: https://phpstan.org/writing-php-code/phpdoc-types
- Pest, testy architektury: https://pestphp.com/docs/arch-testing
