Design by Contract menyatakan aturan untuk pemanggil yang taat; kenyataannya, data kerap datang dari pihak yang tidak membaca kontrak — pengguna yang mengosongkan formulir, berkas rusak, atau respons layanan luar yang berubah tanpa pemberitahuan. Defensive Programming adalah praktik mengasumsikan bahwa masukan dan state bisa salah, lalu melindungi kode di titik yang tepat: batas sistem. Dua teknik utamanya adalah validasi yang gagal cepat (fail fast) dan salinan pelindung (defensive copy) untuk data yang dapat berubah. Chapter ini membuktikan keduanya dengan program yang sengaja menampilkan upaya pengubahan data dari luar.
Validasi di Batas Sistem #
Aturan pertama defensive programming: validasi sekali, di batas, lalu percayai data di dalam. Batas (trust boundary) adalah tempat data berpindah tangan — HTTP request yang masuk, berkas yang dibaca, baris yang diurai. Setelah melewati gerbang validasi, kode domain boleh bekerja tanpa pemeriksaan berulang. Pendekatan ini adalah fail fast: kesalahan ditolak di pintu masuk dengan pesan yang jelas, bukan dibiarkan mengalir hingga gagal jauh di dalam.
import java.util.Objects;
public class FormulirPendaftaran {
public static void main(String[] args) {
// Batas sistem: masukan pengguna divalidasi sebelum masuk domain
Peserta budi = Pendaftaran.daftar("Budi", "[email protected]", 17);
System.out.println("Terdaftar: " + budi.nama() + " (" + budi.email() + ")");
try {
Pendaftaran.daftar("Siti", "bukan-email", 17);
} catch (IllegalArgumentException e) {
System.out.println("Ditolak: " + e.getMessage());
}
try {
Pendaftaran.daftar("Dewi", "[email protected]", 12);
} catch (IllegalArgumentException e) {
System.out.println("Ditolak: " + e.getMessage());
}
}
}
record Peserta(String nama, String email, int umur) {
}
class Pendaftaran {
static Peserta daftar(String nama, String email, int umur) {
Objects.requireNonNull(nama, "nama tidak boleh null");
Objects.requireNonNull(email, "email tidak boleh null");
if (nama.isBlank()) {
throw new IllegalArgumentException("nama tidak boleh kosong");
}
if (!email.contains("@") || email.startsWith("@") || email.endsWith("@")) {
throw new IllegalArgumentException("format email tidak valid: " + email);
}
if (umur < 13) {
throw new IllegalArgumentException("umur minimal 13: " + umur);
}
return new Peserta(nama.trim(), email.toLowerCase(), umur);
}
}Output yang Dihasilkan:
Terdaftar: Budi ([email protected])
Ditolak: format email tidak valid: bukan-email
Ditolak: umur minimal 13: 12Mari kita bedah gerbang validasi tersebut:
- Baris 28-29 (dua
Objects.requireNonNull): null ditolak lebih dulu dengan pesan eksplisit — bukan pesan generik dari JVM. Pemeriksaan ini murah, selalu aktif, dan menunjuk field yang bermasalah. - Baris 30-38 (tiga
IllegalArgumentException): setiap aturan bisnis dinyatakan dengan pesan yang memuat nilai aktual —bukan-email,umur 12. Pemanggil tahu persis apa yang harus diperbaiki tanpa membuka debugger. - Baris 39 (
new Peserta(nama.trim(), ...)): setelah lolos gerbang, data dibersihkan sekali (trim, huruf kecil) dan dibekukan dalam record. Seluruh kode setelah garis ini bekerja dengan data yang sudah bersih dan tidak dapat berubah.
Defensive Copy pada Getter #
Ancaman kedua bukan data yang salah, melainkan referensi yang bocor. Getter yang mengembalikan List internal menyerahkan kunci lemari: pemanggil dapat mengosongkan atau menambah isi tanpa sepengetahuan class. Hal yang sama berlaku untuk objek mutable seperti java.util.Date — pemanggil yang memegang referensi yang sama dapat menggeser waktunya kapan pun. Program berikut membandingkan class yang bocor dengan class yang menyalin:
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
public class TimAman {
public static void main(String[] args) {
Date berdiri = Date.from(
LocalDate.of(2026, 1, 15).atStartOfDay(ZoneOffset.UTC).toInstant());
System.out.println("== Class tanpa pertahanan ==");
TimRentan rentan = new TimRentan();
rentan.tambah("Budi");
rentan.tambah("Siti");
rentan.anggota().clear(); // referensi internal diubah dari luar
System.out.println("Sisa anggota: " + rentan.anggota().size());
System.out.println("== Class dengan defensive copy ==");
TimAman aman = new TimAman("Backend", berdiri);
aman.tambah("Budi");
aman.tambah("Siti");
List<String> snapshot = aman.anggota(); // salinan beku saat itu
aman.tambah("Dewi"); // internal berubah setelahnya
System.out.println("Snapshot awal : " + snapshot);
System.out.println("Isi terkini : " + aman.anggota());
berdiri.setTime(0); // Date milik pemanggil digeser, salinan internal aman
System.out.println("Tahun berdiri : " + aman.tahunBerdiri());
System.out.println("== Validasi di batas ==");
try {
aman.tambah(" ");
} catch (IllegalArgumentException e) {
System.out.println("Ditolak: " + e.getMessage());
}
try {
new TimAman(null, berdiri);
} catch (NullPointerException e) {
System.out.println("Ditolak: " + e.getMessage());
}
}
private final String nama;
private final Date berdiri;
private final List<String> anggota = new ArrayList<>();
TimAman(String nama, Date berdiri) {
this.nama = Objects.requireNonNull(nama, "nama tim tidak boleh null");
Objects.requireNonNull(berdiri, "tanggal berdiri tidak boleh null");
this.berdiri = new Date(berdiri.getTime()); // salin saat masuk
}
void tambah(String namaAnggota) {
Objects.requireNonNull(namaAnggota, "nama anggota tidak boleh null");
if (namaAnggota.isBlank()) {
throw new IllegalArgumentException("nama anggota tidak boleh kosong");
}
anggota.add(namaAnggota);
}
List<String> anggota() {
return List.copyOf(anggota); // salinan yang tidak dapat diubah
}
Date berdiriSejak() {
return new Date(berdiri.getTime()); // salinan baru setiap panggilan
}
int tahunBerdiri() {
return berdiri.toInstant().atZone(ZoneOffset.UTC).getYear();
}
}
class TimRentan {
private final List<String> anggota = new ArrayList<>();
void tambah(String nama) {
anggota.add(nama);
}
List<String> anggota() {
return anggota; // bocor: referensi internal dikembalikan langsung
}
}Output yang Dihasilkan:
== Class tanpa pertahanan ==
Sisa anggota: 0
== Class dengan defensive copy ==
Snapshot awal : [Budi, Siti]
Isi terkini : [Budi, Siti, Dewi]
Tahun berdiri : 2026
== Validasi di batas ==
Ditolak: nama anggota tidak boleh kosong
Ditolak: nama tim tidak boleh nullMari kita bedah lapisan pertahanan pada program tersebut:
- Baris 17 (
rentan.anggota().clear()): padaTimRentan,anggota()mengembalikan referensi internal, sehinggaclear()benar-benar menghapus data milik class — baris 18 mencetak0. Class tidak lagi berdaulat atas state-nya sendiri. - Baris 25-28 (
snapshotvsaman.anggota()): padaTimAman, getter memanggilList.copyOf(anggota)— salinan beku dibuat setiap panggilan. Snapshot yang diambil sebelumtambah("Dewi")tetap berisi dua nama, sementara data terkini berisi tiga; keduanya berdiri sendiri. Upaya mutasi pada salinan berikutnya akan melemparUnsupportedOperationException. - Baris 30-31 (
berdiri.setTime(0)): pemanggil menggeserDatemiliknya sendiri, tetapi constructorTimAmanmenyimpan salinan (new Date(berdiri.getTime())), sehingga tahun berdiri tetap tercetak2026. Salinan dilakukan dua arah: saat data masuk (constructor) dan saat data keluar (getter). - Baris 53 (
this.berdiri = new Date(berdiri.getTime())): constructor menyalinDateyang diterima. Tanpa salinan ini, pemanggil dapat menggeser waktu berdiri kapan pun dari referensi yang masih dipegangnya.
Validasi di batas dan salinan pelindung menuntaskan perlindungan level data. Pertanyaan berikutnya lebih awal lagi: sebelum memikirkan pertahanan, bagaimana kita merancang class dan relasinya dari cerita pengguna? Chapter selanjutnya, Object-Oriented Design, membahas langkah penerjemahan tersebut.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
FormulirPendaftaran.java
Unduh berkasimport java.util.Objects;
public class FormulirPendaftaran {
public static void main(String[] args) {
// Batas sistem: masukan pengguna divalidasi sebelum masuk domain
Peserta budi = Pendaftaran.daftar("Budi", "[email protected]", 17);
System.out.println("Terdaftar: " + budi.nama() + " (" + budi.email() + ")");
try {
Pendaftaran.daftar("Siti", "bukan-email", 17);
} catch (IllegalArgumentException e) {
System.out.println("Ditolak: " + e.getMessage());
}
try {
Pendaftaran.daftar("Dewi", "[email protected]", 12);
} catch (IllegalArgumentException e) {
System.out.println("Ditolak: " + e.getMessage());
}
}
}
record Peserta(String nama, String email, int umur) {
}
class Pendaftaran {
static Peserta daftar(String nama, String email, int umur) {
Objects.requireNonNull(nama, "nama tidak boleh null");
Objects.requireNonNull(email, "email tidak boleh null");
if (nama.isBlank()) {
throw new IllegalArgumentException("nama tidak boleh kosong");
}
if (!email.contains("@") || email.startsWith("@") || email.endsWith("@")) {
throw new IllegalArgumentException("format email tidak valid: " + email);
}
if (umur < 13) {
throw new IllegalArgumentException("umur minimal 13: " + umur);
}
return new Peserta(nama.trim(), email.toLowerCase(), umur);
}
}
TimAman.java
Unduh berkasimport java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
public class TimAman {
public static void main(String[] args) {
Date berdiri = Date.from(
LocalDate.of(2026, 1, 15).atStartOfDay(ZoneOffset.UTC).toInstant());
System.out.println("== Class tanpa pertahanan ==");
TimRentan rentan = new TimRentan();
rentan.tambah("Budi");
rentan.tambah("Siti");
rentan.anggota().clear(); // referensi internal diubah dari luar
System.out.println("Sisa anggota: " + rentan.anggota().size());
System.out.println("== Class dengan defensive copy ==");
TimAman aman = new TimAman("Backend", berdiri);
aman.tambah("Budi");
aman.tambah("Siti");
List<String> snapshot = aman.anggota(); // salinan beku saat itu
aman.tambah("Dewi"); // internal berubah setelahnya
System.out.println("Snapshot awal : " + snapshot);
System.out.println("Isi terkini : " + aman.anggota());
berdiri.setTime(0); // Date milik pemanggil digeser, salinan internal aman
System.out.println("Tahun berdiri : " + aman.tahunBerdiri());
System.out.println("== Validasi di batas ==");
try {
aman.tambah(" ");
} catch (IllegalArgumentException e) {
System.out.println("Ditolak: " + e.getMessage());
}
try {
new TimAman(null, berdiri);
} catch (NullPointerException e) {
System.out.println("Ditolak: " + e.getMessage());
}
}
private final String nama;
private final Date berdiri;
private final List<String> anggota = new ArrayList<>();
TimAman(String nama, Date berdiri) {
this.nama = Objects.requireNonNull(nama, "nama tim tidak boleh null");
Objects.requireNonNull(berdiri, "tanggal berdiri tidak boleh null");
this.berdiri = new Date(berdiri.getTime()); // salin saat masuk
}
void tambah(String namaAnggota) {
Objects.requireNonNull(namaAnggota, "nama anggota tidak boleh null");
if (namaAnggota.isBlank()) {
throw new IllegalArgumentException("nama anggota tidak boleh kosong");
}
anggota.add(namaAnggota);
}
List<String> anggota() {
return List.copyOf(anggota); // salinan yang tidak dapat diubah
}
Date berdiriSejak() {
return new Date(berdiri.getTime()); // salinan baru setiap panggilan
}
int tahunBerdiri() {
return berdiri.toInstant().atZone(ZoneOffset.UTC).getYear();
}
}
class TimRentan {
private final List<String> anggota = new ArrayList<>();
void tambah(String nama) {
anggota.add(nama);
}
List<String> anggota() {
return anggota; // bocor: referensi internal dikembalikan langsung
}
}