Sirkulasi adalah jantung perpustakaan: buku keluar dari rak, melewati jatuh tempo, lalu kembali — kadang terlambat bersama dendanya. Bagian inilah tempat seluruh pondasi sebelumnya diuji: record Buku dan Anggota dirangkai menjadi transaksi, collection menampung sirkulasi yang hidup, dan exception domain menolak setiap pelanggaran aturan. Dua aturan bisnis baru ditanamkan: satu buku hanya boleh dimiliki satu peminjam pada satu waktu, dan satu anggota memegang maksimal tiga buku. Denda keterlambatan dihitung dengan BigDecimal dan ChronoUnit.DAYS dari tanggal beku.

Dua Invarian Sirkulasi #

Invarian adalah pernyataan yang harus selalu benar kapan pun state diamati. Sirkulasi menjaga dua invarian lewat struktur data yang dipilih sengaja: pinjamanAktif berupa LinkedHashMap berkunci ISBN — keberadaan kunci ISBN di peta itu adalah bukti bahwa buku sedang dipinjam — dan riwayat berupa List yang mencatat seluruh transaksi sejak program berjalan. Method pinjam menambah entri ke kedua wadah, method kembalikan menghapus entri dari peta aktif; tidak ada jalur ketiga untuk mengubah isi keduanya karena keduanya private.

Pemeriksaan batas tiga buku memakai method privat hitungPinjamanAktif yang menghitung ulang dari peta aktif. Pendekatan hitung-ulang menjaga satu sumber kebenaran: jumlah yang tersimpan tidak pernah basi karena selalu dihitung dari data sirkulasi saat itu juga.

Menolak Peminjaman yang Melanggar Aturan #

Dua kegagalan baru membutuhkan dua nama kegagalan baru, keduanya mewarisi PerpustakaanException dari Bagian 2:

// Buku yang ingin dipinjam sedang berada di tangan anggota lain.
public class BukuDipinjamException extends PerpustakaanException {

    public BukuDipinjamException(String pesan) {
        super(pesan);
    }
}
// Anggota sudah memegang jumlah buku maksimum yang diizinkan.
public class BatasPeminjamanTercapaiException extends PerpustakaanException {

    public BatasPeminjamanTercapaiException(String pesan) {
        super(pesan);
    }
}

Method pinjam memeriksa aturan berurutan: pencarian buku dan anggota lebih dulu, lalu ketersediaan buku, terakhir batas anggota. Buku, anggota, dan exception domain lain dari Bagian 2 tidak berubah dan tetap tersedia di bundle bagian ini:

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Layanan perpustakaan: katalog, keanggotaan, dan sirkulasi peminjaman.
public class Perpustakaan {

    /** Jumlah buku maksimum yang boleh dipinjam satu anggota. */
    public static final int BATAS_PINJAMAN_PER_ANGGOTA = 3;

    /** Denda keterlambatan per hari. */
    public static final BigDecimal TARIF_DENDA_PER_HARI = new BigDecimal("500");

    private final Map<String, Buku> katalog = new LinkedHashMap<>();
    private final Map<String, Anggota> anggota = new LinkedHashMap<>();

    // Invarian: satu ISBN hanya boleh punya satu pinjaman aktif.
    private final Map<String, Peminjaman> pinjamanAktif = new LinkedHashMap<>();
    private final List<Peminjaman> riwayat = new ArrayList<>();

    public void tambahBuku(Buku buku) {
        Buku sebelumnya = katalog.putIfAbsent(buku.isbn(), buku);
        if (sebelumnya != null) {
            throw new PerpustakaanException(
                    "ISBN sudah terdaftar: " + buku.isbn());
        }
    }

    public void daftarkan(Anggota baru) {
        Anggota sebelumnya = anggota.putIfAbsent(baru.id(), baru);
        if (sebelumnya != null) {
            throw new PerpustakaanException(
                    "ID anggota sudah terdaftar: " + baru.id());
        }
    }

    public Buku cariBukuIsbn(String isbn) {
        Buku buku = katalog.get(isbn);
        if (buku == null) {
            throw new BukuTidakDitemukanException(
                    "ISBN tidak ada di katalog: " + isbn);
        }
        return buku;
    }

