Przez wiele lat architektura aplikacji w Javie była niemal automatycznie utożsamiana z programowaniem obiektowym. Domena miała składać się z obiektów, dane powinny być ukryte za metodami, a dobra enkapsulacja była jednym z podstawowych wyznaczników jakości projektu.
W ostatnich latach coraz częściej mówi się jednak o Data-Oriented Programming, czyli podejściu, w którym punktem wyjścia nie są obiekty i ich zachowania, lecz dane oraz ich znaczenie.
Czy oznacza to kolejną rewolucję, po której powinniśmy uznać dotychczasowe podejście za przestarzałe? A może jest to jedynie nowa nazwa dla technik, które od dawna stosowaliśmy w dobrze zaprojektowanych systemach?
Czym właściwie jest Data-Oriented Programming?
W klasycznym podejściu obiektowym dużą część uwagi poświęcamy odpowiedzialnościom klas, komunikacji pomiędzy obiektami i projektowaniu interfejsów. W DOP głównym obszarem projektowania jest reprezentacja danych oraz semantyka wynikająca z wybranych typów.
Podstawowe pytanie nie brzmi wtedy:
Co ten obiekt potrafi zrobić?
Zamiast tego zaczynamy od pytania:
Czym dokładnie jest informacja, którą próbujemy reprezentować?
DOP modeluje nie tylko stan systemu, ale również jego decyzje, operacje i reguły biznesowe jako jawne dane oraz nazwane struktury. Takie podejście sprawia, że kod dokładniej odzwierciedla domenę, ogranicza liczbę niepoprawnych stanów i ułatwia przewidywanie zachowania systemu.
Problemem często nie jest logika, lecz reprezentacja
Rozważmy prosty przykład:
1class Payment { 2 private PaymentStatus status; 3 private Instant paidAt; 4 private String transactionId; 5 private String rejectionReason; 6 // ... 7}
Na pierwszy rzut oka nie ma tutaj niczego szczególnie podejrzanego. Mamy klasę opisującą płatność, jej status oraz informacje potrzebne w poszczególnych etapach przetwarzania.
Problem pojawia się wtedy, gdy zaczniemy analizować wszystkie stany, które można utworzyć:
- płatność oczekująca z ustawionym
paidAt, - płatność zakończona bez
transactionId, - płatność odrzucona bez przyczyny odrzucenia,
- płatność zakończona, która ma jednocześnie przyczynę odrzucenia,
- płatność oczekująca, która ma ustawione wszystkie pola.
Większość tych kombinacji nie ma sensu domenowego. Mimo to reprezentacja pozwala je utworzyć. Możemy oczywiście dodać walidację, testy jednostkowe oraz metody pomocnicze interpretujące kombinacje pól.
W pewnym momencie kod zaczyna składać się głównie z zabezpieczeń przed stanami, które sami dopuściliśmy poprzez wybór nieprecyzyjnej reprezentacji.
Data-Oriented Programming proponuje rozpoczęcie rozwiązania problemu poziom wcześniej. Zamiast pytać, jak zabezpieczyć klasę, pytamy:
Jakie stany płatności rzeczywiście istnieją w naszej domenie?
Możemy reprezentować je bezpośrednio:
1sealed interface PaymentState { 2 record Pending(Instant createdAt) implements PaymentState {} 3 record Paid(Instant paidAt, TransactionId transactionId) implements PaymentState {} 4 record Rejected(Instant rejectedAt, RejectionReason reason) implements PaymentState {} 5}
Teraz płatność może być w jednym z trzech unikalnie reprezentowanych stanów (mówimy tu o sumie algebraicznej).
Każdy z tych stanów zawiera dokładnie te informacje, które mają w nim znaczenie.
Nie możemy przypadkowo utworzyć zakończonej płatności bez identyfikatora transakcji, ponieważ konstruktor typu Paid wymaga jego przekazania.
Zatem nie dodaliśmy walidacji niepoprawnego stanu - usunęliśmy możliwość jego reprezentowania.
Pattern matching jako naturalne uzupełnienie modelu
Jawnie reprezentowane dane wymagają wykonywania na nich operacji. W tym miejscu pojawia się często zarzut, że DOP prowadzi do wszechobecnych instrukcji instanceof.
W starszych wersjach Javy rzeczywiście mogło to oznaczać dużo powtarzalnego kodu. Obecnie zamknięty model danych możemy obsłużyć przy pomocy pattern matchingu:
1String describe(PaymentState state) { 2 return switch (state) { 3 case PaymentState.Pending(var createdAt) -> "Payment pending since " + createdAt; 4 case PaymentState.Paid(var paidAt, var transactionId) -> "Payment completed as " + transactionId; 5 case PaymentState.Rejected(var rejectedAt, var reason) ->"Payment rejected because of " + reason; 6 }; 7}
Kompilator zna wszystkie warianty PaymentState. Jeżeli dodamy nowy stan, na przykład Cancelled, otrzymamy informację o miejscach wymagających aktualizacji.
W klasycznym podejściu obiektowym podobny problem moglibyśmy rozwiązać przy pomocy polimorfizmu, nie zawsze jest to jednak lepsze rozwiązanie. Jeżeli operacja stanowi naturalne zachowanie danego typu i zmienia się razem z nim, polimorfizm może być właściwy. Jeżeli natomiast regularnie dodajemy nowe operacje wykonywane na stabilnym zbiorze wariantów, oddzielenie danych od operacji często okazuje się prostsze.
Dlaczego DOP stał się popularny właśnie teraz?
Większość idei stojących za DOP nie jest nowa. Value Objects, niemutowalne dane, algebraiczne typy danych, funkcje przekształcające wartości i jawne modelowanie stanów od dawna występują w programowaniu funkcyjnym, Domain-Driven Design oraz językach innych niż Java.
Nawet w Javie podobny kod można było tworzyć wiele lat temu (i nadal można):
1final class Paid implements PaymentState { 2 private final Instant paidAt; 3 private final TransactionId transactionId; 4 // konstruktor, gettery, equals, hashCode, toString... 5}
Problem polegał na tym, że koszt jego napisania był stosunkowo wysoki. Prosty model wymagał konstruktora, getterów, equals, hashCode i toString (tu biblioteka Lombok nas wspiera od wielu lat). Zamknięcie hierarchii typów opierało się głównie na konwencji. Obsługa wariantów wymagała rzutowania albo zastosowania wzorca Visitor.
Nowości w języku Java takie jak Records, sealed interfaces, switch expressions i pattern matching zmieniły tę relację kosztów. Konstrukcje, które wcześniej wymagały dużej ilości dodatkowego kodu, mogą być obecnie zapisane bezpośrednio.
Jak wprowadzać DOP do istniejącego systemu?
Najgorszym sposobem byłoby rozpoczęcie od przepisania całej aplikacji. Data-Oriented Programming dobrze nadaje się do stopniowego refaktoryzowania kodu.
Warto rozpocząć od miejsc, w których widzimy:
- wiele powiązanych wartości nullable,
- kilka flag typu boolean,
- instrukcje interpretujące kombinacje pól (złożona ifologia),
- parametry typu String, których znaczenie wynika tylko z nazwy,
- metody void, które zmieniają rozproszony stan,
- duże klasy łączące dane, logikę, persystencję i integracje,
- walidację powtarzaną w wielu miejscach,
- komentarze wyjaśniające, które kombinacje pól są prawidłowe.
Nie każda transformacja musi zakończyć się hierarchią zapieczętowanych interfejsów z klasami i rekordami. Czasami wystarczy lepsza nazwa, osobny rekord albo zastąpienie String konkretnym typem.
Ewolucja czy rewolucja?
Z perspektywy historii programowania Data-Oriented Programming jest przede wszystkim ewolucją.
Łączy idee znane z programowania funkcyjnego, Domain-Driven Design, algebraicznych typów danych i architektury opartej na jawnych granicach. Można było stosować je również w starszych wersjach Javy, choć wymagało to większej ilości kodu.
Z perspektywy typowej aplikacji tworzonej przez lata zgodnie z klasycznym OOP może być jednak rewolucją w sposobie myślenia.
Nowoczesna Java sprawiła, że odpowiedzi na te pytania można zapisać bez ogromnej ilości kodu infrastrukturalnego. Records, sealed interfaces i pattern matching nie tworzą Data-Oriented Programming, ale sprawiają, że jego stosowanie staje się naturalne.
Dlatego najtrafniejsza odpowiedź brzmi:
Data-Oriented Programming jest ewolucją stosowanych przez nas technik, ale może być rewolucją w sposobie projektowania kodu.
Nie musimy porzucać programowania obiektowego. Powinniśmy natomiast przestać traktować je jako jedyną właściwą odpowiedź na każdy problem.
Czasami najlepszym obiektem domenowym jest obiekt posiadający zachowanie i pilnujący własnego cyklu życia. A czasami dane powinny pozostać po prostu danymi.
Chcesz dowiedzieć się więcej o modelowaniu w stylu Data Oriented Programming z wykorzystaniem najnowszych wersji Javy? Sprawdź nasze szkolenia:




