Jak czytać i refaktoryzować stary kod w Javie: techniki, narzędzia i pułapki

0
13
Rate this post

Nawigacja:

Czym jest „stary kod” w Javie i skąd się bierze problem

Różne oblicza legacy code w Javie

Określenie legacy code w Javie rzadko oznacza tylko „stary wiek” kodu. Najczęściej chodzi o jedną z kilku sytuacji, które często występują razem:

  • Kod bez testów – nie wiadomo, czy zmiana niczego nie popsuje. Każde kliknięcie „Run” to mała ruletka.
  • Stary stos technologiczny – Java 6/7, stare Springi, własne frameworki webowe, biblioteki bez wsparcia.
  • Brak maintainerów – zespół, który to pisał, już nie pracuje, albo pamięta tylko „że to działa, nie ruszać”.
  • Kod „kogoś innego” – nawet w świeżym projekcie kod pisany przez inny zespół bywa traktowany jako legacy.

W praktyce legacy code to najczęściej po prostu kod trudny do bezpiecznej zmiany. Może być napisany wczoraj, jeśli powstał „na szybko”, bez testów i bez myślenia o strukturze. To ważne mentalnie: nie chodzi o wiek, ale o koszt wprowadzania zmian.

Jak rośnie dług technologiczny w projektach javowych

Dług technologiczny rzadko pojawia się z dnia na dzień. Najczęściej to długa seria małych kompromisów:

  • Ciśnienie na deadline’y – „Zróbmy, żeby działało, potem się posprząta”. „Potem” zwykle nie nadchodzi.
  • Brak code review – każdy programuje po swojemu, standardy istnieją tylko w Confluence.
  • Rotacja ludzi – nowi programiści nie znają kontekstu, kopiują istniejące rozwiązania, bo „tak już jest”.
  • Brak właściciela architektury – decyzje są ad hoc, rośnie liczba wyjątków od zasad.

W projektach javowych dochodzą do tego zmiany w ekosystemie: przejścia z Java EE na Spring Boot, migracje z XML na konfigurację Java, zmiany ORM, serwerów aplikacyjnych. Często stare moduły zostają „jak są”, bo nikt nie ma czasu ani odwagi ich ruszyć.

Charakterystyczne cechy starej bazy kodu

W praktyce stary kod w Javie da się rozpoznać po kilku wzorcach:

  • Długie klasy i metody – klasa serwisu na kilka tysięcy linii, metoda kontrolera na kilkaset.
  • Brak jasnych warstw – logika biznesowa, dostępy do bazy, walidacja i mapping JSON w jednej klasie.
  • Mieszanie technologii i epok – Spring + EJB, Hibernate + bezpośrednie JDBC, nowe adnotacje obok starych XML-i.
  • Zakomentowany kod – „historyczne” fragmenty, których nikt się nie odważy usunąć, bo „może jeszcze się przyda”.
  • Statyczne helpery i singletons – globalny stan, trudne do testowania zależności, niekontrolowane side-effecty.

W takich systemach każda zmiana wiąże się z obawą, że coś wybuchnie w zupełnie innym miejscu. To główny powód, dla którego refaktoryzacja legacy code w Javie bywa odkładana tak długo, aż problem staje się krytyczny.

Emocje przy pracy ze starym kodem

Przy czytaniu zastanego kodu dochodzi jeszcze warstwa emocjonalna. Pojawia się frustracja („kto to napisał?”), obawa przed popsuciem („jak to zmienię, spadną raporty dla zarządu”) i efekt „świętej krowy” – są moduły, których „nie można dotykać”, bo były kiedyś ratowane na szybko i działają na szczęściu.

Zamiast wchodzić w ton moralizatorski, lepiej przyjąć prostą zasadę: twoim zadaniem nie jest ocena przeszłości, ale poprawa przyszłości. Kod taki jest, bo takie były warunki. Twoim celem jest go zrozumieć, ogrodzić testami i refaktoryzować w małych, bezpiecznych krokach.

Przygotowanie do pracy z legacy Java: kontekst, narzędzia, nastawienie

Zebranie kontekstu biznesowego przed pierwszą zmianą

Bez zrozumienia biznesu nawet najlepsze techniki refaktoryzacji Java są jak strzelanie z zamkniętymi oczami. Najpierw trzeba wiedzieć:

  • Kto używa systemu – dział sprzedaży, klienci zewnętrzni, integracje B2B.
  • Jakie są krytyczne ścieżki – logowanie, składanie zamówienia, księgowanie płatności.
  • Gdzie są pieniądze i ryzyko – błędne naliczenia faktur, podwójne pobrania z karty, utrata danych.

Praktyczny krok: narysuj na kartce prosty przepływ najważniejszego procesu (np. zakup online) i zaznacz, które moduły Java są w niego zaangażowane. Refaktoryzację zacznij od miejsc kluczowych dla biznesu – i tam najpierw dodaj testy charakterystyki.

Szybkie rozpoznanie architektury i stacku

Kolejny krok to zrozumienie struktury technicznej. Krótka checklista:

  • Monolit czy mikrousługi? A może modularny monolit?
  • Jaka wersja Javy (8, 11, 17…)? Czy są plany aktualizacji?
  • Jaki framework webowy: Spring MVC, Spring Boot, JSF, Struts, coś własnego?
  • Jak wygląda warstwa persystencji: Hibernate/JPA, MyBatis, czyste JDBC, mieszanka?
  • Gdzie są konfiguracje: pliki XML, adnotacje, pliki properties/yaml?

Dla wielu decyzji refaktoryzacyjnych (np. wprowadzenie nowych wzorców projektowych Java, rozbijanie warstw) kluczowe jest, czy system jest blisko „standardowego” ekosystemu Spring Boot, czy raczej to customowy twór z wieloma niestandardowymi rozwiązaniami.