    public Anggota cariAnggota(String id) {
        Anggota dicari = anggota.get(id);
        if (dicari == null) {
            throw new AnggotaTidakDitemukanException(
                    "ID anggota tidak terdaftar: " + id);
        }
        return dicari;
    }

    public List<Buku> cariBuku(String kataKunci) {
        List<Buku> hasil = new ArrayList<>();
        for (Buku buku : katalog.values()) {
            if (buku.cocokKataKunci(kataKunci)) {
                hasil.add(buku);
            }
        }
        return hasil;
    }

    /**
     * Pinjam sebuah buku. Melempar BukuTidakDitemukanException,
     * AnggotaTidakDitemukanException, BukuDipinjamException, atau
     * BatasPeminjamanTercapaiException bila aturan dilanggar.
     */
    public Peminjaman pinjam(String isbn, String idAnggota, LocalDate tanggal) {
        Buku buku = cariBukuIsbn(isbn);
        Anggota peminjam = cariAnggota(idAnggota);
        if (pinjamanAktif.containsKey(isbn)) {
            throw new BukuDipinjamException(
                    "buku sudah dipinjam oleh " + pinjamanAktif.get(isbn).anggota().nama()
                            + ": " + buku.judul());
        }
        if (hitungPinjamanAktif(idAnggota) >= BATAS_PINJAMAN_PER_ANGGOTA) {
            throw new BatasPeminjamanTercapaiException(
                    "anggota " + peminjam.nama() + " sudah memegang "
                            + BATAS_PINJAMAN_PER_ANGGOTA + " buku");
        }
        Peminjaman baru = new Peminjaman(buku, peminjam, tanggal);
        pinjamanAktif.put(isbn, baru);
        riwayat.add(baru);
        return baru;
    }

    /**
     * Kembalikan buku yang sedang dipinjam. Transaksi hanya dilepas dari
     * sirkulasi setelah pengembalian dinyatakan sah oleh Peminjaman.
     */
    public Peminjaman kembalikan(String isbn, LocalDate tanggal) {
        Peminjaman pinjaman = pinjamanAktif.get(isbn);
        if (pinjaman == null) {
            throw new PerpustakaanException(
                    "buku tidak sedang dipinjam: " + isbn);
        }
        pinjaman.kembalikan(tanggal); // bisa melempar bila tanggal tidak sah
        pinjamanAktif.remove(isbn);   // dijalankan hanya bila pengembalian sah
        return pinjaman;
    }

    /** Pinjaman aktif milik satu anggota, dalam urutan peminjaman. */
    public List<Peminjaman> pinjamanAktifAnggota(String idAnggota) {
        List<Peminjaman> hasil = new ArrayList<>();
        for (Peminjaman p : pinjamanAktif.values()) {
            if (p.anggota().id().equals(idAnggota)) {
                hasil.add(p);
            }
        }
        return hasil;
    }

    /** Seluruh transaksi (aktif dan selesai) dalam urutan kejadian. */
    public List<Peminjaman> riwayat() {
        return List.copyOf(riwayat);
    }

    public int jumlahPinjamanAktif() {
        return pinjamanAktif.size();
    }

    public List<Buku> semuaBuku() {
        return List.copyOf(katalog.values());
    }

    public List<Anggota> semuaAnggota() {
        return List.copyOf(anggota.values());
    }

    public int jumlahBuku() {
        return katalog.size();
    }

    public int jumlahAnggota() {
        return anggota.size();
    }

    private int hitungPinjamanAktif(String idAnggota) {
        int jumlah = 0;
        for (Peminjaman p : pinjamanAktif.values()) {
            if (p.anggota().id().equals(idAnggota)) {
                jumlah++;
            }
        }
        return jumlah;
    }
}

Tiga hal pada class layanan ini patut dicermati. Pertama, TARIF_DENDA_PER_HARI dideklarasikan new BigDecimal("500") — dari string, bukan double, agar nilai desimal tersimpan persis tanpa galat biner. Kedua, kembalikan memanggil pinjaman.kembalikan(tanggal) sebelum menghapus entri dari pinjamanAktif; bila tanggal tidak sah dan IllegalArgumentException terlempar, peta aktif tidak tersentuh sehingga invarian tetap utuh. Ketiga, pengembalian yang sah melepas ISBN dari peta aktif — buku langsung dapat dipinjam anggota lain.

