Setiap sistem yang dirancang baik dimulai dari satu pertanyaan sederhana: konsep apa saja yang hidup di dunia nyata, dan bagaimana konsep tersebut diterjemahkan menjadi tipe data di dalam kode. Perpustakaan memiliki tiga konsep inti — buku yang dikatalogkan, anggota yang terdaftar, dan transaksi peminjaman yang menghubungkan keduanya. Pada Bagian ini kita rancang ketiganya dari nol hanya dengan JDK murni: tanpa framework, tanpa pustaka luar, cukup record, class biasa, dan API java.time. Keputusan bentuk pada bagian ini menjadi pondasi bagi tiga bagian berikutnya, jadi perlakukan setiap pilihan sebagai keputusan arsitektur, bukan latihan sintaks.

Memilih Bentuk untuk Setiap Konsep Domain #

Kita mulai dari perilaku, bukan dari sintaks. Data buku dan data anggota tidak berubah setelah dibuat: judul Laskar Pelangi tetap sama selama buku tercatat di katalog, dan nomor anggota Budi tidak berganti selama keanggotaan berlangsung. Nilai beku seperti itu cocok dipetakkan menjadi record — satu baris deklarasi yang otomatis menghasilkan constructor, accessor, equals, hashCode, dan toString. Transaksi peminjaman berbeda: lahir tanpa tanggal kembali, lalu berubah tepat satu kali saat buku dikembalikan — karena itu transaksi menjadi class biasa dengan method kembalikan.

KonsepPerilaku sepanjang hidupnyaBentuk yang dipilih
Bukudata katalog beku sejak dicatatrecord
Anggotaidentitas beku sejak pendaftaranrecord
Peminjamanberubah satu kali saat pengembalianclass mutable

Identitas tiap konsep juga diputuskan sejak awal: buku diidentifikasi oleh ISBN yang unik per judul-edisi, anggota diidentifikasi oleh nomor berformat A-NNN seperti A-001. Kedua kunci tersebut dipakai pencarian katalog dan pendaftaran anggota pada Bagian 2.

Record Buku dan Anggota dengan Validasi Masukan #

Record berikut memanfaatkan compact constructor — constructor tanpa daftar parameter — sebagai titik pemeriksaan seluruh data masuk: compiler javac menyusun field, accessor, dan equals di sekeliling body tersebut, sementara kode validasi berjalan setiap kali objek dibuat:

java Buku.java
// Model katalog: data buku bersifat beku, jadi record adalah bentuk yang tepat.
public record Buku(String isbn, String judul, String penulis, int tahunTerbit) {

    // Compact constructor: validasi masukan sebelum field disimpan.
    public Buku {
        if (isbn == null || isbn.isBlank()) {
            throw new IllegalArgumentException("isbn wajib diisi");
        }
        if (judul == null || judul.isBlank()) {
            throw new IllegalArgumentException("judul wajib diisi");
        }
        if (tahunTerbit <= 0) {
            throw new IllegalArgumentException(
                    "tahunTerbit harus positif: " + tahunTerbit);
        }
        isbn = isbn.trim();
        judul = judul.trim();
        penulis = (penulis == null || penulis.isBlank()) ? "Tanpa Nama" : penulis.trim();
    }
}

Compact constructor pada Buku menolak ISBN kosong, judul kosong, dan tahun terbit tidak positif, sekaligus merapikan masukan: spasi berlebih pada judul dipangkas dan penulis kosong diganti Tanpa Nama agar katalog selalu terisi. Pola yang sama berlaku pada keanggotaan, dengan satu tambahan penting: tanggal terdaftar disimpan sebagai LocalDate, bukan string, sehingga perhitungan durasi pada Bagian 3 dapat memakai java.time secara penuh:

java Anggota.java
import java.time.LocalDate;

// Keanggotaan: identitas anggota tidak berubah setelah pendaftaran.
public record Anggota(String id, String nama, String email, LocalDate terdaftar) {

