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:
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:
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:
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 transaksiMari kita bedah skenarionya:
pinjam 1sampaipinjam 3: Budi memegang dua buku dan Siti satu; setiap transaksi mencatat jatuh tempo2026-01-19— 14 hari setelahHARI_INI2026-01-05.ditolakpertama: Budi mencoba meminjam Filosofi Teras yang sedang dipegang Siti;BukuDipinjamExceptionmenyebut nama pemegang buku, sehingga petugas langsung tahu harus menunggu siapa.ditolakkedua: setelahpinjam 4melengkapi jatah Budi menjadi tiga buku, peminjaman keempat ditolakBatasPeminjamanTercapaiExceptiondengan pesan yang menyebut nama dan batasnya.terlambat: 6 haridandenda : Rp3.000: Laskar Pelangi kembali pada 2026-01-25, enam hari setelah jatuh tempo 2026-01-19;6 * 500 = 3000diformatformatRupiahmenjadiRp3.000.tepat : 0 haridandenda : Rp0: Hujan Bulan Juni kembali pada 2026-01-10 —ChronoUnit.DAYS.betweenmenghasilkan nilai negatif yang dipangkas menjadi nol.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.berjalan: Filosofi Teras milik Siti belum kembali; pada tanggal hitung 2026-02-01, 13 hari melewati jatuh tempo menghasilkanRp6.500— denda yang masih bertambah bila buku belum kembali.aktif : 3danriwayat : 5: peta aktif memegang tiga pinjaman yang belum selesai, sementarariwayatmenghitung 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 #
- Java API Specification: java.math.BigDecimal
- Java API Specification: java.time.temporal.ChronoUnit
- Java API Specification: java.time.LocalDate
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
Anggota.java
Unduh berkasimport 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// Nomor anggota yang dicari tidak terdaftar.
public class AnggotaTidakDitemukanException extends PerpustakaanException {
public AnggotaTidakDitemukanException(String pesan) {
super(pesan);
}
}
BatasPeminjamanTercapaiException.java
Unduh berkas// Anggota sudah memegang jumlah buku maksimum yang diizinkan.
public class BatasPeminjamanTercapaiException extends PerpustakaanException {
public BatasPeminjamanTercapaiException(String pesan) {
super(pesan);
}
}
Buku.java
Unduh berkasimport 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// 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// Buku yang dicari tidak ada di katalog.
public class BukuTidakDitemukanException extends PerpustakaanException {
public BukuTidakDitemukanException(String pesan) {
super(pesan);
}
}
LayarDemo.java
Unduh berkasimport 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 berkasimport 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 berkasimport 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// Induk exception domain: seluruh pelanggaran aturan perpustakaan.
public class PerpustakaanException extends RuntimeException {
public PerpustakaanException(String pesan) {
super(pesan);
}
}