Denda dengan BigDecimal dan ChronoUnit #

Denda adalah uang, dan uang tidak boleh dihitung dengan double — representasi biner floating point membuat 0.1 + 0.2 tidak persis 0.3. Method berikut memakai BigDecimal untuk aritmetika dan ChronoUnit.DAYS.between untuk selisih hari:

java Peminjaman.java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;

// Transaksi sirkulasi: berubah saat buku dikembalikan, plus hitungan denda.
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;
    }

    /**
     * Selisih hari dari jatuh tempo; 0 bila tidak terlambat.
     * Acuan: tanggal kembali aktual bila transaksi sudah selesai,
     * atau tanggalHitung untuk pinjaman yang masih berjalan.
     */
    public long hariTerlambat(LocalDate tanggalHitung) {
        LocalDate acuan = (tanggalKembali != null) ? tanggalKembali : tanggalHitung;
        return Math.max(0, ChronoUnit.DAYS.between(jatuhTempo, acuan));
    }

    /** Denda = hari terlambat dikali tarif per hari; tanpa pembulatan. */
    public BigDecimal denda(LocalDate tanggalHitung, BigDecimal tarifPerHari) {
        Objects.requireNonNull(tarifPerHari, "tarifPerHari tidak boleh null");
        return BigDecimal.valueOf(hariTerlambat(tanggalHitung)).multiply(tarifPerHari);
    }

    // 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) + "]";
    }
}

Method hariTerlambat melayani dua keadaan. Untuk transaksi yang sudah selesai, acuan adalah tanggalKembali — keterlambatan terkunci saat pengembalian. Untuk transaksi berjalan, acuan adalah tanggalHitung yang diisikan pemanggil, sehingga denda berjalan dapat dihitung pada tanggal berapa pun; Math.max(0, ...) menjaga pengembalian lebih awal dari jatuh tempo tetap menghasilkan nol, bukan angka negatif. Denda sendiri hanya satu baris: BigDecimal.valueOf(hari).multiply(tarifPerHari) — enam hari terlambat dikali tarif 500 menghasilkan 3000 tanpa pembulatan.

Demo Sirkulasi Lengkap #

Skenario berikut memakai tanggal beku Januari–Februari 2026, dari peminjaman pertama sampai denda berjalan:

java LayarDemo.java
import java.math.BigDecimal;
import java.time.LocalDate;

// Demo sirkulasi lengkap: peminjaman, penolakan aturan, pengembalian, denda.
public class LayarDemo {

    private static final LocalDate HARI_INI = LocalDate.of(2026, 1, 5);