Podstawowy zestaw narzędzi do pracy ze starym kodem

Do pracy z legacy code w Javie przydaje się rozsądny zestaw narzędzi:

  • IDE: IntelliJ IDEA (najczęściej), Eclipse lub VS Code z dobrym wsparciem dla Javy.
  • Git – zrozumienie historii plików, sensowne branche, możliwość bezpiecznej pracy eksperymentalnej.
  • Narzędzia analizy statycznej – SonarLint, SonarQube, SpotBugs, PMD, Checkstyle.
  • Profiler – VisualVM, YourKit, Flight Recorder – przydaje się szczególnie przy refaktoryzacji pod wydajność.

Narzędzia do analizy statycznej potraktuj jako „drugi zestaw oczu”: wychwycą typowe antywzorce w starym kodzie, ale ich wyniki trzeba filtrować. Nie każde ostrzeżenie warto naprawiać od razu.

Jasno określony cel refaktoryzacji

Zanim ruszysz z refaktoryzacją, odpowiedz sobie wprost:

  • Po co to robisz? (utrzymywalność, wydajność, przygotowanie do migracji technologicznej, redukcja długu technologicznego)
  • Co jest sukcesem? (np. pokrycie testami krytycznych ścieżek, rozbicie God Class na 3 klasy, eliminacja statycznych zależności w module X)

Spisz ten cel choćby w Jirze czy README modułu. Bez tego łatwo wpaść w pułapkę „refaktoryzujemy wszystko”, co kończy się przerwanym na pół roku projektem i brakiem namacalnych efektów.

Nastawienie: najpierw zrozum, potem zmieniaj

Przy starym kodzie szczególnie kuszące jest „napiszę to od zera, będzie lepiej”. W 99% przypadków to zła decyzja. Bez pełnych testów i wiedzy domenowej nowa implementacja powtórzy stare błędy i doda własne.

Lepsze podejście:

  • Najpierw czytanie zastanego kodu i tworzenie hipotez, jak działa.
  • Potem testy charakterystyki, które tę wiedzę „zamrażają” w testach.
  • Dopiero potem stopniowa refaktoryzacja modułów – mikro krok po mikro kroku.

Zasada robocza: jeśli nie potrafisz napisać prostego testu, który odtwarza aktualne zachowanie fragmentu kodu, prawdopodobnie jeszcze go nie rozumiesz na tyle, by bezpiecznie go mocniej przebudowywać.

Jak skutecznie czytać stary kod w Javie – podejście krok po kroku

Zaczynanie od punktów wejścia do systemu

Najtrudniej czyta się pojedyncze klasy wyrwane z kontekstu. Lepiej zacząć od punktów wejścia:

  • Kontrolery REST / MVC – klasy z adnotacjami @RestController, @Controller.
  • Joby batchowe – klasy z main, @Scheduled, konfiguracje Spring Batch.
  • Listener’y – kolejki JMS, Kafka, cron.

Dzięki temu od razu widzisz, jak użytkownik lub inny system uruchamia logikę. Łatwiej wtedy zrozumieć, które ścieżki są ważne, a które to rzadko używane boczne funkcje.

Na co patrzeć przy pierwszym czytaniu klasy

Szybka analiza klasy to dobre wejście do dalszej refaktoryzacji. Sprawdź:

  • Nazwę i komentarz klasowy – czy odzwierciedlają faktyczną odpowiedzialność?
  • Pola – szczególnie statyczne, współdzielone kolekcje, cache’e, singletons.
  • Ilość publicznych metod – im więcej, tym większa powierzchnia zmian.
  • Wyjątki – co jest łapane, co propagowane, gdzie ukrywa się logika w catch.

Przykład: klasa OrderService ma 40 metod publicznych i kilka pól statycznych typu Map. To sygnał, że masz do czynienia z God Class i potencjalnie niebezpiecznym stanem współdzielonym. Zanim cokolwiek tu refaktoryzujesz, dobrze jest napisać testy wokół najważniejszych metod.

Strategia „od zewnątrz do środka”

Dobra praktyka czytania starego kodu to przechodzenie przez warstwy w kierunku od interfejsu do domeny:

  1. Kontroler (np. OrderController) – jakie endpointy obsługuje?
  2. Serwis (np. OrderService) – jakie metody biznesowe są wywoływane?
  3. Warstwa domenowa (np. Order, OrderItem, PricingPolicy) – gdzie jest logika biznesowa?
  4. Warstwa persystencji (repozytoria, DAO) – jakie dane są ładowane/zapisywane?

Podczas przechodzenia zapisuj sobie krótkie notatki: „POST /ordersOrderService.createOrderPricingService.calculate → baza”. Nawet prosty tekstowy diagram sekwencji pomaga, gdy po kilku dniach wracasz do tego samego fragmentu.

Notowanie hipotez o działaniu i drobne diagramy

Podczas pierwszego czytania starego kodu w Javie trudno od razu zrozumieć wszystkie szczegóły. W praktyce lepiej:

  • Spisywać hipotezy („ta flaga decyduje, czy naliczyć rabat dla stałego klienta”).
  • Przy każdej hipotezie zaznaczyć niepewność (np. znak zapytania, jeśli nie jesteś pewien).
  • Potem potwierdzać/obalać hipotezy przez krótkie testy lub debugowanie.

Prosty diagram przepływu (np. w notatniku, na kartce lub w narzędziu typu draw.io) potrafi zaoszczędzić godziny klikania „Go to definition” po klasach. Nie musi być ładny – ważne, żeby był zrozumiały dla ciebie i kilku osób z zespołu.

Krótkie sesje eksploracji i zmiana perspektywy

