Pamiętam, kiedy dostałem wraz z zespołem swój pierwszy projekt greenfield. To było mniej więcej tak…
Siadamy do klawiatury i… lecimy z tym!
- Feature A: 1 dzień i zrobione.
- Feature B: dobrze idzie, kolejny dzień i zrobione.
- Feature C: kiedy to będzie gotowe? Jeszcze nie wiem… estymacje nam się rozjeżdżają. Miały być tylko 3 “story
pointy”.
- Feature D: jeszcze chwila, na jutro. Ale wczoraj mówiłeś, że na jutro.
- Feature XX: to wszystko trzeba przepisać. Następnym razem będzie lepiej.
Potem był kolejny projekt. W międzyczasie nauczyłem się, czym jest Architektura Hexagonalna — teraz na pewno będzie
lepiej.
- Feature A: 1 dzień i zrobione.
- Feature B: idzie, ale zahacza o model z Feature A — trzeba się wczytać co zrobił tam programista, może coś
zmienić.
- Feature C: już skończyliśmy, ale mały refaktor, bo kolega zapomniał zrobić portu i mamy zależność do providera
notyfikacji.
- Feature D: Kasia i Tomek blokują się na
Reservation. Ktoś musi poczekać.
- Feature E: moglibyśmy to mieć w godzinę, ale muszę dorobić mapowania między warstwami.
- Feature F: jeszcze chwila, zrobię to na jutro. Głosy: “Ale wczoraj mówiłeś, że na jutro”
- Feature XX: to wszystko trzeba przepisać. W międzyczasie byłem na prezentacji o mikroserwisach… teraz wiem co
poszło nie tak.
Wiem? Oczywiście bardzo się myliłem… bo to nie stack technologiczny zabija projekty — a coś zupełnie
innego.
Nauczyłem się na swoich błędach, po to, żebyś Ty nie musiał/a.
Nie chcesz powtórzyć tych samych co ja? Dalej przeczytasz, co ja chciałbym wiedzieć kilka lat temu.
Z życia na kodach
No dobra, ale chwila… chwila…
Przecież zrobiliśmy wzorowo te porty i adaptery. Domena była odcięta od frameworka i bazy danych.
Testy latały w pamięci na implementacji InMemory - trwały tylko milisekundy. “Możemy podmienić Postgresa na Mongo z
dnia na dzień albo providera notyfikacji” — pochwaliliśmy się na demo.
I rzeczywiście — mogliśmy. Tylko że nikt tego nigdy nie potrzebował.
A czego potrzebowaliśmy? Żeby Kasia mogła w spokoju dorzucić funkcjonalność rezerwacji, podczas gdy Tomek robi
odwoływanie rezerwacji, a Piotrek pracuje nad wdrażaniem GDPR.
Niestety… prawie każdy PR konfliktował na wspólnym modelu domenowym — czy to był User, czy Reservation.
Dwóch deweloperów, dwa nowe feature’y, jeden model domenowy. Porty i Adaptery nie pomogły. Ani trochę, bo po prostu
nie o tym jest ta architektura.
Nie mogę pracować. Czekam, ale aktywnie…
Wyobraź sobie sytuację.
Dostajesz prosty task: zaimplementuj “odblokowywanie siedzenia w sali kinowej”.
Brzmi banalnie.
Otwierasz projekt i okazuje się, że żeby to zrobić, musisz przeczytać i zmodyfikować SeatService, Seat,
SeatRepository. A User — oczywiście, że tak. Przecież wszystko łączy się z Userem, każda akcja jest przez kogoś
wykonywana.
Otwierasz repository, bo będziesz komunikował się z bazą danych.
W repository jest 50 metod. Niepowiązanych żadnym use casem. Należących do “jednego portu”.
Użyjesz jednej (albo może dopiszesz nową). Ale i tak musisz wiedzieć, że pozostałe 49 istnieje, bo któraś może mieć
znaczenie dla Twojej zmiany. Każdy feature zostawił tutaj swój ślad.
Oczywiście nie czytaj tego teraz — nikt by tego nie chciał zobaczyć w takim artykule, a co dopiero w projekcie… jednak
to jest rzeczywistość (btw. ten interfejs też zaczął się od 2 metod).
interface SeatRepository {
fun findById(seatId: SeatId): Seat?
fun findByIdOrThrow(seatId: SeatId): Seat
fun save(seat: Seat): Seat
fun saveAll(seats: List<Seat>): List<Seat>
fun findByScreeningId(screeningId: ScreeningId): List<Seat>
fun findAvailableByScreeningId(screeningId: ScreeningId): List<Seat>
fun findBlockedByScreeningId(screeningId: ScreeningId): List<Seat>
fun findByScreeningIdAndStatus(screeningId: ScreeningId, status: SeatStatus): List<Seat>
fun countAvailableByScreeningId(screeningId: ScreeningId): Int
fun countBlockedByScreeningId(screeningId: ScreeningId): Int
fun findByUserId(userId: UserId): List<Seat>
fun findByUserIdAndScreeningId(userId: UserId, screeningId: ScreeningId): List<Seat>
fun findConfirmedByUserId(userId: UserId): List<Seat>
fun findPendingByUserId(userId: UserId): List<Seat>
fun countReservationsByUserId(userId: UserId): Int
fun findSoldByScreeningId(screeningId: ScreeningId): List<Seat>
fun countSoldByScreeningId(screeningId: ScreeningId): Int
fun findRevenueDataByScreeningId(screeningId: ScreeningId): List<SeatRevenueView>
fun findByDateRange(from: LocalDate, to: LocalDate): List<Seat>
fun countByDateRange(from: LocalDate, to: LocalDate): Int
fun findMostPopularRows(screeningId: ScreeningId, limit: Int): List<String>
fun findAwaitingPaymentByScreeningId(screeningId: ScreeningId): List<Seat>
fun findExpiredReservations(before: Instant): List<Seat>
fun markPaymentExpired(seatIds: List<SeatId>): Int
fun findByPaymentId(paymentId: PaymentId): List<Seat>
fun findByHallId(hallId: HallId): List<Seat>
fun findByHallIdAndRow(hallId: HallId, row: String): List<Seat>
fun markAsUnavailable(seatIds: List<SeatId>): Int
fun markAsAvailable(seatIds: List<SeatId>): Int
fun findMaintenanceSeatsByHallId(hallId: HallId): List<Seat>
fun updateHallLayout(hallId: HallId, seats: List<Seat>): List<Seat>
fun findReservationsExpiringBetween(from: Instant, to: Instant): List<Seat>
fun findWithUpcomingScreeningByUserId(userId: UserId): List<Seat>
fun findByScreeningIdAndPriceCategory(screeningId: ScreeningId, category: PriceCategory): List<Seat>
fun findByScreeningIdAndRowIn(screeningId: ScreeningId, rows: List<String>): List<Seat>
fun findAdjacentAvailableSeats(screeningId: ScreeningId, count: Int): List<List<Seat>>
fun existsByScreeningIdAndSeatNumber(screeningId: ScreeningId, seatNumber: String): Boolean
fun deleteByScreeningId(screeningId: ScreeningId): Int
fun deleteExpiredTemporaryReservations(before: Instant): Int
}
Czy Port odcina domenę od bazy? Tak. Czy to pomogło nam dowieźć funkcjonalność szybciej? Nie.
A nagle i tak okazuje się, że nikt nie zaimplementował jeszcze “blokowania siedzenia” - więc zblokowany to jest właśnie
Twój task i po prostu musisz czekać.
Każdy nowy feature wymaga modyfikacji tego, co zrobiliśmy wcześniej (np. dołożenia property do encji). Albo przynajmniej
zapoznania się z tym. Koszt
każdego nowego feature’a kumuluje się z poprzednimi.
Skumulowany koszt nowych feature. Source: EventModeling.org
I tu zaczyna się prawdziwy problem — to nie jest problem techniczny. To jest problem biznesowy. Projekt idzie wolniej,
dorzucenie programistów nie pomaga (bo i tak się blokują), estymacje rozjeżdżają się coraz bardziej, a kiedy w końcu coś
dowieziemy — jest drogo, nie w terminie i nie do końca to, czego biznes chciał.
Projektów nie zabija technologia. Zabija je coupling między featureami.
Architektura oprogramowania nie jest tylko o tym, co siedzi w kodzie.
Bezpośrednio wpływa na to, jak performuje Twój zespół.
🛡️ Co Hexagonal naprawdę rozwiązuje
I tu uwaga, bo to ważne. Hexagonal Architecture to świetna rzecz w swojej działce. Konkretnie:
- Odcina domenę od frameworka i I/O. Spring, baza, broker, UI mogą się zmieniać — domena zostaje. To realna wartość,
szczególnie w długo żyjących systemach.
- Pozwala testować domenę w izolacji. Bez Spring Bootu, bez bazy, w pamięci. Testy są szybkie i deterministyczne.
- Odracza decyzje techniczne do “last responsible moment”. Wybór bazy danych przez pierwsze tygodnie projektu nie
blokuje rozwoju domeny.
- Wymusza świadomy podział: domena ↔ infrastruktura. Sam akt narysowania tej granicy ma realną wartość
architektoniczną — zmusza zespół do nazwania, co jest nasze, a co jest cudze.
Jeśli Twój zespół umie z Hexagonalem korzystać — niech korzysta dalej. Nie wyrzucaj go z
projektu po przeczytaniu tego wpisu. Lepiej mieć tę elastyczność niż jej nie mieć.
Ale…
Pytanie tylko brzmi — czy to są problemy, które masz codziennie?
Bo zauważ: strzałki na diagramie hexagona biegną między warstwami. Aplikacja → port → adapter. Adresujemy zależności,
żeby były luźno powiązane. Cudownie.
Ale ten coupling, który Cię boli każdego dnia? Ten jest wewnątrz tych warstw.
I jego diagram Portów i Adapterów w ogóle nie adresuje.
Co robimy, kiedy projekt rośnie? Dokładamy kolejne metody (np. do wspomnianego Repository). Abstrakcje pęcznieją. Żeby
dodać jeden feature, dalej latasz po wszystkich warstwach, w różnych miejscach.
Hexagon dał Ci czyste granice na zewnątrz — a totalny bałagan w środku dalej pozostał.
❌ Czego Hexagonal NIE rozwiązuje
Hexagonal nie obiecuje (a my po cichu oczekiwaliśmy): że dodanie nowego feature’a nie będzie wymagało grzebania w
dziesięciu plikach. Że programista nie będzie musiał rozumieć całego systemu, żeby dotknąć małego kawałka. Że juniora
wdrożysz w tydzień, a nie w trzy miesiące. Że AI Agent nie wybuchnie po skonsumowaniu połowy projektu w kontekście.
To są problemy couplingu między funkcjonalnościami, nie między warstwami.
A inwestujemy w hexagonal mega dużo czasu i pieniędzy, które nie wiadomo, czy się kiedyś zwrócą. Taka elastyczność
może
się przydać raz na pięć lat, kiedy będziemy wymieniać bazę.
I możemy dalej podążać w myśl Króla Juliana: “A teraz prędko, zanim dotrze do nas, że to bez sensu!”, albo… przyznać,
że codziennie boli nas coś innego.
Co naprawdę powinniśmy optymalizować
Co się najczęściej zmienia w Twoim projekcie? Baza danych? Framework? Provider notyfikacji?
Nie. Logika biznesowa.
Więc optymalizujmy pod jej łatwą zmianę. To, co zmienia się razem, trzymajmy blisko siebie w codebasie. Najlepiej w
jednym pliku albo pakiecie.
“Wszystko w jednym pliku” brzmi jak herezja. Wiem. Ale to nie jest hack — to jest świadoma decyzja, żeby slice (czyli
kawałek aplikacji odpowiadający jednemu use case’owi) był niezależny od reszty. Żeby programista pracował w
odseparowanym kawałku kodu, dokładnie wyciętym pod jeden feature.
Gwarantuję — nie skończysz na stosie zespołu, jeśli to zrobisz.
🪓 The Art of Destroying Software
W 2015 roku Greg Young wszedł na scenę z prezentacją pełną slajdów. I co? Wszystkie wyrzucił. Zostawił jeden i
powiedział: ”
I want to talk about deleting code.” Usiadł na krześle i zaczął mówić…
Jego talk najlepiej podsumują te dwa zdania:
- “The difference between great code and sucky code is the size of the programs. Nothing more.”
- “Optimize for deletability, not for change.”
Jeśli jeszcze jej nie znasz, to koniecznie zobacz TUTAJ.
To zmienia całą perspektywę. Nie próbuj przewidzieć, jak Twój kod będzie się zmieniał. Nie projektuj abstrakcji “na
przyszłość” wproawdzając przypadkową złożoność. Zamiast tego zadbaj, żeby każdy fragment dało się wyrzucić i napisać
od nowa w tydzień. Heurystyka
Younga: jeśli da się przepisać w tydzień, to:
- Da się go w tydzień zrozumieć — “by definition if you can rewrite it in a week, you can understand it in a
week”.
- Nie boisz się go wywalić, kiedy okaże się, że model był zły.
- Twój strach znika. “Your fear goes away.”
Treść tej prezentacji — choć bardzo stara, to wciąż aktualna.
Jednak od tej pory branża bardzo się rozwinęła i teraz powiem Ci więcej: pisz programy, które da się zrozumieć w
godzinę, przepisać — tak
samo.
Slice jako niezależne “mini programy”
Vertical Slice Architecture to podejście, w którym aplikację dzielisz nie na poziome warstwy (Domain, Application,
Infrastructure etc.), ale na pionowe przekroje — Slice’y — każdy odpowiadający dokładnie jednemu use c ase’owi.
Blokowanie miejsca — jeden Slice. Odblokowanie — osobny. Odczyt dostępnych miejsc — kolejny, niezależny.
Każdy Slice zawiera wszystko, czego potrzebuje: logikę, model, dostęp do danych.
Nie ma wspólnego SeatRepository z 50 metodami — jest np. BlockSeat.kt z tym, czego ten jeden use case potrzebuje.
Poniżej przykładowa implementacja blokowania miejsc na sali kinowej.
data class BlockSeats(
val screeningId: ScreeningId,
val seats: Set<SeatNumber>,
val blockadeOwner: String,
val issuedAt: Instant
)
private data class State(
val blockadeBySeat: Map<SeatNumber, String> = emptyMap(),
val screeningEndTime: Instant? = null
)
private fun decide(command: BlockSeats, state: State): List<SeatEvent> {
if (state.screeningEndTime == null)
throw IllegalStateException("Cannot block seats - screening not scheduled yet")
if (command.issuedAt.isAfter(state.screeningEndTime))
throw IllegalStateException("Cannot block seats - screening has already ended")
val seatsBlockedByOthers = command.seats.filter { seat ->
val blockedBy = state.blockadeBySeat[seat]
blockedBy != null && blockedBy != command.blockadeOwner
}
if (seatsBlockedByOthers.isNotEmpty())
throw IllegalStateException("Cannot block seats - already blocked by others: $seatsBlockedByOthers")
return command.seats.mapNotNull { seat ->
when (state.blockadeBySeat[seat]) {
null -> SeatBlocked(command.screeningId, seat, command.blockadeOwner, command.issuedAt)
command.blockadeOwner -> null
else -> null
}
}
}
private fun evolve(state: State, event: CinemaEvent): State = when (event) {
is ScreeningScheduled -> state.copy(screeningEndTime = event.endTime)
is SeatBlocked -> state.copy(blockadeBySeat = state.blockadeBySeat + (event.seat to event.blockadeOwner))
is SeatUnblocked -> state.copy(blockadeBySeat = state.blockadeBySeat - event.seat)
else -> state
}
Nie ma tu nic o innych use case’ach. Ten plik wie tylko tyle, ile musi — i nic więcej.
Pełną implementację znajdziesz na GitHub: Cinema.EventSourcing.VerticalSlice.DCB.Kotlin.Axon5.Spring.
Co łączy te Slice’y ze sobą? Eventy. Jeden Slice coś robi i publikuje zdarzenie — “miejsce zostało zablokowane”.
Drugi to widzi i reaguje — np. aktualizuje widok dostępnych miejsc, inicjalizuje płatność.
Slice’y nie wiedzą nic o sobie nawzajem, nie importują klas jeden od drugiego. Jedyna zależność między nimi to kształt
tego zdarzenia, który określa kontrakt. Zmień coś wewnątrz Slice’a — reszta tego nawet nie poczuje.
Możesz go dokładnie wyrzucić czy przepisać od nowa.
Jak to zrobić w praktyce? Jeśli jeszcze nie widziałeś — koniecznie przeczytaj moją serię, gdzie wyjaśniam Vertical Slice
Architecture, na przykładzie Heroes III:
https://nakodach.pl/domain-driven-design/heroes/eventmodeling-jakosc-bez-codereview/
Vertical Slice architecture dzieli naszą aplikację na w pełni niezależne kawałki. Każdy można implementować osobno - nie czekając na resztę.
(Kliknij obrazek, aby powiększyć)
Dzięki takiemu podejściu twoje mini moduły — tzw. Slice’y — stają się naprawdę niezależne.
Programista pracuje sam, nie blokuje go inny task. Nie musi rozumieć całego systemu — tylko eventy między slice’ami,
które są kontraktem.
Nowi w projekcie? Wdrażają się w godziny, nie w tygodnie — bo muszą zrozumieć jeden mały skrawek, a kolejne slice’y
robią przez analogię.
Koszt feature’a przestaje się kumulować. Każdy slice to mniej więcej tyle samo pracy, niezależnie od tego, czy jest
pierwszy, czy setny.
AI Agent dostaje wyizolowany kontekst — jeden plik, jedna specyfikacja. Nie halucynuje, bo nie ma jak. A Ty rano
znajdujesz zrobione slice’y, które puściłeś w pętli na noc.
Jak się nie rozczarować?
Hexagonal Architecture nie zawodzi. Robi dokładnie to, co obiecuje — i nic więcej. To my spodziewaliśmy się czegoś,
do czego nie został zaprojektowany.
Jeśli chcesz mieć elastyczność na wymianę bazy raz na dekadę — hexagonal jest super.
Jeśli chcesz mieć elastyczność na dodanie kolejnego feature’a w przyszły piątek bez bólu — potrzebujesz Vertical Slice
Architecture.
I tu ważna rzecz: to nie są podejścia sprzeczne. Możesz stosować Porty i Adaptery wewnątrz Slice’ów — jeśli masz ku
temu powód.
Każdy Slice to niezależny kawałek aplikacji i sam decyduje, jak chce wyglądać od środka.
Jeden może mieć port do bazy, drugi może trafiać do niej bezpośrednio — bo jest na tyle prosty, że warstwa abstrakcji
tylko by przeszkadzała.
VSA rozwiązuje coupling między feature’ami.
Hexagonal rozwiązuje coupling między logiką a infrastrukturą.
To dwa różne poziomy, dwa różne problemy — można je adresować jednocześnie.
Jak to zrobić dobrze krok po kroku — już w kolejnych wpisach, gdzie pokażę jak projektować i budować takie slice’y z
pomocą Event Modelingu i narzędzi AI. A jeśli wolisz najpierw solidne fundamenty bez AI — od tego jest właśnie moja
seria Heroes of Domain-Driven Design,
gdzie tłumaczę Event Modeling i VSA na przykładzie Heroes III.
Ale mam dla Ciebie też lepszą propozycję!
📚 Chcesz to zobaczyć w praktyce? Kurs SSJ - Super Senior Java
Jeśli chcesz wejść głębiej w Vertical Slice Architecture, Event Modeling, a nawet Dynamic Consistency Boundary (jeszcze
lepsza modularność aplikacji), i nauczyć się jak wdrażać to
produkcyjnie (a nie tylko na POC) — wraz z Cezarym Saneckim i Arturem Laskowskim z JavaSenior.pl robię kurs *
🦆 SuperSeniorJava.pl, gdzie pokazuję dokładnie ten proces krok po
kroku.
W dobie AI seniorzy muszą się dalej rozwijać. A pisanie kodu, w którym człowiek, a także Agent AI się świetnie
odnajdzie (bo dostaje wyizolowany, niezależny slice zamiast splątanego spaghetti), to jedna z najważniejszych
umiejętności na najbliższe lata.
❤️ Inni też tym żyją
Jeśli chcesz pójść jeszcze głębiej w temat, zobacz: