Funkcyjnie czy obiektowo jak łączyć paradygmaty programowania w jednym projekcie aby ułatwić rozwój i testowanie kodu

0
32
2.5/5 - (2 votes)

Brief pytań, które realnie stoją za decyzją architektoniczną: czy łączenie programowania funkcyjnego i obiektowego faktycznie upraszcza projekt, czy tylko dodaje warstw i pojęć; które elementy systemu lepiej modelować obiektowo, a które funkcyjnie; jak rozdzielić stan, reguły biznesowe i efekty uboczne; co dzięki temu staje się łatwiejsze w testowaniu; jak uniknąć hybrydy, w której klasy tylko udają sens, a funkcje po cichu mutują stan; kiedy takie podejście pomaga zespołowi, a kiedy lepiej wybrać prostszą konwencję.

programowanie funkcyjne i obiektowe, łączenie paradygmatów, OOP vs FP w praktyce, testowalność kodu, czyste funkcje i efekty uboczne, architektura domenowa, encje i value objects, refaktoryzacja logiki biznesowej, języki wieloparadygmatowe, antywzorce architektoniczne, granice I/O, utrzymanie dużego projektu

Nawigacja:

Czy mieszanie paradygmatów naprawdę upraszcza projekt, czy tylko brzmi mądrze

Lepsze pytanie brzmi: jaki problem próbujesz rozwiązać

Spór funkcyjnie czy obiektowo często jest źle postawiony. W praktyce nie chodzi o to, który paradygmat jest „lepszy”, tylko o to, jaki rodzaj problemu występuje w danym fragmencie systemu. Jeżeli masz obszar z wyraźnym stanem, cyklem życia i ograniczeniami przejść, obiektowe modelowanie bywa naturalne. Jeżeli masz logikę typu „weź dane, przekształć, zwróć wynik”, styl funkcyjny zwykle prowadzi do prostszego kodu i prostszych testów.

Najwięcej szkód robi podejście ideologiczne. Z jednej strony powstają systemy, w których każda operacja to osobna klasa, nawet jeśli zawiera trzy linie przekształcenia danych. Z drugiej strony można spotkać kod udający czystą funkcyjność, który tak naprawdę rozlewa stan po modułach, singletonach i zamknięciach. Efekt jest podobny: trudniej zrozumieć przepływ, trudniej testować, trudniej przewidywać skutki zmian.

Rozsądne łączenie programowania funkcyjnego i obiektowego ma sens wtedy, gdy porządkuje granice odpowiedzialności. Obiekty pilnują spójności i reguł stanu. Funkcje biorą na siebie reguły obliczeniowe, walidacje i transformacje, które nie wymagają własnej tożsamości ani ukrytego życia wewnętrznego. Nie ma tu magii. Jest za to mniej kodu ceremonialnego i mniej miejsc, w których błąd pojawia się „bo coś gdzieś się zmieniło”.

Skąd bierze się rozczarowanie czystym OOP i czystym FP

Czyste OOP rozczarowuje zwykle wtedy, gdy architektura zaczyna opisywać sama siebie zamiast problemu biznesowego. Klasy się mnożą, interfejsów przybywa, a logika biznesowa rozprasza się po metodach, serwisach, fabrykach, adapterach i strategiach. Samo użycie obiektów nie gwarantuje jeszcze spójnego modelu. Jeśli obiekt jest tylko pudełkiem na gettery, settery i kilka przypadkowych metod, to trudniej mówić o modelowaniu, a łatwiej o kosztowny teatr architektury.

Czyste FP potrafi rozczarować z przeciwnej strony. W projektach biznesowych mamy bazę danych, zewnętrzne API, uwierzytelnianie, opóźnienia, awarie sieci, retry, harmonogramy i masę zależności od świata zewnętrznego. Nie wszystko da się sensownie opisać jako piękny ciąg czystych funkcji. Gdy próbuje się za wszelką cenę utrzymać ten ideał, kod bywa mniej przystępny dla zespołu, szczególnie w językach, które formalnie wspierają FP, ale ekosystemowo żyją jednak bardziej obiektowo.

Najbardziej pragmatyczne podejście przyjmuje prosty fakt: system biznesowy prawie nigdy nie jest w pełni czysty. Zawsze istnieją efekty uboczne. Pytanie nie brzmi więc, jak je wyeliminować, tylko jak je przesunąć do czytelnych granic. Kiedy to się uda, zyskuje i architektura, i testowanie, i onboarding nowych osób do projektu.

Największa korzyść z hybrydy: granice stają się wyraźniejsze

Dobrze połączone programowanie funkcyjne i obiektowe daje przede wszystkim lepszą separację między logiką a efektami ubocznymi. To nie jest slogan. W praktyce oznacza to, że łatwiej rozpoznać, które fragmenty można testować bardzo szybko i tanio, a które wymagają testów scenariuszowych albo integracyjnych.

Druga korzyść to ograniczenie ukrytej mutacji. Gdy logika obliczeniowa siedzi w czystych funkcjach, nie trzeba zgadywać, czy po drodze jakiś obiekt zmienił swój stan, bo „przy okazji” odświeżył cache, przestawił flagę lub wykonał dodatkowy zapis. Takie niespodzianki zwykle są zabawne tylko przez pierwsze pięć minut.