Łatwo ugrzęznąć w jednej klasie na kilka godzin, analizując każdy warunek if. Lepsza strategia:

  • Ustaw sobie limit: np. 25–30 minut na jedną ścieżkę analizy.
  • Po tym czasie zrób krótką przerwę lub zmień poziom – wróć do kontrolera albo spójrz na testy (jeśli istnieją).
  • Jeśli dalej nic nie rozumiesz – zmień narzędzie: włącz debugger, użyj call hierarchy, spójrz na logi produkcyjne.

Czytanie kodu to wysiłek poznawczy. W krótszych, intensywnych sesjach będziesz podejmować rozsądniejsze decyzje refaktoryzacyjne niż po trzech godzinach gapienia się w ten sam fragment if-else.

Ekran laptopa z edytorem kodu Java i pluszowym pomarańczowym krabem obok
Źródło: Pexels | Autor: Daniil Komov

Użycie IDE i narzędzi do zrozumienia i analizy starego kodu

Nawigacja w IntelliJ, Eclipse i VS Code

Dobre opanowanie nawigacji w IDE to często największy przyspieszacz przy pracy z legacy code w Javie. Kluczowe funkcje:

  • Go to declaration / implementation – szybki skok do definicji klasy/metody.
  • Korzystanie z hierarchii wywołań i odniesień

    Przy dużej bazie kodu sam Go to declaration nie wystarcza. Potrzebujesz widoku, który pokaże, kto woła dany fragment i co on sam wywołuje. Tu wchodzą:

  • Call Hierarchy – drzewo wywołań metody/klasy.
  • Find Usages / References – lista miejsc użycia klasy, metody, pola.

Prosty schemat pracy:

  1. Stoisz na metodzie podejrzanej o bycie „centralnym punktem” (np. OrderService.process()).
  2. Uruchamiasz Call Hierarchy i oglądasz, z ilu miejsc jest wołana.
  3. Jeśli lista jest długa – oznacza to silne sprzężenie. Przed refaktoryzacją trzeba ogarnąć, które wywołania są krytyczne.

W IntelliJ zwróć uwagę na różnicę między „Find Usages” a „Highlight usages in file”. Pierwsza opcja pomaga w skali projektu, druga – w obrębie jednej klasy. Przy dużych God Classach ta druga jest wygodna, bo od razu widzisz, jak wewnątrz klasy używane są konkretne pola i metody.

Debugowanie starego kodu bez strachu

Debugger to najkrótsza droga, żeby zweryfikować hipotezy o działaniu systemu. Kilka praktyk, które oszczędzają czas:

  • Ustawiaj breakpointy warunkowe (np. zatrzymaj się tylko, gdy orderId == 123L).
  • Korzystaj z log breakpoints (w IntelliJ) – zamiast zatrzymywać program, wypisz stan do loga i pozwól mu biec dalej.
  • Debuguj poziom wyżej niż chcesz refaktoryzować (np. kontroler zamiast prywatnej metody), żeby nie utknąć w detalach.

Przykład: masz skomplikowany PricingService i trzy różne typy rabatów. Ustaw breakpoint w metodzie publicznej, przepuść dwa–trzy różne scenariusze zamówień i poobserwuj, które warunki faktycznie się wykonują. Wiele martwych gałęzi logiki samo wyjdzie na wierzch.

Przegląd historii w Gicie jako narzędzie analizy

Historia zmian często wyjaśnia „dlaczego jest tak brzydko”. Kilka rzeczy, które można szybko sprawdzić:

  • git blame na problematycznym fragmencie – kto i kiedy go wprowadził, z jakim komentarzem.
  • Historia pliku – czy był wielokrotnie ruszany w ostatnim czasie, czy to „skamielina”.
  • Połączenie z Jirą – jeśli w commit message są numery ticketów, odszukaj zgłoszenie i zobacz kontekst biznesowy.

Jeżeli widzisz, że klasa była dotykana co sprint, a każda zmiana dorzuca kolejne if (specialCase), masz kandydata na wydzielenie nowego modułu lub refaktoryzację warunków do strategii.

Analiza statyczna jako filtr problemów, nie lista TODO

Narzędzia typu SonarQube potrafią wygenerować setki ostrzeżeń. Żeby nie utonąć:

  • Na początek filtruj po module, nad którym pracujesz, a nie po całej bazie.
  • Skup się na Bug i Vulnerability, a nie na kosmetyce typu konwencje nazewnicze.
  • Twórz krótkie sesje „cleanup” – np. 30 minut tygodniowo na wybrane, poważniejsze ostrzeżenia.

Jeśli narzędzie wskazuje na potencjalne NullPointerException w starym kodzie, to dobry pretekst, żeby dodać test, który reprodukuje sytuację i dopiero potem ją naprawić.

Metryki złożoności i rozmiaru klas

Większe projekty dobrze przeskanować metrykami (np. pluginy w IntelliJ, SonarQube). Przydatne wskaźniki:

  • Cyclomatic complexity – ile gałęzi warunków ma metoda.
  • Lines of code (LOC) na klasę/metodę – ile dokładnie jest kodu.
  • Depth of inheritance – jak głęboka jest hierarchia dziedziczenia.

Nie ma sensu ślepo gonić za „idealnymi” wartościami, ale lista metod o najwyższej złożoności to często dobry backlog refaktoryzacyjny. To tam zacznij od testów charakterystyki.

Bezpieczne wprowadzanie testów do nieprzetestowanego kodu (testy charakterystyki)

Na czym polegają testy charakterystyki

Test charakterystyki nie sprawdza „czy system działa poprawnie”. Sprawdza „czy system dalej zachowuje się tak samo jak przed zmianą”. Nawet jeśli to zachowanie zawiera bugi – na początku chodzi o stabilizację.

Tego typu testy:

  • Odwzorowują obecne zachowanie, często w formie „snapshotu”.
  • Pozwalają bezpiecznie refaktoryzować, bo wykryją niezamierzone zmiany.
  • Stają się bazą do późniejszej korekty biznesowej (najpierw refaktoring, potem zmiana zachowania).