    public static void main(String[] args) {
        Perpustakaan perpus = new Perpustakaan();
        isiData(perpus);

        // 1. Budi meminjam dua buku, Siti satu buku.
        System.out.println("pinjam 1 : " + perpus.pinjam("978-602-421-123-4", "A-001", HARI_INI));
        System.out.println("pinjam 2 : " + perpus.pinjam("978-602-032-556-2", "A-001", HARI_INI));
        System.out.println("pinjam 3 : " + perpus.pinjam("978-602-401-718-3", "A-002", HARI_INI));

        // 2. Buku yang sudah dipinjam tidak dapat dipinjam lagi.
        try {
            perpus.pinjam("978-602-401-718-3", "A-001", HARI_INI);
        } catch (BukuDipinjamException e) {
            System.out.println("ditolak  : " + e.getMessage());
        }

        // 3. Budi menambah buku ketiga, lalu menabrak batas pinjaman.
        System.out.println("pinjam 4 : " + perpus.pinjam("978-602-291-024-4", "A-001", HARI_INI));
        try {
            perpus.pinjam("978-979-433-555-1", "A-001", HARI_INI);
        } catch (BatasPeminjamanTercapaiException e) {
            System.out.println("ditolak  : " + e.getMessage());
        }

        // 4. Pengembalian terlambat: jatuh tempo 2026-01-19, kembali 2026-01-25.
        Peminjaman terlambat = perpus.kembalikan("978-602-421-123-4", LocalDate.of(2026, 1, 25));
        System.out.println("terlambat: " + terlambat.hariTerlambat(LocalDate.of(2026, 1, 25)) + " hari");
        System.out.println("denda    : " + formatRupiah(
                terlambat.denda(LocalDate.of(2026, 1, 25), Perpustakaan.TARIF_DENDA_PER_HARI)));

        // 5. Pengembalian tepat waktu: denda nol.
        Peminjaman tepat = perpus.kembalikan("978-602-032-556-2", LocalDate.of(2026, 1, 10));
        System.out.println("tepat    : " + tepat.hariTerlambat(LocalDate.of(2026, 1, 10)) + " hari");
        System.out.println("denda    : " + formatRupiah(
                tepat.denda(LocalDate.of(2026, 1, 10), Perpustakaan.TARIF_DENDA_PER_HARI)));

        // 6. Buku yang sudah kembali boleh dipinjam anggota lain.
        System.out.println("pinjam 5 : "
                + perpus.pinjam("978-602-421-123-4", "A-003", LocalDate.of(2026, 1, 26)));

        // 7. Denda berjalan untuk pinjaman yang belum dikembalikan.
        LocalDate tanggalHitung = LocalDate.of(2026, 2, 1);
        for (Peminjaman p : perpus.pinjamanAktifAnggota("A-002")) {
            System.out.println("berjalan : " + p.buku().judul() + ", terlambat "
                    + p.hariTerlambat(tanggalHitung) + " hari, denda " + formatRupiah(
                    p.denda(tanggalHitung, Perpustakaan.TARIF_DENDA_PER_HARI)));
        }

        // 8. Ringkasan akhir.
        System.out.println("aktif    : " + perpus.jumlahPinjamanAktif() + " pinjaman");
        System.out.println("riwayat  : " + perpus.riwayat().size() + " transaksi");
    }

    static void isiData(Perpustakaan perpus) {
        perpus.tambahBuku(new Buku("978-602-421-123-4", "Laskar Pelangi",
                "Andrea Hirata", 2005));
        perpus.tambahBuku(new Buku("978-979-433-555-1", "Bumi Manusia",
                "Pramoedya Ananta Toer", 1980));
        perpus.tambahBuku(new Buku("978-602-032-556-2", "Hujan Bulan Juni",
                "Sapardi Djoko Damono", 1994));
        perpus.tambahBuku(new Buku("978-602-401-718-3", "Filosofi Teras",
                "Henry Manampiring", 2018));
        perpus.tambahBuku(new Buku("978-602-291-024-4", "Laut Bercerita",
                "Leila S. Chudori", 2017));
        perpus.daftarkan(new Anggota("A-001", "Budi Santoso",
                "[email protected]", LocalDate.of(2026, 1, 2)));
        perpus.daftarkan(new Anggota("A-002", "Siti Rahayu",
                "[email protected]", LocalDate.of(2026, 1, 3)));
        perpus.daftarkan(new Anggota("A-003", "Dewi Lestari",
                "[email protected]", LocalDate.of(2026, 1, 5)));
    }

    // Format uang sederhana: 3000 menjadi "Rp3.000".
    static String formatRupiah(BigDecimal nilai) {
        return "Rp" + nilai.setScale(0).toPlainString()
                .replaceAll("\\B(?=(\\d{3})+(?!\\d))", ".");
    }
}

Output yang Dihasilkan:

text
pinjam 1 : Peminjaman[judul=Laskar Pelangi, anggota=Budi Santoso, pinjam=2026-01-05, jatuhTempo=2026-01-19, kembali=-]
pinjam 2 : Peminjaman[judul=Hujan Bulan Juni, anggota=Budi Santoso, pinjam=2026-01-05, jatuhTempo=2026-01-19, kembali=-]
pinjam 3 : Peminjaman[judul=Filosofi Teras, anggota=Siti Rahayu, pinjam=2026-01-05, jatuhTempo=2026-01-19, kembali=-]
ditolak  : buku sudah dipinjam oleh Siti Rahayu: Filosofi Teras
pinjam 4 : Peminjaman[judul=Laut Bercerita, anggota=Budi Santoso, pinjam=2026-01-05, jatuhTempo=2026-01-19, kembali=-]
ditolak  : anggota Budi Santoso sudah memegang 3 buku
terlambat: 6 hari
denda    : Rp3.000
tepat    : 0 hari
denda    : Rp0
pinjam 5 : Peminjaman[judul=Laskar Pelangi, anggota=Dewi Lestari, pinjam=2026-01-26, jatuhTempo=2026-02-09, kembali=-]
berjalan : Filosofi Teras, terlambat 13 hari, denda Rp6.500
aktif    : 3 pinjaman
riwayat  : 5 transaksi

Mari kita bedah skenarionya:

  1. pinjam 1 sampai pinjam 3: Budi memegang dua buku dan Siti satu; setiap transaksi mencatat jatuh tempo 2026-01-19 — 14 hari setelah HARI_INI 2026-01-05.
  2. ditolak pertama: Budi mencoba meminjam Filosofi Teras yang sedang dipegang Siti; BukuDipinjamException menyebut nama pemegang buku, sehingga petugas langsung tahu harus menunggu siapa.
  3. ditolak kedua: setelah pinjam 4 melengkapi jatah Budi menjadi tiga buku, peminjaman keempat ditolak BatasPeminjamanTercapaiException dengan pesan yang menyebut nama dan batasnya.
  4. terlambat: 6 hari dan denda : Rp3.000: Laskar Pelangi kembali pada 2026-01-25, enam hari setelah jatuh tempo 2026-01-19; 6 * 500 = 3000 diformat formatRupiah menjadi Rp3.000.
  5. tepat : 0 hari dan denda : Rp0: Hujan Bulan Juni kembali pada 2026-01-10 — ChronoUnit.DAYS.between menghasilkan nilai negatif yang dipangkas menjadi nol.
  6. pinjam 5: Laskar Pelangi yang sudah dikembalikan langsung dipinjam Dewi pada 2026-01-26 dengan jatuh tempo baru 2026-02-09 — bukti buku kembali beredar.
  7. berjalan: Filosofi Teras milik Siti belum kembali; pada tanggal hitung 2026-02-01, 13 hari melewati jatuh tempo menghasilkan Rp6.500 — denda yang masih bertambah bila buku belum kembali.
  8. aktif : 3 dan riwayat : 5: peta aktif memegang tiga pinjaman yang belum selesai, sementara riwayat menghitung seluruh transaksi termasuk dua yang sudah selesai.

Sirkulasi kini lengkap: aturan dijaga, denda terhitung, riwayat tercatat. Namun seluruh state itu hidup di memori — begitu program berhenti, katalog dan sisa pinjaman aktif hilang bersama proses JVM. Bagian 4 menutup proyek dengan persistensi berbasis berkas dan harness pengujian atas seluruh aturan bisnis.

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();
    }
}

AnggotaTidakDitemukanException.java

Unduh berkas
java
// Nomor anggota yang dicari tidak terdaftar.
public class AnggotaTidakDitemukanException extends PerpustakaanException {

    public AnggotaTidakDitemukanException(String pesan) {
        super(pesan);
    }
}

BatasPeminjamanTercapaiException.java

Unduh berkas
java
// Anggota sudah memegang jumlah buku maksimum yang diizinkan.
public class BatasPeminjamanTercapaiException extends PerpustakaanException {

    public BatasPeminjamanTercapaiException(String pesan) {
        super(pesan);
    }
}

Buku.java

Unduh berkas
java
import java.util.Locale;

// Model katalog: data buku beku (record) + pencocokan kata kunci pencarian.
public record Buku(String isbn, String judul, String penulis, int tahunTerbit) {

    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();
    }

    /** Apakah judul, penulis, atau ISBN memuat kata kunci (huruf besar diabaikan)? */
    public boolean cocokKataKunci(String kataKunci) {
        String kunci = kataKunci.toLowerCase(Locale.ROOT);
        return judul().toLowerCase(Locale.ROOT).contains(kunci)
                || penulis().toLowerCase(Locale.ROOT).contains(kunci)
                || isbn().contains(kunci);
    }
}

