Builder Pattern #
Coba bayangkan sebuah fungsi NewHTTPClient(timeout, maxRetries, baseURL, authToken, userAgent, keepAlive, maxIdleConns, tlsSkipVerify, proxyURL, rateLimitRPS) dengan sepuluh parameter berurutan. Kode yang memanggilnya akan terlihat seperti NewHTTPClient(30, 3, "https://api.example.com", "Bearer xyz", "MyApp/1.0", true, 10, false, "", 100) — tanpa membaca definisi fungsinya, tidak ada yang tahu parameter keempat itu authToken atau userAgent, dan tidak ada yang bisa mendeteksi kalau dua int di akhir tertukar posisinya. Builder Pattern hadir untuk menghilangkan masalah ini secara tuntas: objek dibangun secara bertahap, setiap langkah diberi nama yang jelas, hanya field yang relevan yang perlu diisi, dan validasi dijalankan tepat sebelum objek diserahkan ke pemanggilnya.
Apa itu Builder Pattern? #
Builder Pattern adalah creational design pattern yang memisahkan proses konstruksi objek dari representasi akhirnya. Alih-alih menyerahkan semua nilai melalui satu constructor sekaligus, kamu membangun objek secara bertahap — menetapkan satu field pada satu waktu — melalui serangkaian method yang ekspresif, lalu memanggil Build() untuk mendapatkan objek final yang sudah divalidasi.
Tiga properti yang membedakan Builder dari sekadar setter biasa:
- Immutability setelah
Build()— objek final tidak bisa diubah setelah diserahkan; builder adalah satu-satunya jalan untuk mengkonstruksinya - Validasi terpusat — semua invariant dan aturan bisnis divalidasi di satu tempat, tepat sebelum objek dibuat
- Fluent API — method chaining membuat kode konstruksi bisa dibaca hampir seperti kalimat biasa
flowchart LR
subgraph Tanpa Builder
C["NewUser(\n name,\n email,\n age,\n phone,\n address,\n role,\n isActive\n)"]
end
subgraph Dengan Builder
direction TB
B1["NewUserBuilder()"]
B2[".SetName(name)"]
B3[".SetEmail(email)"]
B4[".SetRole(role)"]
B5[".Build()"]
B1 --> B2 --> B3 --> B4 --> B5
end
C -->|"parameter ke-3 itu age atau phone?"| Q["❓ Rawan salah"]
B5 -->|"setiap step punya nama"| A["✓ Jelas dan aman"]
Masalah yang Dipecahkan #
Builder Pattern bukan pattern yang dibuat untuk estetika. Ada masalah konkret yang mendorongnya.
Masalah 1: Telescoping Constructor #
Semakin banyak field opsional, semakin banyak variasi constructor yang dibutuhkan jika mengikuti pendekatan konvensional:
// ANTI-PATTERN: telescoping constructor — setiap kombinasi field butuh fungsi sendiri
func NewUser(name, email string) *User { ... }
func NewUserWithAge(name, email string, age int) *User { ... }
func NewUserWithPhone(name, email, phone string) *User { ... }
func NewUserWithRole(name, email, role string) *User { ... }
func NewUserWithAgeAndPhone(name, email string, age int, phone string) *User { ... }
// ... kombinasi terus bertambah secara eksponensial
// BENAR: satu builder menangani semua kombinasi
user, err := NewUserBuilder().
SetName("Budi").
SetEmail("[email protected]").
SetAge(28).
SetPhone("+6281234567890").
SetRole("admin").
Build()
Masalah 2: Parameter Bertipe Sama yang Mudah Tertukar #
Compiler tidak akan membantu jika kamu menukar dua string atau dua int yang berurutan:
// ANTI-PATTERN: dua string berurutan — compiler tidak menangkap kesalahan ini
NewServer("localhost", "8080", "/api", "DEBUG")
// ^^host^^ ^port^ ^base^ ^level^
// Mudah tertukar menjadi:
NewServer("8080", "localhost", "/api", "DEBUG") // runtime error, bukan compile error
// BENAR: setiap nilai diberi nama eksplisit
server, err := NewServerBuilder().
SetHost("localhost").
SetPort(8080). // port sebagai int — type-safe
SetBasePath("/api").
SetLogLevel("DEBUG").
Build()
Masalah 3: Validasi Tersebar #
Tanpa builder, validasi biasanya dilakukan di berbagai tempat: di constructor, di setter, di service yang menggunakan objek. Hasilnya: aturan yang sama bisa tidak konsisten di setiap tempat.
// ANTI-PATTERN: validasi tersebar di berbagai tempat
func CreateUser(name, email string, age int) (*User, error) {
if name == "" { return nil, errors.New("name required") } // validasi di sini
user := &User{Name: name, Email: email, Age: age}
// tapi email belum divalidasi di sini — tergantung siapa yang ingat
return user, nil
}
// Di service, ada validasi lagi:
func (s *UserService) Register(name, email string, age int) error {
if !isValidEmail(email) { return errors.New("invalid email") } // duplikasi validasi
user, err := CreateUser(name, email, age)
// ...
}
// BENAR: semua validasi di Build() — satu tempat, tidak bisa terlewat
func (b *UserBuilder) Build() (*User, error) {
if b.name == "" {
return nil, errors.New("name is required")
}
if !isValidEmail(b.email) {
return nil, fmt.Errorf("invalid email: %s", b.email)
}
if b.age < 0 || b.age > 150 {
return nil, fmt.Errorf("invalid age: %d", b.age)
}
return &User{
name: b.name,
email: b.email,
age: b.age,
}, nil
}
Komponen Builder Pattern di Golang #
Di bahasa seperti Java, Builder Pattern melibatkan class Builder yang terpisah dari class Product, kadang dengan Director sebagai lapisan ketiga. Di Golang, pendekatan yang lebih pragmatis dan idiomatik digunakan — tanpa interface Builder yang wajib, cukup struct builder dengan method chaining.
flowchart TD
subgraph "Komponen Golang Builder"
direction TB
P["Product (struct dengan field unexported)\nHanya bisa dibuat melalui builder"]
B["Builder (struct terpisah)\nMenyimpan nilai sementara"]
M["Fluent Methods\n.SetX() *Builder — return self untuk chaining"]
V["Build()\nValidasi + konstruksi Product final"]
end
B -->|"method chaining"| M
M --> V
V -->|"valid"| P
V -->|"invalid"| E["error"]
Empat komponen:
| Komponen | Peran | Karakteristik di Golang |
|---|---|---|
| Product | Objek final yang dihasilkan | Field unexported, hanya dibuat via builder |
| Builder struct | Akumulator nilai sementara | Field exported atau unexported, mutable |
| Fluent methods | Setter yang mengembalikan *Builder |
Return *Builder untuk method chaining |
Build() method |
Validasi + konstruksi Product | Mengembalikan (*Product, error) |
Implementasi Lengkap: HTTP Client Builder #
Mari bangun sesuatu yang lebih realistis dari sekadar User — sebuah HTTP client yang bisa dikonfigurasi dengan banyak parameter opsional. Ini adalah use case yang sering ditemui di aplikasi produksi.
Product: Struct dengan Field Unexported #
package httpclient
import (
"crypto/tls"
"net/http"
"time"
)
// Client adalah product — HTTP client yang sudah terkonfigurasi dan siap digunakan.
// Semua field unexported: satu-satunya cara membuat Client adalah melalui ClientBuilder.
type Client struct {
baseURL string
httpClient *http.Client
defaultHeaders map[string]string
maxRetries int
retryDelay time.Duration
rateLimitRPS int
}
// Do mengirimkan HTTP request menggunakan konfigurasi yang sudah ditetapkan.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
// Tambahkan default headers
for key, value := range c.defaultHeaders {
if req.Header.Get(key) == "" {
req.Header.Set(key, value)
}
}
return c.httpClient.Do(req)
}
// BaseURL mengembalikan base URL yang dikonfigurasi.
func (c *Client) BaseURL() string { return c.baseURL }
// MaxRetries mengembalikan jumlah maksimum retry yang dikonfigurasi.
func (c *Client) MaxRetries() int { return c.maxRetries }
Builder: Akumulator Nilai Sementara #
// ClientBuilder mengakumulasi semua nilai konfigurasi sebelum Client dibuat.
type ClientBuilder struct {
baseURL string
timeout time.Duration
maxRetries int
retryDelay time.Duration
rateLimitRPS int
defaultHeaders map[string]string
tlsSkipVerify bool
maxIdleConns int
keepAlive time.Duration
proxyURL string
}
// NewClientBuilder membuat builder dengan nilai default yang masuk akal.
// Default value ditetapkan di sini, bukan di Product.
func NewClientBuilder(baseURL string) *ClientBuilder {
return &ClientBuilder{
baseURL: baseURL,
timeout: 30 * time.Second, // 30 detik — aman untuk sebagian besar kasus
maxRetries: 3, // retry hingga 3 kali
retryDelay: 500 * time.Millisecond,
maxIdleConns: 100,
keepAlive: 90 * time.Second,
defaultHeaders: map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
},
}
}
Fluent Setter Methods #
Setiap method mengembalikan *ClientBuilder — ini yang memungkinkan method chaining.
// WithTimeout menetapkan timeout untuk setiap request.
func (b *ClientBuilder) WithTimeout(d time.Duration) *ClientBuilder {
b.timeout = d
return b
}
// WithMaxRetries menetapkan jumlah maksimum percobaan ulang saat request gagal.
func (b *ClientBuilder) WithMaxRetries(n int) *ClientBuilder {
b.maxRetries = n
return b
}
// WithRetryDelay menetapkan jeda antar percobaan ulang.
func (b *ClientBuilder) WithRetryDelay(d time.Duration) *ClientBuilder {
b.retryDelay = d
return b
}
// WithRateLimit menetapkan batas request per detik.
// Nilai 0 berarti tidak ada rate limiting.
func (b *ClientBuilder) WithRateLimit(rps int) *ClientBuilder {
b.rateLimitRPS = rps
return b
}
// WithHeader menambahkan header default yang disertakan di setiap request.
func (b *ClientBuilder) WithHeader(key, value string) *ClientBuilder {
b.defaultHeaders[key] = value
return b
}
// WithAuthToken menetapkan Authorization header dengan Bearer token.
func (b *ClientBuilder) WithAuthToken(token string) *ClientBuilder {
b.defaultHeaders["Authorization"] = "Bearer " + token
return b
}
// WithTLSSkipVerify menonaktifkan verifikasi sertifikat TLS.
// JANGAN gunakan di production — hanya untuk development/testing.
func (b *ClientBuilder) WithTLSSkipVerify(skip bool) *ClientBuilder {
b.tlsSkipVerify = skip
return b
}
// WithMaxIdleConns menetapkan jumlah maksimum koneksi idle dalam pool.
func (b *ClientBuilder) WithMaxIdleConns(n int) *ClientBuilder {
b.maxIdleConns = n
return b
}
// WithProxy menetapkan URL proxy yang akan digunakan.
func (b *ClientBuilder) WithProxy(proxyURL string) *ClientBuilder {
b.proxyURL = proxyURL
return b
}
Build Method dengan Validasi Komprehensif #
Build() adalah tempat semua validasi dijalankan sebelum objek diserahkan ke pemanggil.
// Build memvalidasi semua konfigurasi dan membuat Client yang siap digunakan.
// Mengembalikan error jika ada konfigurasi yang tidak valid.
func (b *ClientBuilder) Build() (*Client, error) {
// Validasi field wajib
if b.baseURL == "" {
return nil, errors.New("baseURL is required")
}
if _, err := url.ParseRequestURI(b.baseURL); err != nil {
return nil, fmt.Errorf("invalid baseURL %q: %w", b.baseURL, err)
}
// Validasi nilai numerik
if b.timeout <= 0 {
return nil, fmt.Errorf("timeout must be positive, got %v", b.timeout)
}
if b.maxRetries < 0 {
return nil, fmt.Errorf("maxRetries must be non-negative, got %d", b.maxRetries)
}
if b.rateLimitRPS < 0 {
return nil, fmt.Errorf("rateLimitRPS must be non-negative, got %d", b.rateLimitRPS)
}
if b.maxIdleConns <= 0 {
return nil, fmt.Errorf("maxIdleConns must be positive, got %d", b.maxIdleConns)
}
// Konstruksi http.Transport
transport := &http.Transport{
MaxIdleConns: b.maxIdleConns,
IdleConnTimeout: b.keepAlive,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: b.tlsSkipVerify, //nolint:gosec
},
}
// Set proxy jika ada
if b.proxyURL != "" {
proxyURL, err := url.Parse(b.proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxyURL %q: %w", b.proxyURL, err)
}
transport.Proxy = http.ProxyURL(proxyURL)
}
// Salin defaultHeaders agar tidak ter-mutate dari luar setelah Build()
headers := make(map[string]string, len(b.defaultHeaders))
for k, v := range b.defaultHeaders {
headers[k] = v
}
return &Client{
baseURL: b.baseURL,
httpClient: &http.Client{
Timeout: b.timeout,
Transport: transport,
},
defaultHeaders: headers,
maxRetries: b.maxRetries,
retryDelay: b.retryDelay,
rateLimitRPS: b.rateLimitRPS,
}, nil
}
Penggunaan: Bersih dan Ekspresif #
// Client untuk payment gateway — timeout ketat, ada auth token, rate limited
paymentClient, err := httpclient.NewClientBuilder("https://api.payment-gateway.com").
WithTimeout(10 * time.Second).
WithMaxRetries(2).
WithRetryDelay(1 * time.Second).
WithAuthToken(cfg.PaymentAPIKey).
WithRateLimit(50).
WithHeader("X-Merchant-ID", cfg.MerchantID).
Build()
if err != nil {
return fmt.Errorf("failed to build payment client: %w", err)
}
// Client untuk internal service — timeout longgar, tanpa auth, no retry
internalClient, err := httpclient.NewClientBuilder("http://user-service:8080").
WithTimeout(5 * time.Second).
WithMaxRetries(0).
WithMaxIdleConns(200).
Build()
if err != nil {
return fmt.Errorf("failed to build internal client: %w", err)
}
// Client untuk development — skip TLS, verbose
devClient, err := httpclient.NewClientBuilder("https://localhost:9443").
WithTLSSkipVerify(true).
WithHeader("X-Debug", "true").
Build()
Director Pattern: Preset Konfigurasi #
Dalam implementasi klasik GoF, ada komponen Director yang mengorkestrasikan urutan pembuatan objek dan menyediakan preset konfigurasi yang umum digunakan. Di Golang, ini bisa diimplementasikan sebagai fungsi-fungsi helper yang menggunakan builder di dalamnya.
// Director menyediakan preset konfigurasi umum.
// Ini menghindari duplikasi konfigurasi yang sama di banyak tempat.
type ClientDirector struct {
config AppConfig
}
func NewClientDirector(cfg AppConfig) *ClientDirector {
return &ClientDirector{config: cfg}
}
// BuildPaymentClient membuat client yang sudah dikonfigurasi untuk payment gateway.
func (d *ClientDirector) BuildPaymentClient() (*Client, error) {
return NewClientBuilder(d.config.PaymentGatewayURL).
WithTimeout(10 * time.Second).
WithMaxRetries(2).
WithRetryDelay(1 * time.Second).
WithAuthToken(d.config.PaymentAPIKey).
WithRateLimit(50).
WithHeader("X-Merchant-ID", d.config.MerchantID).
Build()
}
// BuildInternalServiceClient membuat client untuk komunikasi antar service internal.
func (d *ClientDirector) BuildInternalServiceClient(serviceURL string) (*Client, error) {
return NewClientBuilder(serviceURL).
WithTimeout(5 * time.Second).
WithMaxRetries(3).
WithMaxIdleConns(200).
WithHeader("X-Service-Name", d.config.ServiceName).
Build()
}
// BuildExternalAPIClient membuat client untuk API eksternal dengan rate limiting.
func (d *ClientDirector) BuildExternalAPIClient(apiURL, apiKey string) (*Client, error) {
return NewClientBuilder(apiURL).
WithTimeout(30 * time.Second).
WithMaxRetries(1).
WithAuthToken(apiKey).
WithRateLimit(10). // API eksternal biasanya punya rate limit ketat
Build()
}
Dengan Director, penggunaan menjadi sangat ringkas:
director := NewClientDirector(cfg)
paymentClient, err := director.BuildPaymentClient()
userServiceClient, err := director.BuildInternalServiceClient("http://user-service:8080")
analyticsClient, err := director.BuildExternalAPIClient("https://analytics.example.com", apiKey)
Builder vs Functional Options Pattern #
Di Golang, ada idiom populer lain untuk masalah yang sama: Functional Options Pattern. Keduanya valid — pilihan tergantung karakteristik objek yang dibangun.
// Functional Options — alternatif untuk kasus yang lebih sederhana
type Option func(*Client)
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
c.timeout = d
}
}
func WithAuthToken(token string) Option {
return func(c *Client) {
c.defaultHeaders["Authorization"] = "Bearer " + token
}
}
func NewClient(baseURL string, opts ...Option) (*Client, error) {
c := &Client{
baseURL: baseURL,
timeout: 30 * time.Second, // default
}
for _, opt := range opts {
opt(c)
}
return c, validate(c)
}
// Penggunaan
client, err := NewClient("https://api.example.com",
WithTimeout(10*time.Second),
WithAuthToken("secret"),
)
Perbandingan keduanya:
| Aspek | Builder Pattern | Functional Options |
|---|---|---|
| Urutan step yang penting | Mudah diimplementasikan | Sulit — semua option independen |
| Banyak state internal | Sangat cocok | Bisa, tapi kurang terstruktur |
| Validasi antar field | Terpusat di Build() |
Harus dijalankan terpisah |
| Kemudahan extend | Tambah method baru | Tambah fungsi option baru |
| Immutability product | Mudah — field unexported | Mudah |
| Testing builder itu sendiri | Mudah — test setiap method | Lebih sulit — option adalah closure |
| Cocok untuk | Objek kompleks dengan banyak state | Konfigurasi ringan dengan option independen |
PILIH Builder Pattern jika:
✓ Banyak field yang saling bergantung dan harus divalidasi bersama
✓ Proses konstruksi memiliki urutan yang penting
✓ Perlu Director untuk menyediakan beberapa preset konfigurasi
✓ Objek sangat kompleks dengan lebih dari 7-10 parameter
PILIH Functional Options jika:
✓ Option-option bersifat independen satu sama lain
✓ Konfigurasi relatif sederhana (3-7 option)
✓ Tidak ada urutan yang penting dalam pemberian nilai
✓ Ingin API yang lebih minimalis
Testing Builder #
Builder Pattern membuat testing lebih mudah karena ada titik tunggal untuk validasi. Kamu bisa test setiap skenario tanpa harus membuat objek yang sudah jadi.
func TestClientBuilder_DefaultValues(t *testing.T) {
client, err := NewClientBuilder("https://api.example.com").Build()
if err != nil {
t.Fatalf("expected no error with valid config, got: %v", err)
}
// Verifikasi default values teraplikasi
if client.MaxRetries() != 3 {
t.Errorf("expected default maxRetries=3, got %d", client.MaxRetries())
}
if client.BaseURL() != "https://api.example.com" {
t.Errorf("expected baseURL to match input")
}
}
func TestClientBuilder_ValidationErrors(t *testing.T) {
tests := []struct {
name string
setupFn func(*ClientBuilder) *ClientBuilder
expectedErr string
}{
{
name: "empty baseURL",
setupFn: func(b *ClientBuilder) *ClientBuilder { return b },
expectedErr: "baseURL is required",
},
{
name: "invalid baseURL",
setupFn: func(b *ClientBuilder) *ClientBuilder {
return NewClientBuilder("not-a-url")
},
expectedErr: "invalid baseURL",
},
{
name: "negative timeout",
setupFn: func(b *ClientBuilder) *ClientBuilder {
return NewClientBuilder("https://api.example.com").
WithTimeout(-1 * time.Second)
},
expectedErr: "timeout must be positive",
},
{
name: "negative maxRetries",
setupFn: func(b *ClientBuilder) *ClientBuilder {
return NewClientBuilder("https://api.example.com").
WithMaxRetries(-1)
},
expectedErr: "maxRetries must be non-negative",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := &ClientBuilder{}
_, err := tt.setupFn(builder).Build()
if err == nil {
t.Error("expected error, got nil")
return
}
if !strings.Contains(err.Error(), tt.expectedErr) {
t.Errorf("expected error containing %q, got %q", tt.expectedErr, err.Error())
}
})
}
}
func TestClientBuilder_CustomHeaders(t *testing.T) {
client, err := NewClientBuilder("https://api.example.com").
WithAuthToken("secret-token").
WithHeader("X-Custom", "value").
Build()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verifikasi headers teraplikasi — menggunakan getter jika ada
// atau melalui behavior test (kirim request dan cek header yang diterima)
_ = client
}
Builder Tidak Boleh Digunakan Ulang Setelah
Build()Setelah
Build()dipanggil, jangan gunakan builder yang sama untuk membuat objek kedua. State builder mungkin sudah dimodifikasi selama proses konstruksi, dan berbagi state antar dua objek adalah resep untuk bug yang sulit di-trace. Jika perlu objek kedua dengan konfigurasi yang mirip, buat builder baru atau implementasikan methodClone()pada builder.
Kesalahan Umum #
// ✗ Kesalahan 1: Builder tanpa validasi di Build()
func (b *UserBuilder) Build() *User {
return &User{name: b.name, email: b.email} // tidak ada validasi — state invalid bisa lolos
}
// ✓ Solusi: selalu validasi dan kembalikan error
func (b *UserBuilder) Build() (*User, error) {
if b.name == "" {
return nil, errors.New("name is required")
}
if !isValidEmail(b.email) {
return nil, fmt.Errorf("invalid email: %s", b.email)
}
return &User{name: b.name, email: b.email}, nil
}
// ✗ Kesalahan 2: Memasukkan business logic ke dalam builder
func (b *OrderBuilder) SetDiscount(code string) *OrderBuilder {
// JANGAN: query database atau panggil external service dari builder
discount, _ := db.GetDiscount(code)
b.discountAmount = discount.Amount
b.discountCode = code
return b
}
// ✓ Solusi: builder hanya menerima nilai yang sudah dihitung di luar
func (b *OrderBuilder) WithDiscount(code string, amount int) *OrderBuilder {
b.discountCode = code
b.discountAmount = amount
return b
}
// ✗ Kesalahan 3: Mengekspos field Product — builder jadi tidak berguna
type User struct {
Name string // exported — siapa saja bisa set langsung tanpa melalui builder
Email string
Age int
}
// ✓ Solusi: field Product selalu unexported
type User struct {
name string
email string
age int
}
// Sediakan getter jika perlu membaca nilai dari luar
func (u *User) Name() string { return u.name }
func (u *User) Email() string { return u.email }
// ✗ Kesalahan 4: Panic saat validasi gagal, bukan return error
func (b *ClientBuilder) Build() *Client {
if b.baseURL == "" {
panic("baseURL is required") // crash aplikasi — tidak dapat di-handle
}
return &Client{baseURL: b.baseURL}
}
// ✓ Solusi: selalu return error, biarkan pemanggil yang memutuskan cara menanganinya
func (b *ClientBuilder) Build() (*Client, error) {
if b.baseURL == "" {
return nil, errors.New("baseURL is required")
}
return &Client{baseURL: b.baseURL}, nil
}
// ✗ Kesalahan 5: Menggunakan Builder untuk objek yang sangat sederhana
type Point struct{ x, y int }
type PointBuilder struct{ x, y int }
func (b *PointBuilder) SetX(x int) *PointBuilder { b.x = x; return b }
func (b *PointBuilder) SetY(y int) *PointBuilder { b.y = y; return b }
func (b *PointBuilder) Build() Point { return Point{x: b.x, y: b.y} }
// ✓ Solusi: untuk objek sederhana, konstruktor biasa sudah cukup
func NewPoint(x, y int) Point { return Point{x: x, y: y} }
Checklist Review Builder #
PRODUCT:
□ Semua field unexported — tidak ada akses langsung dari luar package
□ Getter disediakan untuk field yang perlu dibaca dari luar
□ Tidak ada setter pada Product — immutable setelah Build()
BUILDER:
□ Default value yang masuk akal ditetapkan di konstruktor builder, bukan di Product
□ Setiap fluent method mengembalikan *Builder untuk method chaining
□ Tidak ada business logic di dalam builder — hanya akumulasi nilai
BUILD METHOD:
□ Semua field wajib divalidasi
□ Semua invariant antar field divalidasi (misal: maxRetries >= 0)
□ Mengembalikan error, bukan panic
□ Salin slice dan map sebelum diassign ke Product — hindari shared mutable state
TESTING:
□ Default values ditest secara eksplisit
□ Setiap skenario error divalidasi di test terpisah
□ Table-driven test digunakan untuk skenario validasi yang banyak
□ Builder tidak digunakan ulang antar test case yang berbeda
DIRECTOR (jika ada):
□ Setiap preset method dinamai berdasarkan use case, bukan konfigurasi
□ Director menerima konfigurasi via konstruktor, bukan hardcode
□ Director bisa di-mock untuk testing komponen yang menggunakannya
Ringkasan #
- Builder memisahkan konstruksi dari representasi — objek dibangun secara bertahap melalui method yang diberi nama jelas, bukan melalui urutan parameter yang membingungkan.
- Tiga masalah yang dipecahkan: telescoping constructor, parameter bertipe sama yang mudah tertukar, dan validasi yang tersebar di banyak tempat.
- Field Product harus unexported — ini yang memaksa semua kode menggunakan builder dan membuat objek benar-benar immutable setelah
Build().- Validasi terpusat di
Build()— semua invariant dan aturan bisnis divalidasi di satu tempat; return error, jangan panic.- Default value di builder, bukan di Product — builder bertanggung jawab atas nilai awal yang masuk akal; Product hanya menyimpan nilai final.
- Director menyediakan preset — untuk konfigurasi umum yang digunakan di banyak tempat; menghindari duplikasi builder chain yang sama persis.
- Builder vs Functional Options: pilih Builder ketika ada banyak state yang saling bergantung, validasi kompleks antar field, atau urutan step yang penting; pilih Functional Options untuk konfigurasi ringan dengan option yang independen.
- Jangan gunakan Builder untuk objek sederhana — 1–3 field tidak membutuhkan builder; konstruktor biasa lebih dari cukup.