Wybór miejsca na pierwsze testy

Zamiast mierzyć w „idealne pokrycie testami”, skup się na:

  • Najczęściej używanych endpointach REST / akcjach w UI.
  • Procesach batchowych, które robią dużo w tle (faktury, importy).
  • Miejscach, gdzie awaria najmocniej uderza w biznes (płatności, generowanie raportów).

W praktyce często zaczyna się od jednego kontrolera lub jednego serwisu. Dopiero gdy ich zachowanie jest „zabetonowane” testami, można bez stresu poprawiać środek.

Jak pisać testy charakterystyki w istniejącej architekturze

Najczęstszy błąd: próba natychmiastowego pisania czystych testów jednostkowych w mocno splecionym kodzie. Znacznie łatwiej zacząć od:

  • Testów integracyjnych wokół Spring Boot / Spring MVC.
  • Testów na poziomie serwisu z wstrzykiwanymi atrapami (Mockito).

Przykładowy szkielet testu charakterystyki dla kontrolera Spring:

@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerCharacteristicsTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldCreateOrderWithDefaultDiscountForNewCustomer() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{"customerId": 1, "items": [...]}"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.discount").value(0.0))
               .andExpect(jsonPath("$.status").value("CREATED"));
    }
}

Taki test nie rozumie jeszcze „dlaczego jest 0.0”, ale dokumentuje, że tak jest. Po refaktoryzacji natychmiast zobaczysz, jeśli coś ruszyłeś poza planem.

Testy charakterystyki dla legacy bez Springa

Stare aplikacje bez Springa często opierają się na new rozrzuconym po kodzie i pojedynczych klasach‑serwisach. Tam można:

  • Pisać testy na poziomie „public API” klas (np. OrderProcessor.process(order)).
  • Dla trudnych zależności stosować wzorzec „Sprout Method” – nową logikę wydzielać do nowej metody, która jest już testowalna.
  • W ostateczności użyć bibliotek pozwalających na mockowanie statyków (np. Mockito + mockStatic), ale traktować to jako tymczasowy most.

Zabezpieczanie zachowania przez testy danych

W wielu systemach duża część logiki jest zakodowana w danych (np. konfiguracje rabatów w bazie). Dla takich przypadków przydatne są:

  • Fixture’y testowe – małe zestawy danych SQL/JSON odtwarzające rzeczywiste scenariusze.
  • Testy „input → output” z konkretnymi, realnymi danymi.

Podejście: weź kilka reprezentatywnych rekordów z produkcji (po anonimizacji), odpal proces na nich lokalnie i zapisz wynik jako oczekiwany. Potem kod refaktoryzujesz, a dane we/wy w testach muszą się zgadzać.

Stopniowe zmniejszanie zasięgu testów integracyjnych

Na starcie dominują testy integracyjne, bo inaczej nie da się złapać systemu za „uchwyt”. Warto jednak stopniowo:

  1. Identyfikować fragmenty logiki, które można odseparować (np. kalkulacja rabatu).
  2. Wyciągać je do małych klas/serwisów z czystymi zależnościami.
  3. Dopisując do nich testy jednostkowe, jednocześnie redukując ilość asercji na wyższym poziomie.

W efekcie po jakimś czasie testy integracyjne pilnują głównych ścieżek, a cięższa logika ma swoje, szybsze i bardziej precyzyjne testy jednostkowe.

Podstawowe techniki refaktoryzacji w Javie stosowane na starym kodzie

Mikro‑refaktoryzacje wspierane przez IDE

Przy legacy łatwo zrobić za duży krok. Bezpieczniej jest korzystać z małych ruchów:

  • Rename (klasa, metoda, pole) – z IDE, tak żeby zmiana objęła cały projekt.
  • Extract Method – wydzielanie fragmentu do osobnej metody.
  • Inline Variable/Method – uproszczenie przepływu, gdy coś stało się zbędne.
  • Introduce Parameter Object – gdy metoda ma zbyt wiele parametrów.

Zasada: jedna zmiana refaktoryzacyjna = jeden commit. Dzięki temu łatwo znaleźć, co poszło nie tak, jeśli testy zaczną padać.

Rozbijanie długich metod

Metody po kilkaset linii są standardem w starym kodzie. Prosty schemat ich rozbijania:

  1. Zidentyfikuj logiczne kroki (np. „walidacja → kalkulacja → zapis → notyfikacja”).
  2. Wyciągnij każdy krok do osobnej, prywatnej metody z sensowną nazwą.
  3. Na początek nie zmieniaj sygnatur ani nazw parametrów, jedynie wydzielaj kod.

Po takim ruchu metoda nadrzędna staje się czymś w rodzaju scenariusza biznesowego, który łatwo przeczytać, a poszczególne kroki można później przenosić do osobnych klas.

Eliminacja duplikacji przez wydzielanie wspólnych komponentów

Duplikacja logiki w legacy to klasyk. Zamiast od razu polować na wszystkie powtórzenia:

  • Wybierz jedno, krytyczne miejsce (np. logika naliczania opłaty).
  • Znajdź 2–3 oczywiste duplikaty.
  • Wydziel wspólną część do nowej metody/serwisu, przepnij te 2–3 miejsca.

Dopiero gdy nowy komponent okazuje się stabilny, warto systematycznie przepinać kolejne miejsca. W przeciwnym razie drobna pomyłka przy generalnym „search & replace” wprowadzi nieoczekiwane skutki w wielu modułach naraz.

Wprowadzanie interfejsów tam, gdzie dominują implementacje

Stare systemy często operują na konkretnych klasach (np. new EmailNotificationSender() w wielu miejscach). Refaktoryzacja w stronę interfejsów pozwala:

  • Wstrzykiwać zależności (Spring/DI).
  • Łatwiej testować (mockowanie interfejsu).
  • Podmieniać implementacje bez ruszania wszystkich klientów.

Typowy krok:

  1. Wydziel interfejs NotificationSender z istniejącej klasy.
  2. Zmień miejsca użycia tak, aby korzystały z interfejsu.
  3. Dopiero potem wprowadź DI (np. @Autowired w Springu).

Dzięki temu przesuwasz się małymi krokami od kodu „new‑driven” do kodu opartego na DI, bez natychmiastowego przepisywania całego modułu.

Odgod‑klasowianie – rozbijanie God Class

God Class (np. OrderService robiący wszystko) to często największy problem. Jedna z praktycznych dróg:

  • Podziel metody na grupy: „płatności”, „rabatowanie”, „notyfikacje”, „raporty”.
  • Dla każdej grupy wydziel nowy serwis (np. OrderDiscountService).
  • W pierwszym kroku stare metody tylko delegują do nowych serwisów.

Dopiero gdy delegacja działa i jest pokryta testami charakterystyki, możesz stopniowo usuwać stare metody, a nowe serwisy pokazywać światu (np. wstrzykiwać w inne klasy).

Stopniowe usuwanie statycznych zależności

Statyczne singletons i metody utylitarne są wygodne, ale utrudniają testowanie i refaktoryzację. Prostą strategią jest:

  1. Otoczenie wywołań statycznych cienką klasą‑adapterem (np. Clock owijający System.currentTimeMillis()).
  2. Używanie adaptera w kodzie produkcyjnym.
  3. W testach – podmiana adaptera na wersję kontrolowaną (mock/własna implementacja).

Przykład minimalny:

Abstrahowanie dostępu do czasu – przykład z adapterem

Jednym z częstszych kandydatów do odstatyczniania jest czas. Produkcja woła System.currentTimeMillis(), test chce mieć kontrolę. Minimalny krok:

public interface Clock {
    long nowMillis();
}

public class SystemClock implements Clock {
    @Override
    public long nowMillis() {
        return System.currentTimeMillis();
    }
}

Następnie w klasie legacy:

public class InvoiceService {

    private final Clock clock;

    public InvoiceService(Clock clock) {
        this.clock = clock;
    }

    public Invoice createInvoice(Order order) {
        Invoice invoice = new Invoice();
        invoice.setCreatedAt(clock.nowMillis());
        // ...
        return invoice;
    }
}

Przez pewien czas tworzysz InvoiceService ręcznie:

InvoiceService service = new InvoiceService(new SystemClock());

W testach:

Clock fixedClock = () -> 1_600_000_000_000L;
InvoiceService service = new InvoiceService(fixedClock);

Później ten sam wzorzec można wykorzystać dla innych statyków: generowania ID, odczytu konfiguracji, helperów do dat.

Refaktoryzacja warstwy danych bez przepisywania całego DAO

Stare aplikacje korzystają z JDBC, surowych ResultSetów i ręcznie sklejanego SQL. Zamiast przepisywać wszystko na JPA naraz, łatwiej jest:

  • Wydzielić interfejs repozytorium dla jednej, ważnej encji.
  • Utrzymać starą implementację JDBC jako „legacy adapter”.
  • Stopniowo dodawać nowe metody w czystszej formie.

Przykład interfejsu:

public interface CustomerRepository {
    Optional<Customer> findById(long id);
    List<Customer> findActiveSince(LocalDate since);
}

Stara implementacja:

public class JdbcCustomerRepository implements CustomerRepository {

    private final DataSource dataSource;

    public JdbcCustomerRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Optional<Customer> findById(long id) {
        // stary kod JDBC, ale zamknięty w jednym miejscu
    }

    @Override
    public List<Customer> findActiveSince(LocalDate since) {
        // nowa, bardziej testowalna metoda – też JDBC, ale już świadoma domeny
    }
}

Kluczem jest to, że cała reszta systemu widzi tylko interfejs. Gdy dojdziesz do momentu wymiany warstwy danych na JPA/Hibernate albo Spring Data, zmieniasz implementację repozytorium, a nie wszystkie miejsca użycia.

Refaktoryzacja wyjątków i obsługi błędów

Legacy Java lubi:

  • ciągnąć checked exception przez pół systemu,
  • łapać Exception i logować „e.printStackTrace()”.

Porządki można zacząć bardzo lokalnie:

  • W miejscach granicznych (kontrolery, joby) łap konkretne wyjątki i mapuj je na sensowne odpowiedzi.
  • W środku systemu zamieniaj checked exception z infrastruktury na własne wyjątki domenowe (runtime).

Drobny krok:

public class PaymentException extends RuntimeException {
    public PaymentException(String message, Throwable cause) {
        super(message, cause);
    }
}

W serwisie:

public PaymentResult process(PaymentRequest request) {
    try {
        externalGateway.charge(request);
        return PaymentResult.success();
    } catch (GatewayTimeoutException e) {
        throw new PaymentException("Payment timeout for request " + request.getId(), e);
    }
}

W kontrolerze:

@RestControllerAdvice
public class PaymentErrorHandler {

    @ExceptionHandler(PaymentException.class)
    ResponseEntity<ErrorDto> handlePayment(PaymentException ex) {
        return ResponseEntity
                .status(HttpStatus.BAD_GATEWAY)
                .body(new ErrorDto("PAYMENT_ERROR", ex.getMessage()));
    }
}

Taki ruch nie zmienia logiki biznesowej, ale porządkuje przepływ błędów i ułatwia dalszą refaktoryzację.

Weryfikacja efektów refaktoryzacji – poza testami

Same testy nie wystarczą, szczególnie gdy dotykasz krytycznych fragmentów legacy. Kilka prostych technik kontrolnych:

  • Porównywanie logów przed/po dla wybranego scenariusza (np. proces fakturowania).
  • Shadow mode – uruchomienie nowej implementacji równolegle ze starą i porównanie wyników.
  • Feature toggle – przełącznik, który pozwala szybko wrócić do starego kodu.

Przykład shadow mode w kodzie:

public class DiscountService {

    private final LegacyDiscountCalculator legacy;
    private final NewDiscountCalculator modern;
    private final Logger log = LoggerFactory.getLogger(getClass());

    public BigDecimal calculate(Order order) {
        BigDecimal legacyValue = legacy.calculate(order);
        BigDecimal newValue = modern.calculate(order);

        if (legacyValue.compareTo(newValue) != 0) {
            log.warn("Discount mismatch for order {}: legacy={}, new={}",
                    order.getId(), legacyValue, newValue);
        }

        return legacyValue; // produkcja nadal używa starej ścieżki
    }
}

Po jakimś czasie, gdy logi pokazują zgodność, przełączasz się na nową implementację.

Praca z monolitem – porządkowanie modułów krok po kroku

Duży monolit w Javie często jest zlepkiem wszystkiego: warstwy domenowej, integracji, widoków, batchy. Zamiast zaczynać od „rozbijamy na mikroserwisy”, bardziej realistyczne są małe, modułowe kroki:

  • Wprowadzenie pakietów domenowych (np. com.company.billing, com.company.orders).
  • Przenoszenie klas bez zmiany logiki, jedynie z poprawą importów.
  • Wprowadzenie prostych modułów Maven/Gradle tam, gdzie zależności da się odseparować.

Praktyczna sekwencja:

  1. Wybierz jeden obszar biznesowy, np. „zamówienia”.
  2. Wylistuj wszystkie klasy z nazwą Order w projekcie.
  3. Przenieś je do jednego pakietu, np. com.company.orders.
  4. Popraw importy, uruchom testy charakterystyki.

Dopiero gdy kod domenowy jest zebrany w jednym miejscu, można myśleć o wydzieleniu go do osobnego modułu, a kiedyś – do mikroserwisu. Bez tej fazy pośredniej migracja zwykle kończy się regresjami i cofnięciem zmian.

Refaktoryzacja konfiguracji i parametrów „w kodzie”

Legacy chętnie trzyma parametry biznesowe w kodzie:

private static final BigDecimal DEFAULT_DISCOUNT = new BigDecimal("0.05");

albo nawet:

if ("PLATINUM".equals(customer.getTier())) {
    discount = new BigDecimal("0.12");
}

Bezpieczny kierunek:

  • Najpierw otoczyć takie wartości małą klasą konfiguracji domenowej (np. DiscountPolicyConfig).
  • Wstrzyknąć ją tam, gdzie potrzeba.
  • Później podmienić implementację na taką, która czyta z bazy/pliku/feature flaga.

Prosty przykład klasy pośredniej:

public interface DiscountPolicyConfig {
    BigDecimal defaultDiscount();
    BigDecimal discountForTier(String tier);
}

public class HardcodedDiscountPolicyConfig implements DiscountPolicyConfig {
    @Override
    public BigDecimal defaultDiscount() {
        return new BigDecimal("0.05");
    }

    @Override
    public BigDecimal discountForTier(String tier) {
        if ("PLATINUM".equals(tier)) {
            return new BigDecimal("0.12");
        }
        if ("GOLD".equals(tier)) {
            return new BigDecimal("0.08");
        }
        return defaultDiscount();
    }
}

Serwis biznesowy dostaje wstrzyknięty interfejs. Zmiana źródła konfiguracji nie dotyka logiki naliczania, a testy charakterystyki pilnują zachowania.

Kontrolowane wprowadzanie nowych wzorców projektowych

Kuszące jest „naprawić” legacy przez dorzucenie wszystkich znajomych wzorców: Strategia, Fabryka, Builder, CQRS. Zwykle kończy się to większym chaosem. Rozsądniejsze podejście:

  • Najpierw zidentyfikować ból – często if‑else na typie, duplikacja.
  • Dobrać jeden, prosty wzorzec do tego konkretnego problemu.
  • Zastosować go na małym fragmencie i ocenić, czy kod realnie się uprościł.

Przykład prostej Strategii zamiast if‑else na typie płatności:

public interface PaymentStrategy {
    boolean supports(PaymentRequest request);
    PaymentResult process(PaymentRequest request);
}

@Component
public class CardPaymentStrategy implements PaymentStrategy { ... }

@Component
public class BlikPaymentStrategy implements PaymentStrategy { ... }

Serwis:

@Service
public class PaymentProcessor {

    private final List<PaymentStrategy> strategies;

    public PaymentProcessor(List<PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public PaymentResult process(PaymentRequest request) {
        return strategies.stream()
                .filter(s -> s.supports(request))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Unsupported payment type"))
                .process(request);
    }
}

W legacy często da się wdrożyć taki mechanizm tylko dla nowego fragmentu systemu, a stare if‑else utrzymać równolegle, dopóki nie zostanie wygaszone.

Refaktoryzacja batchy i zadań CRON

Batchowe joby w Javie (np. Spring Batch, własne CRON-y) bywają szczególnie trudne w utrzymaniu: robią dużo, trwają długo, dotykają dużej ilości danych. Strategia refaktoryzacji:

  • Ustalenie „małego” zakresu – np. jeden krok w jobie, jeden typ rekordu.
  • Wydzielenie logiki przetwarzania pojedynczego rekordu do osobnej klasy.
  • Napisanie testów charakterystyki dla tej klasy na przykładowych danych.

Przykładowy ruch:

public class InvoiceJob {

    public void run() {
        // duży for po zamówieniach
        for (Order order : orders) {
            // wcześniej: cała logika tu
            processOrder(order);
        }
    }

    // wyciągnięte do osobnej metody
    void processOrder(Order order) {
        // tu można zacząć wyciągać kolejne serwisy
    }
}

Następny krok:

public class InvoiceOrderProcessor {