BukuDipinjamException.java

Unduh berkas
java
// Buku yang ingin dipinjam sedang berada di tangan anggota lain.
public class BukuDipinjamException extends PerpustakaanException {

    public BukuDipinjamException(String pesan) {
        super(pesan);
    }
}

BukuTidakDitemukanException.java

Unduh berkas
java
// Buku yang dicari tidak ada di katalog.
public class BukuTidakDitemukanException extends PerpustakaanException {

    public BukuTidakDitemukanException(String pesan) {
        super(pesan);
    }
}

LayarDemo.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.LocalDate;

// Demo sirkulasi lengkap: peminjaman, penolakan aturan, pengembalian, denda.
public class LayarDemo {

    private static final LocalDate HARI_INI = LocalDate.of(2026, 1, 5);

    public static void main(String[] args) {
        Perpustakaan perpus = new Perpustakaan();
        isiData(perpus);

        // 1. Budi meminjam dua buku, Siti satu buku.
        System.out.println("pinjam 1 : " + perpus.pinjam("978-602-421-123-4", "A-001", HARI_INI));
        System.out.println("pinjam 2 : " + perpus.pinjam("978-602-032-556-2", "A-001", HARI_INI));
        System.out.println("pinjam 3 : " + perpus.pinjam("978-602-401-718-3", "A-002", HARI_INI));

        // 2. Buku yang sudah dipinjam tidak dapat dipinjam lagi.
        try {
            perpus.pinjam("978-602-401-718-3", "A-001", HARI_INI);
        } catch (BukuDipinjamException e) {
            System.out.println("ditolak  : " + e.getMessage());
        }

        // 3. Budi menambah buku ketiga, lalu menabrak batas pinjaman.
        System.out.println("pinjam 4 : " + perpus.pinjam("978-602-291-024-4", "A-001", HARI_INI));
        try {
            perpus.pinjam("978-979-433-555-1", "A-001", HARI_INI);
        } catch (BatasPeminjamanTercapaiException e) {
            System.out.println("ditolak  : " + e.getMessage());
        }

        // 4. Pengembalian terlambat: jatuh tempo 2026-01-19, kembali 2026-01-25.
        Peminjaman terlambat = perpus.kembalikan("978-602-421-123-4", LocalDate.of(2026, 1, 25));
        System.out.println("terlambat: " + terlambat.hariTerlambat(LocalDate.of(2026, 1, 25)) + " hari");
        System.out.println("denda    : " + formatRupiah(
                terlambat.denda(LocalDate.of(2026, 1, 25), Perpustakaan.TARIF_DENDA_PER_HARI)));

        // 5. Pengembalian tepat waktu: denda nol.
        Peminjaman tepat = perpus.kembalikan("978-602-032-556-2", LocalDate.of(2026, 1, 10));
        System.out.println("tepat    : " + tepat.hariTerlambat(LocalDate.of(2026, 1, 10)) + " hari");
        System.out.println("denda    : " + formatRupiah(
                tepat.denda(LocalDate.of(2026, 1, 10), Perpustakaan.TARIF_DENDA_PER_HARI)));

        // 6. Buku yang sudah kembali boleh dipinjam anggota lain.
        System.out.println("pinjam 5 : "
                + perpus.pinjam("978-602-421-123-4", "A-003", LocalDate.of(2026, 1, 26)));

        // 7. Denda berjalan untuk pinjaman yang belum dikembalikan.
        LocalDate tanggalHitung = LocalDate.of(2026, 2, 1);
        for (Peminjaman p : perpus.pinjamanAktifAnggota("A-002")) {
            System.out.println("berjalan : " + p.buku().judul() + ", terlambat "
                    + p.hariTerlambat(tanggalHitung) + " hari, denda " + formatRupiah(
                    p.denda(tanggalHitung, Perpustakaan.TARIF_DENDA_PER_HARI)));
        }

        // 8. Ringkasan akhir.
        System.out.println("aktif    : " + perpus.jumlahPinjamanAktif() + " pinjaman");
        System.out.println("riwayat  : " + perpus.riwayat().size() + " transaksi");
    }

    static void isiData(Perpustakaan perpus) {
        perpus.tambahBuku(new Buku("978-602-421-123-4", "Laskar Pelangi",
                "Andrea Hirata", 2005));
        perpus.tambahBuku(new Buku("978-979-433-555-1", "Bumi Manusia",
                "Pramoedya Ananta Toer", 1980));
        perpus.tambahBuku(new Buku("978-602-032-556-2", "Hujan Bulan Juni",
                "Sapardi Djoko Damono", 1994));
        perpus.tambahBuku(new Buku("978-602-401-718-3", "Filosofi Teras",
                "Henry Manampiring", 2018));
        perpus.tambahBuku(new Buku("978-602-291-024-4", "Laut Bercerita",
                "Leila S. Chudori", 2017));
        perpus.daftarkan(new Anggota("A-001", "Budi Santoso",
                "[email protected]", LocalDate.of(2026, 1, 2)));
        perpus.daftarkan(new Anggota("A-002", "Siti Rahayu",
                "[email protected]", LocalDate.of(2026, 1, 3)));
        perpus.daftarkan(new Anggota("A-003", "Dewi Lestari",
                "[email protected]", LocalDate.of(2026, 1, 5)));
    }

    // Format uang sederhana: 3000 menjadi "Rp3.000".
    static String formatRupiah(BigDecimal nilai) {
        return "Rp" + nilai.setScale(0).toPlainString()
                .replaceAll("\\B(?=(\\d{3})+(?!\\d))", ".");
    }
}

Peminjaman.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;

// Transaksi sirkulasi: berubah saat buku dikembalikan, plus hitungan denda.
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;
    }

    /**
     * Selisih hari dari jatuh tempo; 0 bila tidak terlambat.
     * Acuan: tanggal kembali aktual bila transaksi sudah selesai,
     * atau tanggalHitung untuk pinjaman yang masih berjalan.
     */
    public long hariTerlambat(LocalDate tanggalHitung) {
        LocalDate acuan = (tanggalKembali != null) ? tanggalKembali : tanggalHitung;
        return Math.max(0, ChronoUnit.DAYS.between(jatuhTempo, acuan));
    }

    /** Denda = hari terlambat dikali tarif per hari; tanpa pembulatan. */
    public BigDecimal denda(LocalDate tanggalHitung, BigDecimal tarifPerHari) {
        Objects.requireNonNull(tarifPerHari, "tarifPerHari tidak boleh null");
        return BigDecimal.valueOf(hariTerlambat(tanggalHitung)).multiply(tarifPerHari);
    }

    // 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) + "]";
    }
}

Perpustakaan.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Layanan perpustakaan: katalog, keanggotaan, dan sirkulasi peminjaman.
public class Perpustakaan {

    /** Jumlah buku maksimum yang boleh dipinjam satu anggota. */
    public static final int BATAS_PINJAMAN_PER_ANGGOTA = 3;

    /** Denda keterlambatan per hari. */
    public static final BigDecimal TARIF_DENDA_PER_HARI = new BigDecimal("500");

    private final Map<String, Buku> katalog = new LinkedHashMap<>();
    private final Map<String, Anggota> anggota = new LinkedHashMap<>();

    // Invarian: satu ISBN hanya boleh punya satu pinjaman aktif.
    private final Map<String, Peminjaman> pinjamanAktif = new LinkedHashMap<>();
    private final List<Peminjaman> riwayat = new ArrayList<>();

    public void tambahBuku(Buku buku) {
        Buku sebelumnya = katalog.putIfAbsent(buku.isbn(), buku);
        if (sebelumnya != null) {
            throw new PerpustakaanException(
                    "ISBN sudah terdaftar: " + buku.isbn());
        }
    }

    public void daftarkan(Anggota baru) {
        Anggota sebelumnya = anggota.putIfAbsent(baru.id(), baru);
        if (sebelumnya != null) {
            throw new PerpustakaanException(
                    "ID anggota sudah terdaftar: " + baru.id());
        }
    }

