💔 Hexagonal Architecture Ci w tym nie pomoże (ale Vertical Slices już tak)

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 {

    // --- rezerwacja miejsc ---
    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>

    // --- blokowanie / odblokowywanie ---
    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

    // --- rezerwacje użytkownika ---
    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

    // --- raportowanie i statystyki ---
    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>

    // --- płatności ---
    fun findAwaitingPaymentByScreeningId(screeningId: ScreeningId): List<Seat>
    fun findExpiredReservations(before: Instant): List<Seat>
    fun markPaymentExpired(seatIds: List<SeatId>): Int
    fun findByPaymentId(paymentId: PaymentId): List<Seat>

    // --- administracja salą ---
    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>

    // --- notyfikacje ---
    fun findReservationsExpiringBetween(from: Instant, to: Instant): List<Seat>
    fun findWithUpcomingScreeningByUserId(userId: UserId): List<Seat>

    // --- wyszukiwanie i filtrowanie ---
    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

    // --- cache / invalidacja ---
    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.

Rosnący koszt nowego feature

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.

// BlockSeats.kt — wszystko w jednym pliku: Domain, Application, Presentation

// --- Command ---
data class BlockSeats(
    val screeningId: ScreeningId,
    val seats: Set<SeatNumber>,
    val blockadeOwner: String,
    val issuedAt: Instant
)

// --- State (tylko to, czego ten use case potrzebuje - np. nazwa seansu nie ma znaczenia dla decyzji czy mogę zarezerwować miejsce) ---
private data class State(
    val blockadeBySeat: Map<SeatNumber, String> = emptyMap(),
    val screeningEndTime: Instant? = null
)

// --- Logika biznesowa ---
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 // idempotent
            else -> null
        }
    }
}

// --- Stan odtwarzany z eventów ---
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/

Na powyższym przykładzie rozpatrujemy stronę zapisu (write slice).

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:

Podziel się wpisem:

✉️ Lista Mailingowa

Otrzymasz materiały o Event Sourcingu, Event Modelingu, Domain-Driven Design, programowaniu obiektowym i funkcyjnym oraz innych powiązanych tematach. A także zaproszę Cię na wspólne sesje modelowania.

🫱🏻‍🫲🏽 Mentoring Online

Nauka Domain-Driven Design? Podział projektu na moduły? Zaplanowanie architektury? Konsultacja CV? A może rozwój w kierunku Seniora? Spotkajmy się! Umów się na mentoring lub konsultacje. Wspólnie opracujemy plan dla Ciebie 🫵

📖 SOLIDne CV?

🤔 Szukasz teraz nowej pracy? Masz wymagane kompetencje, ale nikt nie daje Ci szansy ich zaprezentować? Zmień to!

CV na kodach

💪 Przeprowadziłem ponad 100 rozmów rekrutacyjnych i widziałem tysiące CV. Nieraz miałem okazję porównać CV odrzucane, z tymi które robiły największe wrażenie. Na tej podstawie opracowałem porady, które pomogą Ci się pozytywnie wyróżnić i przejść ten etap rekrutacji.

Więcej o życiu na kodach

🍕 Dynamic Consistency Boundary: SourcingCriteria != AppendCriteria

Nie każde zdarzenie, które wpływa na Twoją decyzję, może złamać Twój niezmiennik. Rozdzielenie SourcingCriteria i AppendCriteria w DCB pozwala odblokować współbieżność bez poświęcania spójności. DCB w końcu pozwala osiągnąć to co obiecywały Agregaty w Domain-Driven Design: granice spójności jak najmniejsze, ale wystarczająco duże.

⚔️ Moc i magia Domain-Driven Design w świecie Heroes III: Mapa Kontekstów i Hydra - co mają wspólnego i jak kąsają Twój biznes?

Zobrazujemy zależności między modułami i zespołami — kto będzie musiał się do kogo dostosować, a gdzie potrzebne są wspólne ustalenia i partnerstwo. Context Mapping jest w stanie uwidocznić Ci problemy niedostrzegalne na pierwszy rzut oka, ale niestety odczuwalne przez wszystkich. Możesz wziąć niebieską pigułkę i dalej żyć w błogiej nieświadomości, aż brutalne realia projektu nie wyrwą Cię z tego marazmu. Albo przeczytać ten wpis...

⚔️ Moc i magia Domain-Driven Design w świecie Heroes III: EventStorming, Event Modeling, stawianie granic i wysoka jakość bez code review

W jaki sposób zbudować model architektury, gotowy do przełożenia na kod? Jak wyznaczyć co musimy zrobić najpierw i nad czym możemy pracować równocześnie? Dostrzeż w fantastycznej domenie Heroes III analogie do realnych projektów i sprawdź jak wykorzystać DDD i Event Modeling w Twojej pracy. Przeprowadzisz szybkie eksperymenty, zwiększysz jakość i unikniesz naparzanek przy code review, a dodatkowo zaoszczędzisz mnóstwo czasu i pieniędzy.

Mailing Domain-Driven Design

Wciąż za mało życiowych cheatów?

Zostaw swój adres e-mail i zobacz moje spojrzenie na codzienność programisty.

Na sam początek opowiem Ci o zetknięciu z Domain-Driven Design, zmianie myślenia i nowej erze mojego programistycznego ja.

Możesz liczyć na materiały o Event Sourcingu, Event Modelingu, DDD, programowaniu obiektowym i funkcyjnym oraz innych powiązanych tematach.

Na pewno poświęcę trochę maili umiejętnością miękkim.

Będziesz też informowany o nowościach Życia na kodach prosto na Twoją skrzynkę!

SzkolenieWarsztaty dla zespołów
Event Modeling
  • Proces, który buduje zaufanie
  • Od modelu wprost do kodu — 1:1
  • Idealny kontekst dla ludzi i AI
  • Spec-Driven Development zrobione dobrze
DDDEvent SourcingAIVertical Slices
Sprawdź, czy to dla Ciebie