Trzecia rzecz to mniejsza liczba klas-ornamentów. W wielu projektach część logiki trafia do obiektów tylko dlatego, że „tak wypada” w OOP. Jeśli jednak dana reguła nie ma własnej tożsamości, nie utrzymuje stanu i nie pilnuje invariants, osobna klasa może jedynie utrudniać czytelność. Funkcja bywa uczciwsza wobec problemu.

Najpierw kryteria, potem styl: jak rozpoznać, co modelować obiektowo, a co funkcyjnie

Gdzie obiekt ma sens i nie jest tylko dekoracją

Obiekt jest uzasadniony tam, gdzie występuje stan, tożsamość, cykl życia albo zestaw reguł, których nie wolno rozproszyć po aplikacji. Dobry przykład to zamówienie, koszyk, konto użytkownika, subskrypcja, rezerwacja, proces reklamacji. Tego typu elementy nie są jedynie rekordami danych. One przechodzą przez etapy, mają dozwolone i niedozwolone operacje, niosą skutki biznesowe.

Jeśli zamówienie może być utworzone, opłacone, anulowane, zwrócone albo zablokowane, to sensowny obiekt powinien sam pilnować, które przejścia są legalne. Nie rozrzucaj tej wiedzy po kontrolerach, handlerach i serwisach aplikacyjnych. Gdy reguła „zamówienia opłaconego nie da się anulować po wysyłce” pojawia się w sześciu miejscach, projekt już płaci odsetki od chaosu.

Podobnie działa konto użytkownika z blokadą, resetem hasła, wymuszeniem weryfikacji czy limitem prób logowania. Można oczywiście napisać zestaw wolnych funkcji operujących na strukturze danych, ale jeśli system ma długie życie, wielu autorów i złożone przejścia stanu, obiekt bywa po prostu bezpieczniejszym miejscem dla reguł spójności.

Encje a value objects

Przy łączeniu paradygmatów bardzo pomaga rozróżnienie między encją a value object. Encja ma tożsamość i zmienia się w czasie. Dwa obiekty typu Zamówienie o tych samych polach nadal mogą być różnymi zamówieniami, bo mają inne ID i inne miejsce w procesie biznesowym.

Zespół programistów pracuje wspólnie nad projektem przy komputerze
Źródło: Pexels | Autor: cottonbro studio

Value object reprezentuje małe pojęcie domenowe, zwykle niezmienne: pieniądz, adres e-mail, zakres dat, kod waluty, ilość, numer telefonu po normalizacji. Taki obiekt nie potrzebuje osobnej tożsamości. Liczy się jego wartość. Dzięki temu wiele reguł walidacyjnych i formatowych można przenieść bliżej danych, bez nadmuchiwania encji.

To ważne również z perspektywy testów. Encję testuje się częściej scenariuszowo: czy po określonych operacjach zachowuje poprawny stan. Value object i funkcje pracujące na nim testuje się prościej: wejście, wyjście, warunki brzegowe.

Gdzie funkcja daje więcej niż kolejna klasa

Funkcje sprawdzają się tam, gdzie logika jest transformacją danych albo regułą, która nie potrzebuje własnego stanu. Walidacja formularza, mapowanie DTO na model wejściowy, liczenie rabatu, wyznaczanie podatku, filtrowanie kolekcji, agregacja wyników, budowanie raportu, normalizacja pól wejściowych — to bardzo często przypadki, w których klasa nie daje realnej wartości.

Jeżeli dana logika bierze argumenty i zwraca wynik bez pytania świata zewnętrznego o zgodę, styl funkcyjny poprawia przewidywalność. Taka funkcja nie musi nic wiedzieć o bazie danych, kontenerze DI, frameworku czy cyklu życia obiektu. To oznacza mniej zależności i niższy koszt zmiany. Gdy wymogi biznesowe się przesuwają, łatwiej poprawić funkcję niż rekonfigurować pół hierarchii klas.

Dobrym przykładem jest liczenie rabatu. Jeżeli rabat zależy od wartości koszyka, rodzaju klienta i kodu promocyjnego, można to rozpisać jako zestaw czystych funkcji. Każda przyjmuje jawne dane, zwraca wynik, niczego nie mutuje po drodze. Taki kod łatwo testować i łatwo czytać, bo nie trzeba analizować ukrytych zależności wewnątrz obiektu.

Praktyczna heurystyka

Im mniej tożsamości, mutacji i cyklu życia, tym bardziej naturalny staje się styl funkcyjny. Im więcej odpowiedzialności za spójność stanu i pilnowanie dozwolonych operacji, tym mocniejszy argument za obiektem. To nie jest wzór matematyczny, ale działa zaskakująco dobrze przy codziennych decyzjach projektowych.

Jeśli zadajesz sobie pytanie: „czy to naprawdę musi być klasa?”, sprawdź trzy rzeczy:

  • Czy ten element posiada stan zmieniający się w czasie?
  • Czy ma własną tożsamość albo reguły przejść stanu?
  • Czy logika przestaje być czytelna, gdy zostanie zwykłą funkcją?

Jeżeli odpowiedzi brzmią kolejno: nie, nie i nie, tworzysz klasę prawdopodobnie z przyzwyczajenia.

Co naprawdę przesądza o wyborze

Najważniejsze kryteria są dość przyziemne: mutowalność, liczba efektów ubocznych, złożoność domeny, zakres spodziewanych zmian i koszt testów. Gdy obszar ma dużo logiki czysto obliczeniowej, FP niemal samo się narzuca. Gdy obszar operuje na wieloetapowym procesie z ostrymi zasadami biznesowymi, OOP zwykle daje lepszą kapsułkację.

Dochodzi jeszcze czynnik zespołowy. Można zbudować bardzo elegancką hybrydę, której nikt poza autorem nie chce potem dotykać. Jeżeli zespół pracuje głównie w Javie lub C# i ma umiarkowaną biegłość w idiomach funkcyjnych, przesadnie zaawansowane podejście FP może utrudnić rozwój. Analogicznie, w zespole pracującym w Kotlinie, Scali czy F# wciskanie każdej reguły do osobnych klas bywa sztuczne i męczące.

Znaczenie ma też język. W TypeScripcie naturalniej miesza się lekki model obiektowy z funkcjami transformującymi dane. W Kotlinie czy C# łatwo budować klasy domenowe i jednocześnie utrzymywać czyste funkcje pomocnicze. W Pythonie granice formalne bywają bardziej miękkie, więc szczególnie trzeba pilnować konwencji. Język pomaga albo przeszkadza, ale nie podejmuje decyzji za architekturę.

Najzdrowszy podział przebiega zwykle przez warstwy systemu, nie przez ideologie

Domena, aplikacja, infrastruktura i I/O

Najbardziej praktyczne łączenie paradygmatów nie polega na wymieszaniu wszystkiego ze wszystkim, tylko na wyraźnym podziale warstw. Domena przechowuje pojęcia biznesowe, reguły i ograniczenia. Warstwa aplikacyjna składa przypadki użycia. Infrastruktura obsługuje technikalia. Granice I/O są miejscem kontaktu ze światem zewnętrznym: baza danych, API, kolejki, zegar, system plików, logowanie, generator identyfikatorów.

Zespół prezentuje rozwiązania technologiczne w nowoczesnym biurze
Źródło: Pexels | Autor: Mikhail Nilov

W domenie najłatwiej zobaczyć sens hybrydy. Część logiki potrzebuje obiektów, bo pilnuje stanu encji. Część jest czystą regułą lub obliczeniem i lepiej działa jako funkcja. Warstwa aplikacyjna nie powinna puchnąć od ifów i wyjątków, bo wtedy zaczyna odgrywać rolę przypadkowej domeny. Jej zadanie jest prostsze: pobrać potrzebne dane, uruchomić domenę, zainicjować efekty uboczne, złożyć wynik.

Infrastruktura to miejsce, gdzie żyją implementacje repozytoriów, klientów HTTP, brokerów wiadomości, cache, adapterów płatności. To obszar z natury brudny od efektów ubocznych i zależności technicznych. Nie ma sensu udawać, że ten kod jest „funkcyjnie czysty”. Lepiej zadbać, by ten brud nie rozlewał się na domenę.

Jak rozdzielić odpowiedzialność bez mnożenia bytów

Najczęstszy błąd przy projektowaniu warstw to zbyt dosłowne tłumaczenie idei na strukturę katalogów i klas. Powstają wtedy aplikacje, w których każdy koncept ma po pięć reprezentacji, ale nikt nie wie, gdzie jest właściwa reguła. Zdrowy podział nie wymaga biurokracji. Wymaga konsekwencji.

Encje pilnują spójności stanu i zasad przejść. Value objects porządkują małe pojęcia biznesowe i walidują własne wartości. Serwisy domenowe przydają się tam, gdzie logika nie należy naturalnie do jednej encji, ale nadal jest czystą logiką domeny. Funkcje dobrze obsługują przeliczenia, walidacje i transformacje, które nie potrzebują ukrytego stanu. Adaptery izolują zależności techniczne.

Kluczowe jest to, by nie robić z serwisów domenowych magazynu „reszty”. Jeśli wszystko ląduje w klasie typu OrderService, to znak, że obiekty domenowe są anemiczne albo że nie odróżniono domeny od aplikacji. Z kolei jeśli wszystko trafia do modułu utils, problem jest ten sam, tylko ubrany bardziej minimalistycznie.

Granica efektów ubocznych

W praktyce bardzo pomaga świadome wydzielenie miejsc, w których kod dotyka świata zewnętrznego. To są między innymi:

  • odczyt i zapis do bazy danych,
  • wywołania zewnętrznych API,
  • publikacja i odbiór komunikatów,
  • pobieranie czasu systemowego, losowanie i generowanie identyfikatorów,
  • logowanie, wysyłka e-maili i zapis plików.

Im bliżej środka systemu, tym mniej takich zależności powinno być. Dobra praktyka jest prosta: na wejściu zbierz dane, w środku policz decyzję, na wyjściu wykonaj efekt. Dzięki temu testy rdzenia nie muszą stawiać pół środowiska tylko po to, by sprawdzić jedną regułę biznesową. A to już różnica, którą czuć nie tylko w architekturze, ale też w tempie pracy i poziomie frustracji zespołu.

Tu właśnie hybryda zwykle wygrywa z podejściem „wszystko obiektowo” albo „wszystko funkcyjnie”. Obiekty dobrze trzymają domenę w ryzach tam, gdzie stan i reguły są nierozerwalne. Funkcje porządkują logikę pomocniczą i obliczeniową tam, gdzie stan byłby tylko dekoracją. Jeśli te granice są jasne, kod nie zamienia się ani w klasowy labirynt, ani w worek luźnych funkcji z przypadkową odpowiedzialnością. Jedno i drugie potrafi zmęczyć szybciej, niż ktokolwiek przyzna na code review.

Dwa krótkie scenariusze, w których hybryda działa lepiej niż czyste OOP albo czyste FP

Scenariusz 1: checkout i naliczanie ceny końcowej

Proces zamówienia często łączy dwa różne typy problemów. Z jednej strony masz encję lub agregat pilnujący stanu: czy zamówienie można jeszcze edytować, kiedy wolno je potwierdzić, czy adres dostawy jest kompletny, czy płatność nie została już rozpoczęta. To brzmi obiektowo i zwykle takie właśnie jest. Z drugiej strony dochodzi warstwa obliczeń: rabaty, kupony, progi darmowej dostawy, podatki, przeliczenia walut, zaokrąglenia. Ta część aż prosi się o funkcje.

Jeśli wszystko wrzucisz do jednej klasy OrderService, szybko dostaniesz metodę, która zna pół systemu i ma długość małej noweli. Jeśli pójdziesz w skrajne FP i rozbijesz cały checkout na luźne transformacje, łatwo zgubisz pilnowanie legalnych przejść stanu zamówienia. Rozsądniejszy układ bywa taki: obiekt zamówienia pilnuje, co wolno, a zestaw czystych funkcji liczy, ile wychodzi. Dzięki temu zmiana zasad rabatowych nie rozwala modelu życia zamówienia, a zmiana procesu zamówienia nie wymusza przepisywania kalkulatora od zera.

Scenariusz 2: import danych z zewnętrznego systemu

Import to zwykle mieszanka bałaganu wejściowego i precyzyjnych reguł po stronie domeny. Na wejściu przychodzą niepełne rekordy, dziwne formaty dat, kody z dodatkowymi spacjami i pola, które „teoretycznie zawsze są ustawione”. Czyli klasyka. Tutaj świetnie sprawdzają się funkcje normalizujące, walidujące i mapujące surowe dane na sensowne struktury pośrednie. Są łatwe do testowania i nie potrzebują rozbudowanego modelu obiektowego.

Dopiero po oczyszczeniu danych warto wpuścić je do domeny, gdzie obiekty pilnują znaczenia biznesowego: czy można utworzyć nową relację, czy rekord powinien zaktualizować istniejący byt, czy naruszona zostałaby jakaś reguła spójności. W takim układzie błędy formatu nie mieszają się z błędami domenowymi, a infrastruktura nie przecieka do środka modelu. Gdyby zrobić to wyłącznie obiektowo, łatwo skończyć z encjami zajmującymi się parsowaniem CSV. Gdyby zrobić to wyłącznie funkcyjnie, można zgubić miejsce, w którym system faktycznie broni swoich reguł.

Najbardziej użyteczna zasada jest prosta: nie wybieraj paradygmatu jak deklaracji wiary. Wybieraj go tam, gdzie obniża koszt zmiany, upraszcza testy i wyraźniej pokazuje odpowiedzialność. Jeśli po tygodniu nadal wiadomo, gdzie dopisać nową regułę i czego nie ruszać przy poprawce, architektura robi dokładnie to, co powinna.

Co realnie zyskuje testowanie, gdy logika i efekty uboczne przestają mieszkać pod jednym dachem

Największa poprawa nie bierze się z samego faktu, że część kodu jest „bardziej funkcyjna”. Zysk pojawia się wtedy, gdy testy mogą sprawdzać decyzje biznesowe bez uruchamiania infrastruktury. Jeśli reguła rabatowa, walidacja przejścia stanu albo sposób wyliczenia opłaty zależy wyłącznie od danych wejściowych, test staje się krótki, szybki i odporny na przypadkowe awarie bazy, zegara czy klienta HTTP.

To nie oznacza, że cały system należy zamienić w czyste funkcje. Testowanie aplikacji biznesowej i tak wymaga kilku poziomów. Problem zaczyna się dopiero wtedy, gdy każdy poziom jest testowany tak samo. Jeśli wszystko sprawdza się przez grube testy integracyjne, zespół płaci za to czasem wykonania, kruchością scenariuszy i trudnością diagnozy. Jeśli z kolei testy jednostkowe udają izolację, ale pod spodem obiekt i tak dotyka singletonów, konfiguracji i aktualnego czasu, to jest to bardziej teatr niż automatyzacja.

Jednostkowo testuj decyzje, integracyjnie testuj połączenia

