Wzorzec buildera pojawia się wtedy, gdy obiekt zaczyna mieć za dużo opcji, a konstruktor staje się nieczytelny albo podatny na pomyłki. Temat znany też skrótowo jako kotlin builder dobrze pokazuje, jak w Kotlinie budować konfiguracje, drzewa danych i obiekty z wieloma ustawieniami bez utraty przejrzystości. Poniżej rozkładam to na części: od sensu użycia, przez klasyczną implementację, po bardziej idiomatyczne podejście z lambdą z receiverem i praktyczne kryteria wyboru.
Najważniejsze decyzje przy builderze w Kotlinie
- Builder ma sens głównie tam, gdzie obiekt ma wiele opcjonalnych pól, zagnieżdżoną strukturę albo wymaga walidacji przed utworzeniem.
- W prostych przypadkach zwykle wystarczą argumenty nazwane, wartości domyślne lub metoda `copy()` na `data class`.
- Klasyczny builder nadal bywa dobrym wyborem przy interopie z Javą i w prostych, płaskich konfiguracjach.
- W Kotlinie często lepiej sprawdza się builder oparty na lambdzie z receiverem, bo daje czytelniejszy zapis i lepiej pasuje do stylu języka.
- Najczęstszy błąd to nadmiar abstrakcji: builder powinien upraszczać kod, a nie ukrywać reguły biznesowe.
Kiedy builder naprawdę rozwiązuje problem
Ja sięgam po builder wtedy, gdy konstrukcja obiektu przestaje być jednorazowym przypisaniem kilku pól, a zaczyna przypominać etapowy proces. Najczęściej dzieje się to w kodzie związanym z API, konfiguracją klienta HTTP, generowaniem treści HTML, budowaniem zapytań albo składaniem większych struktur z mniejszych elementów. W takich przypadkach builder porządkuje kod, bo oddziela tworzenie obiektu od jego późniejszego użycia.
Builder ma sens zwłaszcza wtedy, gdy potrzebujesz jednocześnie kilku rzeczy:
- dużej liczby pól opcjonalnych,
- walidacji przed utworzeniem obiektu,
- czytelnego zapisu krok po kroku,
- składania obiektów zagnieżdżonych,
- API, które ma być wygodne dla innych programistów.
Jeśli jednak masz prosty model z trzema parametrami, builder zwykle tylko wydłuża drogę do celu. W Kotlinie to ważna różnica, bo język sam daje już wygodne narzędzia do prostych przypadków, więc builder warto traktować jako rozwiązanie na konkretny problem, a nie domyślny wzorzec. Z tego właśnie powodu najpierw pokażę klasyczną wersję, a dopiero potem bardziej idiomatyczne podejście.
Jak wygląda klasyczny builder w Kotlinie
Klasyczny builder wciąż jest zrozumiały i prosty do wdrożenia. Składa się zazwyczaj z mutowalnej klasy pomocniczej, metod ustawiających kolejne pola oraz metody `build()`, która zwraca gotowy, najlepiej niemutowalny obiekt. W praktyce często wygląda to tak, zwłaszcza przy konfiguracji związanej z siecią lub integracjami webowymi:
data class HttpRequest(
val method: String,
val url: String,
val headers: Map,
val timeoutMs: Int
)
class HttpRequestBuilder {
var method: String = "GET"
var url: String = ""
var timeoutMs: Int = 5000
private val headers = linkedMapOf()
fun header(name: String, value: String) = apply {
headers[name] = value
}
fun build(): HttpRequest {
require(url.isNotBlank()) { "URL jest wymagany" }
require(timeoutMs > 0) { "timeoutMs musi być dodatni" }
return HttpRequest(
method = method,
url = url,
headers = headers.toMap(),
timeoutMs = timeoutMs
)
}
} Użycie jest równie proste:
val request = HttpRequestBuilder().apply {
method = "POST"
url = "/api/posts"
timeoutMs = 3000
header("Accept", "application/json")
}.build()Ten wariant ma jedną istotną zaletę: jest oczywisty dla osób, które przychodzą z Javy albo z bardziej klasycznego stylu programowania obiektowego. Ma też jednak koszt. Builder jest mutowalny, więc trzeba pilnować, żeby nie wyciekał poza fazę budowania i nie był używany w kilku miejscach naraz. Dla mnie to nadal dobra opcja, ale tylko wtedy, gdy prostsze rozwiązania nie wystarczają. A kiedy kod ma być bardziej naturalny po kotlinowemu, zwykle przechodzę na lambdę z receiverem.
Builder z lambdą z receiverem jest zwykle bardziej idiomatyczny
Dokumentacja Kotlin pokazuje, że jednym z najmocniejszych zastosowań lambd z receiverem są właśnie type-safe builders. I rzeczywiście: kiedy zamiast łańcucha wywołań zaczynasz opisywać strukturę w bloku konfiguracji, kod staje się bardziej deklaratywny, a mniej proceduralny. W standardowej bibliotece widać to choćby w funkcjach takich jak `buildList` i `buildString`, które budują wynik wewnątrz bloku, zamiast zmuszać cię do ręcznego składania wszystkiego krok po kroku.
Ten sam pomysł można zastosować do własnego API:
fun buildRequest(block: HttpRequestBuilder.() -> Unit): HttpRequest =
HttpRequestBuilder().apply(block).build()
val request = buildRequest {
method = "POST"
url = "/api/posts"
timeoutMs = 3000
header("Accept", "application/json")
}Tu ważne jest to, że blok `block` działa na obiekcie `HttpRequestBuilder` jako na ukrytym `this`. Dzięki temu wewnątrz lambdy mogę pisać `method = "POST"` zamiast `builder.method = "POST"`. To drobna różnica w składni, ale duża różnica w czytelności, szczególnie gdy konfiguracja zawiera więcej pól lub gdy budujesz strukturę zagnieżdżoną, jak dokument HTML, formularz czy mapę reguł walidacji.
W takim stylu często używa się też `@DslMarker`, gdy w środku jednego buildera pojawia się drugi builder. To zabezpieczenie ogranicza dostęp do zewnętrznych receiverów i zmniejsza ryzyko przypadkowego wywołania metody z niewłaściwego poziomu. Ja traktuję to jako sygnał, że API przestaje być prostą konfiguracją i zaczyna przypominać mały język domenowy. Gdy to już wiesz, sensownie jest porównać builder z innymi opcjami, bo nie zawsze musi on wygrać.
Builder, named arguments i `copy` nie robią tego samego
Najczęstszy błąd, jaki widzę, to traktowanie buildera jako domyślnej odpowiedzi na każdy problem z konstrukcją obiektu. W Kotlinie bardzo często lepsze są po prostu argumenty nazwane, wartości domyślne albo `copy()` na `data class`. Builder ma swoje miejsce, ale nie powinien zasłaniać prostszych rozwiązań.
| Podejście | Kiedy sprawdza się najlepiej | Plusy | Ograniczenia |
|---|---|---|---|
| Konstruktor z argumentami nazwanymi | Proste modele z kilkoma polami | Najkrótszy zapis, mało kodu, dobra czytelność | Słabsze przy wielu opcjach i walidacji etapowej |
| `data class` + `copy()` | Modyfikacja istniejącego obiektu | Immutability, jasny zamiar zmiany | Nie zastępuje procesu budowania od zera |
| Klasyczny builder | Płaskie, ale rozbudowane konfiguracje | Znany wzorzec, dobry przy interopie z Javą | Więcej kodu, mutowalne wnętrze |
| Builder z lambdą z receiverem | Hierarchiczne struktury i wewnętrzne DSL-e | Bardzo czytelny zapis, naturalny w Kotlinie | Łatwo przesadzić z abstrakcją i scope’em |
W praktyce wygrywa nie najbardziej „wzorcowy” wariant, tylko ten, który najlepiej pasuje do problemu. Ja zwykle zaczynam od najprostszego rozwiązania i dopiero gdy pojawia się ból w czytelności albo walidacji, przechodzę na builder. Taka kolejność zwykle oszczędza sporo zbędnego kodu, a jednocześnie nie zamyka drogi do bardziej zaawansowanej wersji. Skoro to już jasne, warto zobaczyć, gdzie buildery najczęściej psują się w realnych projektach.
Najczęstsze błędy przy projektowaniu builderów
Builder potrafi bardzo ułatwić życie, ale równie łatwo robi się z niego ciężka warstwa pośrednia. Najczęściej problem zaczyna się wtedy, gdy kodowi daje się zbyt wiele odpowiedzialności naraz: budowanie, walidację, transformacje i logikę biznesową. To zwykle nie kończy się dobrze, bo sam wzorzec zaczyna przykrywać to, co powinno zostać proste.
- Zbyt późna walidacja - jeśli błędne dane wychodzą dopiero po zbudowaniu obiektu, debugowanie staje się niepotrzebnie trudne.
- Mutowalny builder używany po utworzeniu obiektu - po `build()` builder powinien być traktowany jak narzędzie jednorazowe albo co najmniej lokalne.
- Nadmierna liczba metod - jeśli każdy setter ma osobną logikę i osobne warunki, masz już mini-framework, nie builder.
- Brak ograniczenia zakresu w DSL-u - przy zagnieżdżeniach bez `@DslMarker` łatwo wywołać metodę z niewłaściwego poziomu.
- Builder tam, gdzie wystarczyłby konstruktor - w prostych modelach to po prostu narzut poznawczy.
Ja trzymam się też jednej praktycznej zasady: jeśli builder wymaga długiego komentarza, to zwykle znaczy, że problem leży w projekcie API, a nie w samym języku. Kotlin daje dużo narzędzi, ale nie zwalnia z prostego myślenia o granicach odpowiedzialności. Gdy te granice są pilnowane, zostaje już tylko pytanie o wybór wariantu, który najlepiej pasuje do konkretnego kodu.
Jak wybrać wariant, który nie przerasta problemu
Ja stosuję prostą regułę: jeśli obiekt da się zrozumieć z jednego spojrzenia, nie dokładam buildera tylko dlatego, że „tak się robi w wzorcach”. Najpierw patrzę na strukturę danych, potem na liczbę opcjonalnych pól, potem na to, czy konfiguracja ma charakter hierarchiczny. Dopiero na końcu decyduję, czy potrzebny jest klasyczny builder, czy od razu warto iść w lambdę z receiverem.
- Wybierz argumenty nazwane i wartości domyślne, jeśli konfiguracja jest krótka i statyczna.
- Wybierz `copy()` na `data class`, jeśli najczęściej modyfikujesz istniejący obiekt.
- Wybierz klasyczny builder, jeśli API musi być zrozumiałe także z poziomu Javy albo ma być bardzo oczywiste dla szerokiego zespołu.
- Wybierz builder z lambdą z receiverem, jeśli budujesz strukturę przypominającą DSL albo zależy ci na naturalnym, deklaratywnym zapisie.
- Dodaj walidację w `build()`, ale nie ukrywaj tam całej logiki biznesowej.
Jeśli miałbym zamknąć temat jednym zdaniem, powiedziałbym tak: najlepszy builder to nie ten najbardziej rozbudowany, tylko ten, który skraca kod, zmniejsza liczbę pomyłek i nie zaciera sensu modelu. W Kotlinie bardzo często oznacza to przejście od klasycznego wzorca do lżejszego, bardziej idiomatycznego DSL-u, ale tylko wtedy, gdy problem rzeczywiście tego wymaga.