    public Anggota {
        if (id == null || !id.matches("A-\\d{3}")) {
            throw new IllegalArgumentException(
                    "id harus berformat A-NNN, diterima: " + id);
        }
        if (nama == null || nama.isBlank()) {
            throw new IllegalArgumentException("nama wajib diisi");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException(
                    "email harus memuat karakter '@', diterima: " + email);
        }
        nama = nama.trim();
        email = email.trim();
    }
}

Validasi pada Anggota memakai dua aturan konkret: id harus cocok pola A-\d{3}, dan email harus memuat karakter @. Data yang tidak lolos memicu IllegalArgumentException — kegagalan masukan menjadi tanggung jawab pemanggil, sehingga cabang unchecked menjadi pilihan yang tepat.

Peminjaman sebagai Class yang Berubah #

Transaksi peminjaman memegang referensi Buku dan Anggota sekaligus tiga tanggal: tanggal pinjam, jatuh tempo, dan tanggal kembali. Jatuh tempo tidak diisikan pemanggil, melainkan dihitung constructor sebagai tanggal pinjam ditambah LAMA_PINJAM_HARI — aturan bisnis pertama yang kita tanamkan langsung di dalam model:

java Peminjaman.java
import java.time.LocalDate;
import java.util.Objects;

// Transaksi sirkulasi: tanggalKembali berubah saat buku dikembalikan,
// sehingga Peminjaman dibuat sebagai class biasa, bukan record.
public class Peminjaman {

    /** Lama peminjaman standar, dalam hari. */
    public static final int LAMA_PINJAM_HARI = 14;

    private final Buku buku;
    private final Anggota anggota;
    private final LocalDate tanggalPinjam;
    private final LocalDate jatuhTempo;
    private LocalDate tanggalKembali; // null selama buku belum kembali

    public Peminjaman(Buku buku, Anggota anggota, LocalDate tanggalPinjam) {
        this.buku = Objects.requireNonNull(buku, "buku tidak boleh null");
        this.anggota = Objects.requireNonNull(anggota, "anggota tidak boleh null");
        this.tanggalPinjam =
                Objects.requireNonNull(tanggalPinjam, "tanggalPinjam tidak boleh null");
        this.jatuhTempo = tanggalPinjam.plusDays(LAMA_PINJAM_HARI);
    }

    public Buku buku() {
        return buku;
    }

    public Anggota anggota() {
        return anggota;
    }

    public LocalDate tanggalPinjam() {
        return tanggalPinjam;
    }

    public LocalDate jatuhTempo() {
        return jatuhTempo;
    }

    /** Tanggal kembali aktual; null bila buku masih di tangan anggota. */
    public LocalDate tanggalKembali() {
        return tanggalKembali;
    }

    /** Apakah buku masih dipinjam? */
    public boolean aktif() {
        return tanggalKembali == null;
    }

    /** Catat pengembalian dan kunci transaksi agar tidak dikembalikan dua kali. */
    public void kembalikan(LocalDate tanggal) {
        Objects.requireNonNull(tanggal, "tanggal tidak boleh null");
        if (tanggal.isBefore(tanggalPinjam)) {
            throw new IllegalArgumentException(
                    "tanggal kembali " + tanggal + " lebih awal dari tanggal pinjam "
                            + tanggalPinjam);
        }
        if (!aktif()) {
            throw new IllegalStateException("peminjaman sudah selesai");
        }
        this.tanggalKembali = tanggal;
    }

    // toString eksplisit: hanya memuat data domain, tanpa alamat objek.
    @Override
    public String toString() {
        return "Peminjaman[judul=" + buku.judul() + ", anggota=" + anggota.nama()
                + ", pinjam=" + tanggalPinjam + ", jatuhTempo=" + jatuhTempo
                + ", kembali=" + (aktif() ? "-" : tanggalKembali) + "]";
    }
}

Tiga keputusan pada class ini perlu diperhatikan. Pertama, hanya tanggalKembali yang tidak final; empat field lainnya beku sejak constructor selesai, sehingga ruang perubahan state menyempit ke satu titik. Kedua, method kembalikan menolak tiga kondisi: tanggal null, tanggal lebih awal dari tanggal pinjam, dan pengembalian kedua atas transaksi yang sudah selesai. Ketiga, toString ditimpa secara eksplisit karena toString bawaan Object mencetak hashCode objek — nilai yang berbeda pada setiap eksekusi.

Menjalankan Model Domain #

Program berikut merangkai ketiga tipe menjadi satu skenario dengan tanggal beku LocalDate.of(2026, ...) sehingga keluaran selalu identik:

java LayarDemo.java
import java.time.LocalDate;

// Uji coba model domain dengan tanggal beku agar output selalu sama.
public class LayarDemo {

    public static void main(String[] args) {
        Buku laskar = new Buku("978-602-421-123-4", " Laskar Pelangi ",
                "Andrea Hirata", 2005);
        Buku salinan = new Buku("978-602-421-123-4", "Laskar Pelangi",
                "Andrea Hirata", 2005);

        System.out.println("buku          : " + laskar);
        System.out.println("judul dirapikan: " + laskar.judul().equals("Laskar Pelangi"));
        System.out.println("buku setara   : " + laskar.equals(salinan));

        Anggota budi = new Anggota("A-001", "Budi Santoso",
                "[email protected]", LocalDate.of(2026, 1, 2));
        System.out.println("anggota       : " + budi);

        Peminjaman pinjaman = new Peminjaman(laskar, budi, LocalDate.of(2026, 1, 5));
        System.out.println("pinjam        : " + pinjaman);
        System.out.println("masih aktif   : " + pinjaman.aktif());

        pinjaman.kembalikan(LocalDate.of(2026, 1, 18));
        System.out.println("sudah kembali : " + pinjaman);
        System.out.println("masih aktif   : " + pinjaman.aktif());

        // Validasi di compact constructor benar-benar aktif.
        try {
            new Buku("978-x", "Buku Uji", "Anonim", -1);
        } catch (IllegalArgumentException e) {
            System.out.println("buku ditolak  : " + e.getMessage());
        }
        try {
            new Anggota("X-9", "Dewi Lestari", "dewi.example.com", LocalDate.of(2026, 1, 5));
        } catch (IllegalArgumentException e) {
            System.out.println("anggota ditolak: " + e.getMessage());
        }
        try {
            pinjaman.kembalikan(LocalDate.of(2026, 1, 4));
        } catch (IllegalArgumentException e) {
            System.out.println("kembali ditolak: " + e.getMessage());
        }
        try {
            pinjaman.kembalikan(LocalDate.of(2026, 1, 20));
        } catch (IllegalStateException e) {
            System.out.println("ganda ditolak : " + e.getMessage());
        }
    }
}

Output yang Dihasilkan:

text
buku          : Buku[isbn=978-602-421-123-4, judul=Laskar Pelangi, penulis=Andrea Hirata, tahunTerbit=2005]
judul dirapikan: true
buku setara   : true
anggota       : Anggota[id=A-001, nama=Budi Santoso, [email protected], terdaftar=2026-01-02]
pinjam        : Peminjaman[judul=Laskar Pelangi, anggota=Budi Santoso, pinjam=2026-01-05, jatuhTempo=2026-01-19, kembali=-]
masih aktif   : true
sudah kembali : Peminjaman[judul=Laskar Pelangi, anggota=Budi Santoso, pinjam=2026-01-05, jatuhTempo=2026-01-19, kembali=2026-01-18]
masih aktif   : false
buku ditolak  : tahunTerbit harus positif: -1
anggota ditolak: id harus berformat A-NNN, diterima: X-9
kembali ditolak: tanggal kembali 2026-01-04 lebih awal dari tanggal pinjam 2026-01-05
ganda ditolak : peminjaman sudah selesai

Mari kita bedah baris-baris pentingnya:

  1. new Buku(... " Laskar Pelangi " ...): judul sengaja diberi spasi berlebih; compact constructor memangkasnya, dibuktikan baris judul dirapikan: true.
  2. laskar.equals(salinan): dua objek berbeda dengan komponen sama dianggap setara — equals record membandingkan isi, bukan alamat objek; dasar penolakan ISBN ganda pada Bagian 2.
  3. new Peminjaman(laskar, budi, LocalDate.of(2026, 1, 5)): constructor menghitung jatuh tempo 2026-01-19 — tepat 14 hari setelah tanggal pinjam.
  4. pinjaman.kembalikan(LocalDate.of(2026, 1, 18)): satu-satunya mutasi yang diizinkan: tanggalKembali terisi, keluaran toString berubah, dan aktif() menjadi false.
  5. Empat blok try-catch: masing-masing membuktikan satu penjaga aktif — tahun negatif, format id salah, tanggal kembali terlalu awal, dan pengembalian ganda semuanya ditolak dengan pesan yang menjelaskan alasannya.

Model domain kini berdiri sendiri dan lulus uji coba dasar. Persoalan berikutnya: di mana ratusan objek Buku dan Anggota disimpan, bagaimana pencarian judul bekerja, dan bagaimana pendaftaran menolak nomor ganda. Bagian 2 menjawabnya dengan collection dari JDK.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

Anggota.java

Unduh berkas
java
import java.time.LocalDate;

// Keanggotaan: identitas anggota tidak berubah setelah pendaftaran.
public record Anggota(String id, String nama, String email, LocalDate terdaftar) {

    public Anggota {
        if (id == null || !id.matches("A-\\d{3}")) {
            throw new IllegalArgumentException(
                    "id harus berformat A-NNN, diterima: " + id);
        }
        if (nama == null || nama.isBlank()) {
            throw new IllegalArgumentException("nama wajib diisi");
        }
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException(
                    "email harus memuat karakter '@', diterima: " + email);
        }
        nama = nama.trim();
        email = email.trim();
    }
}

Buku.java

Unduh berkas
java
// Model katalog: data buku bersifat beku, jadi record adalah bentuk yang tepat.
public record Buku(String isbn, String judul, String penulis, int tahunTerbit) {

    // Compact constructor: validasi masukan sebelum field disimpan.
    public Buku {
        if (isbn == null || isbn.isBlank()) {
            throw new IllegalArgumentException("isbn wajib diisi");
        }
        if (judul == null || judul.isBlank()) {
            throw new IllegalArgumentException("judul wajib diisi");
        }
        if (tahunTerbit <= 0) {
            throw new IllegalArgumentException(
                    "tahunTerbit harus positif: " + tahunTerbit);
        }
        isbn = isbn.trim();
        judul = judul.trim();
        penulis = (penulis == null || penulis.isBlank()) ? "Tanpa Nama" : penulis.trim();
    }
}

LayarDemo.java

Unduh berkas
java
import java.time.LocalDate;

// Uji coba model domain dengan tanggal beku agar output selalu sama.
public class LayarDemo {