W hybrydowym układzie dobrze działa prosty podział. Czyste funkcje i małe reguły domenowe testujesz jednostkowo, niemal tabelarycznie: dane wejściowe, wynik, przypadki brzegowe. Encje i agregaty testujesz przez ich zachowanie: czy odrzucają nielegalne przejścia stanu, czy utrzymują niezmienniki, czy publikują odpowiednie zdarzenia domenowe. Warstwę aplikacyjną sprawdzasz pod kątem orkiestracji: czy pobiera właściwe dane, czy wywołuje domenę, czy uruchamia efekt uboczny w odpowiednim momencie. Adaptery zostawiasz testom integracyjnym, bo tam naprawdę liczy się zgodność z bazą, API albo brokerem.

To podejście mocno ogranicza potrzebę nadmiernego mockowania. Gdy rdzeń logiki jest czysty, nie trzeba podstawiać pięciu zależności tylko po to, by obliczyć wynik jednej reguły. A im mniej fantomowych obiektów w teście, tym mniejsze ryzyko, że test potwierdza wyłącznie to, że mock zwrócił to, co mu wcześniej kazano zwrócić. Taki test bywa niezwykle lojalny wobec błędów.

Przykład: ta sama reguła w dwóch wersjach

Załóżmy, że klient może dostać darmową dostawę tylko przy spełnieniu kilku warunków: minimalna wartość koszyka, brak produktów wyłączonych z promocji i właściwy kraj dostawy. Jeżeli ta reguła siedzi w metodzie serwisu, który przy okazji pobiera konfigurację, pyta magazyn o dostępność i zapisuje ślad audytowy, test jednej decyzji zamienia się w mały spektakl z dekoracjami.

Jeśli natomiast warunki darmowej dostawy są wydzielone do funkcji lub niewielkiego serwisu domenowego zależnego wyłącznie od przekazanych danych, test wygląda banalnie. I właśnie o to chodzi. Nie o ideologiczną czystość, tylko o to, by koszt sprawdzenia logiki był proporcjonalny do samej logiki, a nie do liczby kabli wokół niej.

Najczęstsze antywzorce: hybryda, która miała upraszczać, a zaczęła wszystko maskować

Mieszanie paradygmatów daje dużo swobody, a swoboda bez konwencji szybko produkuje chaos. Najbardziej zdradliwe są te rozwiązania, które na pierwszy rzut oka wyglądają nowocześnie i „czysto”, ale po kilku miesiącach utrzymania okazują się tylko zmianą opakowania.

Obiekty anemiczne i serwisy, które robią całą robotę

To klasyczny problem po stronie OOP. Encje przechowują pola i gettery, a cała logika ląduje w klasach typu CustomerService, InvoiceManager albo ProcessHandler. Formalnie system jest obiektowy. W praktyce stan i reguły żyją osobno, więc łatwo złamać spójność. Taka architektura słabo korzysta z zalet obiektów, bo obiekty nie pilnują własnych zasad.

Dodanie do tego kilku funkcyjnych helperów nie rozwiązuje problemu. Powstaje tylko anemiczny model otoczony funkcjami i serwisami. Kod niby jest podzielony, ale odpowiedzialność nadal jest rozmyta.

Funkcje, które tylko udają czystość

Druga skrajność wygląda nowocześnie, lecz potrafi być równie myląca. Funkcja przyjmuje dane, ale w środku czyta globalną konfigurację, pobiera czas systemowy, modyfikuje przekazaną kolekcję albo odpala klienta HTTP. Nazwa i podpis sugerują coś przewidywalnego, a implementacja przemyca efekty uboczne bocznymi drzwiami.

W takich miejscach testy zaczynają zachowywać się kapryśnie, a czytelnik kodu musi zgadywać, czy dana operacja jest zwykłym przeliczeniem, czy jednak dotyka świata zewnętrznego. To szczególnie częsty problem w językach, które łatwo pozwalają mieszać style bez wyraźnych granic. Miękkość języka jest wygodna, ale nie jest architektem.

Warstwa aplikacyjna jako śmietnik na wszystko

Kiedy zespół słyszy, że „domena ma być czysta”, bywa, że zaczyna bać się umieszczać tam jakąkolwiek istotną logikę. W efekcie przypadki użycia rozrastają się do długich metod pełnych warunków, walidacji, przeliczeń i wyjątków. Warstwa aplikacyjna przestaje orkiestrwać, a zaczyna decydować. To sygnał alarmowy, bo oznacza, że system nie ma jednego sensownego miejsca na reguły biznesowe.

Praktyczny test jest prosty: jeśli zmiana zasad biznesowych zwykle zaczyna się od przeszukiwania kilku use case’ów, to logika jest rozproszona. Jeśli z kolei najczęściej trafiasz do jednej encji, value objectu albo jawnie nazwanego modułu reguł, układ jest znacznie zdrowszy.

Kiedy lepiej nie komplikować i zostać przy prostszej konwencji

Nie każdy projekt potrzebuje starannie zbalansowanej hybrydy. Są sytuacje, w których bardziej opłaca się utrzymać prostszy styl i nie udawać, że system ma złożoność, której jeszcze nie ma.

Jeżeli aplikacja jest głównie cienką warstwą CRUD nad bazą, a logika biznesowa sprowadza się do kilku walidacji formularza i prostego przepływu danych, rozbudowane rozdzielanie odpowiedzialności może dać więcej ceremonii niż korzyści. Podobnie w małym zespole, który musi szybko dowozić zmiany i pracuje w kodzie o niskiej złożoności domenowej. Wtedy czytelna, umiarkowanie obiektowa struktura z kilkoma dobrze wydzielonymi funkcjami pomocniczymi bywa w zupełności wystarczająca.