    public Buku cariBukuIsbn(String isbn) {
        Buku buku = katalog.get(isbn);
        if (buku == null) {
            throw new BukuTidakDitemukanException(
                    "ISBN tidak ada di katalog: " + isbn);
        }
        return buku;
    }

    public Anggota cariAnggota(String id) {
        Anggota dicari = anggota.get(id);
        if (dicari == null) {
            throw new AnggotaTidakDitemukanException(
                    "ID anggota tidak terdaftar: " + id);
        }
        return dicari;
    }

    public List<Buku> cariBuku(String kataKunci) {
        List<Buku> hasil = new ArrayList<>();
        for (Buku buku : katalog.values()) {
            if (buku.cocokKataKunci(kataKunci)) {
                hasil.add(buku);
            }
        }
        return hasil;
    }

    /**
     * Pinjam sebuah buku. Melempar BukuTidakDitemukanException,
     * AnggotaTidakDitemukanException, BukuDipinjamException, atau
     * BatasPeminjamanTercapaiException bila aturan dilanggar.
     */
    public Peminjaman pinjam(String isbn, String idAnggota, LocalDate tanggal) {
        Buku buku = cariBukuIsbn(isbn);
        Anggota peminjam = cariAnggota(idAnggota);
        if (pinjamanAktif.containsKey(isbn)) {
            throw new BukuDipinjamException(
                    "buku sudah dipinjam oleh " + pinjamanAktif.get(isbn).anggota().nama()
                            + ": " + buku.judul());
        }
        if (hitungPinjamanAktif(idAnggota) >= BATAS_PINJAMAN_PER_ANGGOTA) {
            throw new BatasPeminjamanTercapaiException(
                    "anggota " + peminjam.nama() + " sudah memegang "
                            + BATAS_PINJAMAN_PER_ANGGOTA + " buku");
        }
        Peminjaman baru = new Peminjaman(buku, peminjam, tanggal);
        pinjamanAktif.put(isbn, baru);
        riwayat.add(baru);
        return baru;
    }

    /**
     * Kembalikan buku yang sedang dipinjam. Transaksi hanya dilepas dari
     * sirkulasi setelah pengembalian dinyatakan sah oleh Peminjaman.
     */
    public Peminjaman kembalikan(String isbn, LocalDate tanggal) {
        Peminjaman pinjaman = pinjamanAktif.get(isbn);
        if (pinjaman == null) {
            throw new PerpustakaanException(
                    "buku tidak sedang dipinjam: " + isbn);
        }
        pinjaman.kembalikan(tanggal); // bisa melempar bila tanggal tidak sah
        pinjamanAktif.remove(isbn);   // dijalankan hanya bila pengembalian sah
        return pinjaman;
    }

    /** Pinjaman aktif milik satu anggota, dalam urutan peminjaman. */
    public List<Peminjaman> pinjamanAktifAnggota(String idAnggota) {
        List<Peminjaman> hasil = new ArrayList<>();
        for (Peminjaman p : pinjamanAktif.values()) {
            if (p.anggota().id().equals(idAnggota)) {
                hasil.add(p);
            }
        }
        return hasil;
    }

    /** Seluruh transaksi (aktif dan selesai) dalam urutan kejadian. */
    public List<Peminjaman> riwayat() {
        return List.copyOf(riwayat);
    }

    public int jumlahPinjamanAktif() {
        return pinjamanAktif.size();
    }

    public List<Buku> semuaBuku() {
        return List.copyOf(katalog.values());
    }

    public List<Anggota> semuaAnggota() {
        return List.copyOf(anggota.values());
    }

    public int jumlahBuku() {
        return katalog.size();
    }

    public int jumlahAnggota() {
        return anggota.size();
    }

    private int hitungPinjamanAktif(String idAnggota) {
        int jumlah = 0;
        for (Peminjaman p : pinjamanAktif.values()) {
            if (p.anggota().id().equals(idAnggota)) {
                jumlah++;
            }
        }
        return jumlah;
    }
}

PerpustakaanException.java

Unduh berkas
java
// Induk exception domain: seluruh pelanggaran aturan perpustakaan.
public class PerpustakaanException extends RuntimeException {

    public PerpustakaanException(String pesan) {
        super(pesan);
    }
}