Struct kecil dengan dua field masih nyaman dibuat lewat struct literal, tetapi konfigurasi nyata jarang berhenti pada dua field. Sebuah konfigurasi server bisa membawa host, port, timeout, ukuran connection pool, batas retry, dan path sertifikat — dan tidak semua field wajib diisi. Builder pattern menjawab pertumbuhan tersebut: konfigurasi disusun bertahap lewat method yang dirantai, lalu seluruh aturan diperiksa pada satu titik akhir bernama build(). Pemanggil mendapat API yang ringkas untuk diketik dan mudah dibaca, sementara pembuat API mendapat tempat tunggal untuk menegakkan invariants. Pada chapter ini kita membangun ServerConfigBuilder untuk konfigurasi koneksi basis data, lengkap dengan jalur sukses dan jalur gagal yang sama-sama teruji.
Konfigurasi yang Tumbuh Melampaui Struct Literal #
Masalahnya bermula dari dua kutub yang sama-sama buruk. Kutub pertama: struct literal wajib menyebut semua field, jadi ServerConfig { host: ..., port: ..., timeout_secs: ... } harus mengisi nilai untuk opsi yang seharusnya boleh dibiarkan bawaan. Kutub kedua: mengubah semua field menjadi Option dengan nilai default mengizinkan konstruksi setengah jadi — ServerConfig bisa lahir dengan port yang belum diisi, dan kompiler tidak berkomentar. Validasi lalu tercecer di setiap fungsi yang menerima konfigurasi tersebut, atau lebih buruk: validasi hanya ada di kepala penulisnya.
Builder pattern menempati titik tengah yang rapi. Tipe akhir ServerConfig tetap sederhana dan hanya berisi nilai yang sudah final, sedangkan tipe sementara ServerConfigBuilder yang membawa Option dan nilai default. Perpindahan dari tipe sementara ke tipe akhir hanya boleh terjadi lewat satu method build(), dan di method itulah seluruh pemeriksaan invariants dikumpulkan. Hasilnya adalah compile-time guarantee yang tidak dimiliki dua kutub sebelumnya: nilai bertipe ServerConfig tidak mungkin lahir dalam keadaan setengah jadi.
Builder dengan Method Chaining #
Mari kita tulis builder lengkap untuk ServerConfig. Setiap method pengatur mengambil self, mengubah satu field, lalu mengembalikan self kembali — bentuk yang memungkinkan rantai pemanggilan:
use std::fmt;
pub struct ServerConfig {
host: String,
port: u16,
timeout_secs: u64,
}
pub struct ConfigError {
field: &'static str,
message: String,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "konfigurasi tidak sah ({}): {}", self.field, self.message)
}
}
pub struct ServerConfigBuilder {
host: String,
port: Option<u16>,
timeout_secs: u64,
}
impl ServerConfig {
// Satu-satunya pintu masuk menuju ServerConfig
pub fn builder() -> ServerConfigBuilder {
ServerConfigBuilder {
host: String::from("localhost"),
port: None,
timeout_secs: 30,
}
}
}
impl ServerConfigBuilder {
// Setiap method menerima self lalu mengembalikannya agar bisa dirantai
pub fn host(mut self, host: &str) -> Self {
self.host = host.to_string();
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
// Seluruh pemeriksaan invariants terjadi di titik ini
pub fn build(self) -> Result<ServerConfig, ConfigError> {
let port = self.port.ok_or(ConfigError {
field: "port",
message: String::from("port wajib diisi sebelum build()"),
})?;
if port == 0 {
return Err(ConfigError {
field: "port",
message: String::from("port 0 tidak sah, gunakan angka 1-65535"),
});
}
Ok(ServerConfig {
host: self.host,
port,
timeout_secs: self.timeout_secs,
})
}
}
fn tampilkan(hasil: Result<ServerConfig, ConfigError>) {
match hasil {
Ok(config) => println!(
"Berhasil: {}:{} (timeout {} detik)",
config.host, config.port, config.timeout_secs
),
Err(error) => println!("Gagal: {}", error),
}
}
fn main() {
// Skenario 1: konfigurasi lengkap, valid
tampilkan(
ServerConfig::builder()
.host("db.internal.example.com")
.port(5432)
.timeout_secs(30)
.build(),
);
// Skenario 2: port 0 melanggar invariant
tampilkan(ServerConfig::builder().host("db.example.com").port(0).build());
// Skenario 3: port tidak pernah diisi
tampilkan(ServerConfig::builder().host("db.example.com").build());
}Output yang Dihasilkan:
Berhasil: db.internal.example.com:5432 (timeout 30 detik)
Gagal: konfigurasi tidak sah (port): port 0 tidak sah, gunakan angka 1-65535
Gagal: konfigurasi tidak sah (port): port wajib diisi sebelum build()Mari kita bedah kode di atas:
- Baris 3-7 (
pub struct ServerConfig { ... }): tipe akhir hanya menyimpan nilai final —port: u16, bukanOption<u16>. Setiap nilai bertipeServerConfigdijamin sudah melewati pemeriksaanbuild(). - Baris 20-24 (
pub struct ServerConfigBuilder { ... }): tipe sementara menampung keadaan belum final. FieldportbertipeOption<u16>karena port memang boleh belum diisi selama proses penyusunan, dantimeout_secslangsung bernilai default 30. - Baris 26-34 (
impl ServerConfig { pub fn builder() ... }): methodbuilder()adalah titik awal dan pintu tunggal menujuServerConfig. Nilai default sepertihost: "localhost"dantimeout_secs: 30ditetapkan di sini, sehingga pemanggil hanya mengisi yang berbeda. - Baris 38-42 (
pub fn host(mut self, host: &str) -> Self): method pengatur menerimamut self— kepemilikan builder berpindah ke dalam method, satu field diubah, lalu builder dikembalikan utuh lewatSelf. Pola yang sama berlaku untukport(baris 44-47) dantimeout_secs(baris 49-52). - Baris 55-59 (
pub fn build(self) -> Result<ServerConfig, ConfigError> ... ok_or(...)?): method akhir mengonsumsi builder dan mengembalikanResult. Tanda tanya?meneruskan error ketikaportmasihNone, dengan pesan bahwa port wajib diisi. - Baris 61-66 (
if port == 0 { return Err(...) }): invariant kedua diperiksa di titik yang sama. Port 0 memang angka sah untuku16, tetapi tidak sah sebagai port server aplikasi — aturan domain semacam ini hanya hidup di kepala kita sampai kita menuliskannya di sini. - Baris 68-72 (
Ok(ServerConfig { ... })): jalur sukses memindahkan seluruh nilai dari builder ke tipe akhir. Setelah baris ini, builder sudah dikonsumsi dan tidak bisa dipakai lagi. - Baris 86-101 (
fn main()): tiga skenario dijalankan. Skenario 1 sukses karena semua invariant terpenuhi; skenario 2 gagal karena port 0; skenario 3 gagal karena port tidak pernah diisi. Ketiganya menghasilkanResultyang ditangani lewatmatch, tidak ada satu pun jalur yang bisa melahirkanServerConfigtidak sah.
Mekanisme Ownership di Balik Rantai #
Rantai ServerConfig::builder().host(...).port(...).timeout_secs(...).build() terlihat seperti mutasi bertahap, tetapi yang sesungguhnya terjadi adalah rangkaian ownership move. Pemanggilan builder() menghasilkan nilai builder di stack. Pemanggilan .host(...) memindahkan nilai tersebut masuk ke method, mengganti satu field, lalu memindahkan nilai keluar sebagai nilai kembalian. Pemanggilan .port(...) mengulang siklus yang sama pada hasil sebelumnya. Di akhir rantai, build() mengambil kepemilikan builder dan tidak mengembalikan builder lagi — builder pun mati dengan terhormat setelah tugasnya selesai.
Konsekuensi pola ini menguntungkan kedua pihak. Pemanggil tidak pernah kehilangan kendali di tengah rantai karena setiap method mengembalikan Self, dan pemanggil juga tidak bisa menahan versi builder yang sudah lewat — nilai yang sudah masuk ke build() tidak kembali. Karena setiap langkah hanya punya satu pemilik, borrow checker tidak perlu terlibat pertengkaran, dan rantai panjang tetap murah: seluruh perpindahan hanya memindahkan satu struct di stack.
Validasi Terkumpul di build #
Penempatan seluruh pemeriksaan pada build() bukan kebetulan gaya, melainkan keputusan desain dengan dua alasan. Pertama, satu titik pemeriksaan berarti satu tempat pemeliharaan: ketika aturan port berubah, kita hanya membuka satu method. Kedua, tipe kembalian Result<ServerConfig, ConfigError> memaksa pemanggil menghadapi kemungkinan gagal — compiler menolak kode yang membuang hasil build() tanpa penanganan, sesuai pola yang kita pelajari pada section Penanganan Error. Bandingkan dengan alternatif panic! di dalam konstruktor: panic memindahkan kegagalan ke runtime tanpa peringatan di tanda tangan, dan pemanggil tidak punya kesempatan menyiapkan jalur pemulihan.
Error ConfigError pada contoh juga sengaja membawa konteks: field field menunjukkan bagian konfigurasi mana yang salah, dan message menjelaskan pelanggarannya. Desain error yang informatif tanpa membocorkan detail internal akan kita perdalam pada chapter Desain Error pada API.
Ringkasan #
Builder pattern memisahkan penyusunan bertahap dari tipe akhir: ServerConfigBuilder menampung Option dan nilai default, method host, port, dan timeout_secs memindahkan builder masuk-keluar agar bisa dirantai, dan build() mengonsumsi builder sambil memeriksa seluruh invariants dalam satu tempat. Pemanggilan yang melanggar aturan tidak menghasilkan panic, melainkan Err(ConfigError) yang harus ditangani. Karena ServerConfig tidak punya constructor lain, setiap nilai bertipe tersebut lahir dalam keadaan sah. Builder mengunci nilai, tetapi masih menyisakan satu kebebasan berbahaya: pemanggil boleh memanggil method pengatur dalam urutan apa pun dan berkali-kali. Ketika urutan pemanggilan juga perlu dijaga, kita naik satu tingkat ke Typestate Pattern pada chapter berikutnya.