Ostrożność jest potrzebna też wtedy, gdy zespół nie ma wspólnego słownika architektonicznego. Bez tego łatwo o hybrydę, w której każdy rozumie „serwis domenowy”, „value object” i „czystą funkcję” trochę inaczej. Efekt jest przewidywalny: dwa pull requesty później jeden moduł wygląda jak mini-DDD, drugi jak skrypt proceduralny, a trzeci jak próba pogodzenia obu przy pomocy siły woli.

Jeżeli więc problem jest prosty, trzymaj rozwiązanie prosto. Mieszanie paradygmatów ma sens wtedy, gdy porządkuje rosnącą złożoność, a nie wtedy, gdy ma dodać projektowi architektonicznej powagi.

Jak refaktoryzować istniejący kod bez rewolucji i bez miesiąca debat o abstrakcjach

Najbezpieczniejsza droga rzadko zaczyna się od przemeblowania całego projektu. Lepiej znaleźć miejsce, w którym ból jest największy: niestabilne testy, klasa-bóg, use case z dziesięcioma odpowiedzialnościami albo logika biznesowa sklejona z wywołaniami zewnętrznymi. Tam zmiana daje najszybszy efekt i najmniej ideologii.

Najpierw wydziel czyste decyzje

W starym kodzie najłatwiej zacząć od wyłuskania reguł, które już dziś są w istocie obliczeniami, tylko zostały zatopione w serwisach. Rabaty, kwalifikacja statusu, walidacja kombinacji pól, dobór wariantu procesu, liczenie opłat, mapowanie danych wejściowych na struktury pośrednie — to wszystko zwykle da się przenieść do funkcji lub niewielkich komponentów bez własnego stanu.

Ten krok ma dwie zalety. Po pierwsze od razu poprawia testowalność. Po drugie pokazuje, gdzie naprawdę kończy się domena, a gdzie zaczyna infrastruktura. Często dopiero po takim ruchu widać, że połowa złożoności danej klasy nie wynika z biznesu, tylko z mieszania pobierania danych, logowania i decyzji w jednej metodzie.

Potem domknij niezmienniki w modelu

Kolejny etap to przesunięcie odpowiedzialności za spójność tam, gdzie naturalnie należy. Jeśli encja ma statusy i przejścia, niech sama pilnuje, czy przejście jest legalne. Jeśli wartość ma format i ograniczenia, niech value object odrzuca niepoprawne dane od razu. Dzięki temu warstwa aplikacyjna przestaje ręcznie odtwarzać zasady przy każdym użyciu.

To dobry moment, by usuwać metody ustawiające wszystko na wszystko. Settery, które pozwalają zmienić stan w dowolnym momencie, są wygodne tylko do chwili, gdy system zaczyna mieć realne reguły. Potem zamieniają model w pole minowe.

Na końcu porządkuj granice techniczne

Dopiero kiedy logika jest sensowniej rozłożona, opłaca się czyścić dostęp do bazy, klientów zewnętrznych, kolejek czy zegara. Adaptery i porty mają sens wtedy, gdy chronią rdzeń przed technikaliami, a nie wtedy, gdy powstają jako dekoracja do projektu, który i tak wszystko robi w jednym miejscu.

Dobra refaktoryzacja zwykle zostawia po sobie prosty ślad: mniej kodu w metodach aplikacyjnych, mniej warunków rozrzuconych po systemie, więcej testów sprawdzających reguły bez infrastruktury i wyraźniejsze miejsce, do którego trafia nowa zmiana. Jeśli po zmianach trzeba dodać trzy interfejsy, żeby obliczyć jedną prowizję, to najpewniej poszło się o zakręt za daleko.

Znaczenie języka jest praktyczne, ale nie decydujące

W językach wieloparadygmatowych sensowne łączenie stylów jest zwykle najłatwiejsze właśnie dlatego, że nie trzeba wybierać jednego obozu. Kotlin, C#, TypeScript, Python czy Scala pozwalają trzymać obiekty tam, gdzie modelują pojęcia i granice, a funkcje tam, gdzie chodzi o transformacje i reguły. Różnica polega głównie na tym, jak łatwo język pomaga utrzymać dyscyplinę.

W językach z mocniejszym systemem typów łatwiej domknąć znaczenie pojęć przez value objecty, typy wyników i jawne modelowanie stanów. W bardziej elastycznych środowiskach trzeba bardziej polegać na konwencji, testach i code review. To nie wada sama w sobie, tylko koszt, który trzeba świadomie ponieść.

Dlatego przy wyborze stylu dobrze patrzeć nie tylko na możliwości języka, ale też na to, co zespół umie czytać równie sprawnie, jak pisze. Kod ma być utrzymywalny po entuzjazmie pierwszej implementacji. Jeżeli dane rozwiązanie wygląda błyskotliwie, ale każdy kolejny programista potrzebuje chwili ciszy i kawy, by zrozumieć dwa podstawowe przepływy, to sygnał jest dość czytelny.