    public static void main(String[] args) {
        Buku laskar = new Buku("978-602-421-123-4", " Laskar Pelangi ",
                "Andrea Hirata", 2005);
        Buku salinan = new Buku("978-602-421-123-4", "Laskar Pelangi",
                "Andrea Hirata", 2005);

        System.out.println("buku          : " + laskar);
        System.out.println("judul dirapikan: " + laskar.judul().equals("Laskar Pelangi"));
        System.out.println("buku setara   : " + laskar.equals(salinan));

        Anggota budi = new Anggota("A-001", "Budi Santoso",
                "[email protected]", LocalDate.of(2026, 1, 2));
        System.out.println("anggota       : " + budi);

        Peminjaman pinjaman = new Peminjaman(laskar, budi, LocalDate.of(2026, 1, 5));
        System.out.println("pinjam        : " + pinjaman);
        System.out.println("masih aktif   : " + pinjaman.aktif());

        pinjaman.kembalikan(LocalDate.of(2026, 1, 18));
        System.out.println("sudah kembali : " + pinjaman);
        System.out.println("masih aktif   : " + pinjaman.aktif());

        // Validasi di compact constructor benar-benar aktif.
        try {
            new Buku("978-x", "Buku Uji", "Anonim", -1);
        } catch (IllegalArgumentException e) {
            System.out.println("buku ditolak  : " + e.getMessage());
        }
        try {
            new Anggota("X-9", "Dewi Lestari", "dewi.example.com", LocalDate.of(2026, 1, 5));
        } catch (IllegalArgumentException e) {
            System.out.println("anggota ditolak: " + e.getMessage());
        }
        try {
            pinjaman.kembalikan(LocalDate.of(2026, 1, 4));
        } catch (IllegalArgumentException e) {
            System.out.println("kembali ditolak: " + e.getMessage());
        }
        try {
            pinjaman.kembalikan(LocalDate.of(2026, 1, 20));
        } catch (IllegalStateException e) {
            System.out.println("ganda ditolak : " + e.getMessage());
        }
    }
}

Peminjaman.java

Unduh berkas
java
import java.time.LocalDate;
import java.util.Objects;

// Transaksi sirkulasi: tanggalKembali berubah saat buku dikembalikan,
// sehingga Peminjaman dibuat sebagai class biasa, bukan record.
public class Peminjaman {

    /** Lama peminjaman standar, dalam hari. */
    public static final int LAMA_PINJAM_HARI = 14;

    private final Buku buku;
    private final Anggota anggota;
    private final LocalDate tanggalPinjam;
    private final LocalDate jatuhTempo;
    private LocalDate tanggalKembali; // null selama buku belum kembali

    public Peminjaman(Buku buku, Anggota anggota, LocalDate tanggalPinjam) {
        this.buku = Objects.requireNonNull(buku, "buku tidak boleh null");
        this.anggota = Objects.requireNonNull(anggota, "anggota tidak boleh null");
        this.tanggalPinjam =
                Objects.requireNonNull(tanggalPinjam, "tanggalPinjam tidak boleh null");
        this.jatuhTempo = tanggalPinjam.plusDays(LAMA_PINJAM_HARI);
    }

    public Buku buku() {
        return buku;
    }

    public Anggota anggota() {
        return anggota;
    }

    public LocalDate tanggalPinjam() {
        return tanggalPinjam;
    }

    public LocalDate jatuhTempo() {
        return jatuhTempo;
    }

    /** Tanggal kembali aktual; null bila buku masih di tangan anggota. */
    public LocalDate tanggalKembali() {
        return tanggalKembali;
    }

    /** Apakah buku masih dipinjam? */
    public boolean aktif() {
        return tanggalKembali == null;
    }

    /** Catat pengembalian dan kunci transaksi agar tidak dikembalikan dua kali. */
    public void kembalikan(LocalDate tanggal) {
        Objects.requireNonNull(tanggal, "tanggal tidak boleh null");
        if (tanggal.isBefore(tanggalPinjam)) {
            throw new IllegalArgumentException(
                    "tanggal kembali " + tanggal + " lebih awal dari tanggal pinjam "
                            + tanggalPinjam);
        }
        if (!aktif()) {
            throw new IllegalStateException("peminjaman sudah selesai");
        }
        this.tanggalKembali = tanggal;
    }

    // toString eksplisit: hanya memuat data domain, tanpa alamat objek.
    @Override
    public String toString() {
        return "Peminjaman[judul=" + buku.judul() + ", anggota=" + anggota.nama()
                + ", pinjam=" + tanggalPinjam + ", jatuhTempo=" + jatuhTempo
                + ", kembali=" + (aktif() ? "-" : tanggalKembali) + "]";
    }
}