    public void process(Order order) {
        // logika przetwarzania pojedynczego zamówienia
    }
}

Teraz InvoiceOrderProcessor da się normalnie testować na kilku zamówieniach z fikstur. Sam job zostaje cienką pętlą, którą łatwiej będzie kiedyś wymienić na Spring Batch lub podzielić na mniejsze zadania.

Stopniowe usuwanie „magicznych” liczb i stringów

Magic numbers i magic strings są plagą w starym kodzie:

if (status == 3) { ... }
if ("X12".equals(code)) { ... }

Najprostszy, bezpieczny ruch:

  • Wprowadzenie stałej o czytelnej nazwie w tej samej klasie.
  • Podmiana magicznej wartości na nazwę stałej.

Przykład:

private static final int STATUS_PAID = 3;

if (status == STATUS_PAID) {
    // ...
}

Dopiero później, gdy testy trzymają zachowanie, można przenosić stałe do wspólnych miejsc (enumy, klasy z wartościami domenowymi). Zbyt szybkie „sprzątanie” magicznych wartości na poziomie całego systemu bez zabezpieczenia testami lub logami kończy się zazwyczaj rozjechaniem stanu po bazie.

Praca z wieloma wersjami Javy i bibliotek

Legacy często działa na starej Javie (7/8) i bibliotekach, których nikt nie dotykał od lat. Refaktoryzacja logiki zwykle miesza się wtedy z aktualizacjami środowiska. Bezpieczniej jest:

  • Oddzielić upgrade JDK/bibliotek od czystej refaktoryzacji.
  • Robić małe skoki wersji (np. 8 → 11, a nie 8 → 21 naraz).
  • Po każdym skoku – uruchomić testy charakterystyki i podstawowe scenariusze ręcznie.

Dobrym trikiem jest też świadome, ograniczone używanie nowych konstrukcji języka:

  • Na początek wprowadzić try-with-resources wokół JDBC – to realnie redukuje wycieki zasobów.
  • Stopniowo używać Streams tylko tam, gdzie naprawdę poprawiają czytelność.
  • Unikać rewolucji typu masowe przepisywanie pętli for na streamy jednym automatem z IDE.

Klucz to jednoznaczne rozdzielenie commitów: osobno „upgrade zależności”, osobno „refaktoryzacja”, żeby łatwiej szukać przyczyn regresji.

Organizacja pracy zespołu przy refaktoryzacji legacy

Techniczne techniki to połowa sukcesu. Druga połowa to sposób, w jaki zespół podchodzi do codziennej pracy z legacy. Kilka sprawdzonych praktyk:

  • Boy Scout Rule – zostawiaj kod w trochę lepszym stanie, niż go zastałeś (nawet jedna zmiana nazwy metody).
  • Refaktoryzacja przy okazji – nie ma osobnych „projektów sprzątania”, refaktoryzujesz w ramach normalnych zadań.
  • Najczęściej zadawane pytania (FAQ)

    Co to dokładnie znaczy legacy code w Javie?

    Legacy code w Javie to przede wszystkim kod trudny do bezpiecznej zmiany, a nie tylko „stary wiekiem”. Może być napisany rok temu, jeśli powstał na szybko, bez testów i bez sensownej struktury. Problemem jest wysoki koszt wprowadzania zmian i duże ryzyko zepsucia czegoś przy każdej modyfikacji.

    Typowe cechy takiego kodu to brak testów, stary stos technologiczny, brak osób, które dobrze znają system, oraz chaos w architekturze (mieszanie warstw, różnych frameworków i stylów). Jeśli każda zmiana wiąże się z obawą, że „coś wybuchnie gdzie indziej”, to najpewniej pracujesz z legacy code.

    Od czego zacząć pracę ze starym kodem w Javie?

    Najpierw zbierz kontekst biznesowy: kto używa systemu, które funkcje są krytyczne (logowanie, płatności, zamówienia), gdzie jest największe ryzyko finansowe. Dobrze działa prosta mapa procesu na kartce z zaznaczonymi modułami Javy biorącymi udział w kluczowych ścieżkach.

    Kolejny krok to szybkie rozpoznanie architektury i stacku: monolit czy mikroserwisy, wersja Javy, użyty framework webowy, sposób dostępu do bazy oraz miejsce konfiguracji (XML, adnotacje, properties/yaml). Dopiero na takim tle ma sens planowanie refaktoryzacji i wybór technik.

    Jak czytać i rozumieć stary kod w Javie, żeby czegoś nie popsuć?

    Zacznij od punktów wejścia do systemu, a nie od losowej klasy. Najczęściej są to kontrolery REST/MVC (@RestController, @Controller), joby batchowe (klasy z main, @Scheduled, Spring Batch) oraz listenery kolejek (JMS, Kafka, cron). Dzięki temu widzisz realne przepływy i to, co faktycznie jest używane.

    Przy pierwszym czytaniu klasy skup się na:

    • nazwie i ewentualnym komentarzu – czy pasują do tego, co klasa robi faktycznie,
    • polach i zależnościach – co jest wstrzykiwane, co jest statyczne, gdzie są wywołania do bazy/zewnętrznych usług,
    • długich metodach – od nich zwykle zaczyna się wydzielanie mniejszych fragmentów i pisanie testów charakterystyki.

    Jeśli nie potrafisz napisać prostego testu odtwarzającego aktualne zachowanie klasy, to znaczy, że trzeba ją jeszcze poczytać i przeanalizować przepływ.

    Jak bezpiecznie refaktoryzować legacy code w Javie?

    Podstawą są testy charakterystyki: najpierw utrwalasz obecne zachowanie w testach, dopiero potem zmieniasz implementację. Nie zaczynaj od „wielkiego sprzątania” całego modułu, tylko od małych kroków – skrócenie jednej metody, wydzielenie jednego komponentu, usunięcie jednej statycznej zależności.