Najrozsądniejsza zasada jest mało efektowna, ale działa: obiekty niech bronią stanu i granic odpowiedzialności, funkcje niech obsługują reguły i transformacje, a efekty uboczne niech siedzą przy krawędzi systemu. Gdy ten podział jest konsekwentny, rozwój przyspiesza nie dlatego, że projekt brzmi nowocześnie, tylko dlatego, że mniej rzeczy trzeba zgadywać.

Jak to wygląda w testach, kiedy podział jest naprawdę sensowny

Najłatwiej ocenić jakość takiej hybrydy nie po diagramie, tylko po tym, jak zachowuje się test suite. Jeśli po zmianie jednej reguły biznesowej da się dopisać lub poprawić kilka krótkich testów jednostkowych bez uruchamiania bazy, kolejki i połowy frameworka, to znaczy, że granice zostały postawione tam, gdzie trzeba.

Czyste funkcje dają przewidywalność. Testujesz wejście i wyjście, bez całego rytuału z mockami, fixture’ami i zgadywaniem, czy przypadkiem nie aktywował się jakiś ukryty efekt uboczny. To szczególnie dobrze działa dla walidacji, polityk cenowych, kwalifikacji statusów, transformacji modeli wejściowych i reguł decyzyjnych. W takich miejscach test powinien być niemal nudny, a to akurat komplement.

Z kolei obiekty domenowe dobrze testuje się tam, gdzie mają pilnować niezmienników i legalnych przejść stanu. Jeżeli encja reprezentuje zamówienie, rezerwację albo subskrypcję, test nie powinien sprawdzać tylko tego, że setter ustawia pole. Powinien sprawdzać, że obiekt odrzuca nielegalną zmianę, wymusza kolejność działań albo poprawnie buduje nowy stan po poprawnej operacji. Taki test nie zastępuje funkcji. On pilnuje kontraktu modelu.

Warstwa aplikacyjna zwykle zasługuje na mniejszą liczbę testów jednostkowych, a większy nacisk na testy scenariuszowe. Nie dlatego, że jest mniej ważna, lecz dlatego, że jej główną rolą jest koordynacja. Jeśli use case pobiera dane, woła reguły domenowe, zapisuje wynik i publikuje zdarzenie, to sensowny test sprawdza przede wszystkim, czy ten przepływ jest poprawnie złożony. Gdy takich testów robi się dziesiątki tylko po to, by zasymulować każdy branch logiki biznesowej, to najczęściej znak, że ta logika siedzi za wysoko.

Jest też praktyczna różnica w testach integracyjnych. W projekcie, który miesza style bez dyscypliny, test integracyjny często staje się jedynym bezpiecznym sposobem sprawdzenia czegokolwiek. W projekcie z wyraźnym podziałem pełni inną rolę: potwierdza, że adapter zapisuje, czyta i komunikuje się poprawnie z otoczeniem, a nie że przypadkiem działa też cała biznesowa algebra wszechświata.

Przykład podziału odpowiedzialności bez architektonicznej gimnastyki

Weźmy typowy przypadek: naliczenie opłaty za zamówienie z rabatem, ograniczeniami kampanii i statusem klienta. To obszar, w którym łatwo przesadzić w obie strony.

Jeżeli wszystko wsadzisz do encji Order, szybko skończysz z klasą, która zna cenniki, aktywne promocje, zasady marketingowe, politykę podatkową i pewnie jeszcze fazy księżyca. Brzmi jak plan na klasę-boga. Jeśli z kolei rozbijesz to na dziesięć statycznych helperów, logika zacznie krążyć po systemie bez właściciela.

Zdrowszy wariant wygląda prościej. Order pilnuje własnego stanu: czy można dodać pozycję, czy zamówienie jest już zamknięte, czy zmiana jest dozwolona po autoryzacji płatności. Money, Discount albo CustomerTier występują jako value objecty, bo niosą znaczenie i własne ograniczenia. Sama kalkulacja ceny może być zestawem czystych funkcji albo niewielkim modułem reguł, który przyjmuje jawne dane wejściowe i zwraca wynik bez dotykania świata zewnętrznego. Warstwa aplikacyjna składa to w całość: pobiera kampanię, odczytuje zamówienie, uruchamia kalkulację, zapisuje rezultat.

Taki układ daje coś bardzo konkretnego. Gdy marketing zmienia zasady rabatu, zwykle dotykasz modułu reguł i kilku testów jednostkowych. Gdy zmienia się sposób pobierania kampanii z zewnętrznego systemu, ruszasz adapter. Gdy dochodzi nowa zasada przejścia statusu zamówienia, modyfikujesz model domenowy. Zmiana ma swój adres, zamiast błąkać się po całym projekcie jak zagubiony merge.

Drugi scenariusz: proces z dużą ilością I/O i niewielką domeną

Nie każdy system ma ciężką domenę. Czasem problemem nie są złożone reguły, tylko rozbudowany przepływ między usługami: import danych, walidacja, wzbogacenie rekordów, zapis, wysłanie komunikatu, aktualizacja statusu. W takim przypadku przesadne modelowanie obiektowe często daje marny zwrot. Bardziej opłaca się trzymać obiekty na granicach odpowiedzialności, a środek procesu zbudować jako serię czytelnych transformacji.

Dobrym przykładem jest moduł przetwarzania feedu produktowego. Adapter pobiera plik lub odpowiedź API. Następnie kilka funkcji czyści dane, normalizuje pola, wykrywa konflikty i przygotowuje komendy do zapisu. Obiekt lub niewielki komponent aplikacyjny kontroluje przebieg procesu, limity błędów, idempotencję i komunikację z repozytorium. To nie jest triumf jednego paradygmatu nad drugim. To zwykły zdrowy rozsądek: funkcje tam, gdzie masz potok danych; obiekty tam, gdzie trzeba utrzymać stan procesu i kontrakt z otoczeniem.

Właśnie w takich modułach widać, że „wszystko jako encja” bywa równie nietrafione jak „wszystko jako pipeline”. Jeżeli proces pamięta, co już przetworzył, ma retry, checkpointy i ograniczenia wykonania, to jednak posiada własną tożsamość i stan. Jeśli natomiast pojedynczy krok tylko mapuje jedną strukturę na drugą, robienie z niego klasy z trzema interfejsami to sztuka dla sztuki.

Dwóch programistów omawia kod na ekranie w biurze
Źródło: Pexels | Autor: Mikhail Nilov

Po czym poznać, że zespół miesza style dojrzale, a nie przypadkiem

Najbardziej praktyczne kryterium jest zaskakująco proste: czy nowa osoba w projekcie potrafi przewidzieć, gdzie dodać zmianę. Jeżeli reguła biznesowa raz ląduje w encji, raz w serwisie aplikacyjnym, raz w helperze util, a raz w mapperze, to problemem nie jest brak znajomości FP albo OOP. Problemem jest brak konsekwencji.

Dojrzały układ zwykle ma kilka rozpoznawalnych zasad. Reguły czysto obliczeniowe są wydzielone i jawne. Obiekty domenowe nie są wyłącznie pojemnikami na dane. Warstwa aplikacyjna orkiestruje, ale nie przejmuje całej odpowiedzialności za biznes. Infrastrukturę da się wymienić lub przynajmniej odseparować bez przepisywania połowy reguł. I co równie ważne: zespół umie nazwać wyjątki od tych zasad, zamiast udawać, że ich nie ma.

To ostatnie bywa niedoceniane. Dobra konwencja nie polega na tym, że wszystko zawsze wygląda identycznie. Polega na tym, że odstępstwo ma powód. Jeśli jedna reguła zostaje w warstwie aplikacyjnej, bo wymaga decyzji zależnej od kilku systemów zewnętrznych i chwilowo nie ma sensownego modelu domenowego, to jest to normalne. Kłopot zaczyna się wtedy, gdy każda taka decyzja jest „chwilowa”, a pół roku później projekt przypomina magazyn rzeczy odłożonych na moment.

Mała zasada decyzyjna, która oszczędza dużo sporów

Jeśli fragment kodu ma odpowiadać na pytanie „co z tych danych wynika?”, zwykle dobrze znosi styl funkcyjny. Jeśli ma pilnować pytania „w jakim stanie ten obiekt może się znaleźć i kto za to odpowiada?”, zwykle lepiej czuje się w modelu obiektowym. Jeśli robi jedno i drugie naraz, to najpierw rozdziel problem, a dopiero potem wybieraj formę.

Ta zasada nie jest efektowna, nie nadaje się na konferencyjny slogan i raczej nie wywoła architektonicznych oklasków. Za to porządkuje kod lepiej niż wiele ambitnych diagramów. W praktyce właśnie o to chodzi: żeby rozwój był szybszy, testy mniej kapryśne, a zmiany trafiały tam, gdzie naprawdę powinny.

Najważniejsze wnioski

  • Najpierw trzeba nazwać problem, a dopiero potem wybierać styl: jeśli dany fragment systemu ma stan, cykl życia i ograniczenia przejść, zwykle lepiej modelować go obiektowo; jeśli chodzi o przekształcanie danych, walidację i obliczenia, prostsze będą funkcje.
  • Łączenie OOP i FP ma sens tylko wtedy, gdy porządkuje odpowiedzialności, a nie dokłada architektonicznych dekoracji. Klasa dla trzech linii mapowania danych to raczej przerost formy nad treścią.
  • Największa korzyść z hybrydy to wyraźna granica między logiką biznesową a efektami ubocznymi: łatwiej wskazać, co testować szybko jako czyste funkcje, a co zostawić do testów integracyjnych i scenariuszowych.
  • Obiekty powinny pilnować spójności i reguł stanu w miejscach takich jak zamówienie, konto użytkownika czy subskrypcja, gdzie niedozwolone przejścia naprawdę mają znaczenie. Dzięki temu reguła typu „opłaconego zamówienia nie anulujesz po wysyłce” nie ląduje w sześciu miejscach naraz.
  • Funkcje sprawdzają się tam, gdzie nie ma tożsamości ani ukrytego życia wewnętrznego: w kalkulacjach, walidacjach, transformacjach i regułach obliczeniowych. To zwykle ogranicza ukrytą mutację i eliminuje niespodzianki w stylu „coś gdzieś się samo przestawiło”.