    Sprawdza się prosty schemat:

    • wybierz mały, ale biznesowo ważny fragment,
    • dopisz testy, które przechodzą na obecnej wersji,
    • refaktoryzuj małymi zmianami, po każdej odpalając testy,
    • wrzucaj zmiany w krótkich branchach i czytelnych commitach.

    Duży „rewrite” od zera w legacy, bez dobrych testów, prawie zawsze kończy się powtórzeniem starych błędów i dorzuceniem nowych.

    Jakie narzędzia pomagają przy pracy ze starym kodem w Javie?

    Na co dzień przydaje się solidne IDE (najczęściej IntelliJ IDEA, alternatywnie Eclipse lub VS Code z dobrym wsparciem Javy), repozytorium Git oraz narzędzia analizy statycznej (SonarLint, SonarQube, SpotBugs, PMD, Checkstyle). Analiza statyczna działa jak dodatkowa para oczu – podpowiada typowe antywzorce, choć jej wyniki trzeba filtrować.

    Przy problemach z wydajnością warto dołożyć profiler, np. VisualVM, YourKit czy Java Flight Recorder. Do tego przydaje się sensowny workflow na branchach (osobne gałęzie na eksperymenty w legacy) oraz historia zmian w Git, żeby szybko ustalić, kto i po co coś kiedyś zmodyfikował.

    Jak poradzić sobie ze stresem i frustracją przy pracy ze starym kodem?

    Emocje są normalne: pojawia się złość („kto to napisał?”), strach przed popsuciem działającej funkcji oraz efekt „świętej krowy” – moduły, których „nie wolno ruszać”. Zamiast oceniać poprzedników, lepiej przyjąć założenie, że kod powstał w określonych warunkach (deadline’y, brak zasobów) i teraz twoim zadaniem jest zrobić krok do przodu.

    Praktycznie pomaga:

    • jasno zdefiniowany cel refaktoryzacji (np. „pokryć testami proces zamówienia”, a nie „posprzątać wszystko”),
    • podział pracy na małe zmiany, które da się zdeployować osobno,
    • pokazywanie zespołowi małych sukcesów (np. jedna „God Class” rozbita na 3 sensowne komponenty).

    Takie podejście zmienia legacy z „minowego pola” w obszar, który krok po kroku da się ogarnąć.

    Skąd się bierze dług technologiczny w projektach javowych?

    Dług technologiczny narasta zwykle przez serię drobnych kompromisów: ciągłe ciśnienie na terminy („najpierw zróbmy, żeby działało”), brak realnego code review, duża rotacja ludzi kopiujących istniejące rozwiązania oraz brak jednego właściciela architektury, który trzymałby spójny kierunek.

    W Javie dochodzi do tego szybka ewolucja ekosystemu: przejścia z Java EE na Spring Boot, migracje z XML do konfiguracji w Javie czy zmiany bibliotek ORM i serwerów aplikacyjnych. Stare moduły często zostają „jak są”, bo nikt nie ma czasu ani odwagi ich ruszyć – i właśnie tam dług technologiczny kumuluje się najszybciej.

    Najważniejsze punkty

  • Legacy code w Javie to nie „stary” kod, tylko kod trudny do bezpiecznej zmiany – zwykle bez testów, z niejasną strukturą i wysokim kosztem każdej modyfikacji.
  • Dług technologiczny rośnie przez serię małych kompromisów (deadline’y, brak code review, rotacja ludzi, brak właściciela architektury), a nie jedną wielką złą decyzję.
  • Stara baza kodu ma powtarzalne symptomy: gigantyczne klasy i metody, brak wyraźnych warstw, mieszanie technologii i epok, zakomentowane „zabytki” oraz statyczne helpery i singletony utrudniające testowanie.
  • Kluczowe jest nastawienie: celem nie jest ocenianie poprzedników, tylko zrozumienie obecnego rozwiązania, ogrodzenie go testami i poprawianie w małych, kontrolowanych krokach.
  • Refaktoryzację trzeba opierać na kontekście biznesowym: najpierw rozpoznaj, kto używa systemu, które ścieżki są krytyczne i gdzie generuje się ryzyko (np. płatności, faktury), a dopiero potem ruszaj kod.
  • Szybkie rozpoznanie architektury i stacku (monolit vs mikroserwisy, wersja Javy, framework webowy, sposób dostępu do bazy, sposób konfiguracji) warunkuje sensowne decyzje refaktoryzacyjne.
  • Bez podstawowego zestawu narzędzi – dobrego IDE, Gita, analizy statycznej i profilera – praca ze starym kodem jest jak grzebanie w produkcji „na żywca”; narzędzia zmniejszają ryzyko i pomagają namierzyć największe problemy.

Źródła

  • Working Effectively with Legacy Code. Prentice Hall (2004) – Klasyczne techniki pracy z legacy code, testy charakterystyki, refaktoryzacja
  • Refactoring: Improving the Design of Existing Code. Addison-Wesley (2018) – Wzorce refaktoryzacji, małe kroki zmian, poprawa struktury kodu Java
  • Clean Architecture: A Craftsman's Guide to Software Structure and Design. Pearson (2017) – Warstwowanie systemu, separacja logiki biznesowej i infrastruktury
  • Java Performance: The Definitive Guide. O'Reilly Media (2014) – Profilowanie, narzędzia JVM, optymalizacja wydajności starszych aplikacji Java
  • Effective Java (3rd Edition). Addison-Wesley Professional (2018) – Nowoczesne idiomy Javy, projektowanie API, unikanie pułapek starszego kodu
  • SonarQube Documentation. SonarSource – Zasady analizy statycznej, typowe antywzorce i dług technologiczny w Javie
  • PMD Java Ruleset Documentation. PMD – Reguły analizy statycznej dla Javy, wykrywanie długich metod, God Class, duplikacji