Java Foundations/Mini-challenge: Model hoá thư viện với record + class
35/35
Bài 35 / 35~30 phútOOP cơ bản — class, object, encapsulationMiễn phí lượt xem

Mini-challenge: Model hoá thư viện với record + class

Bài thực hành khép lại Module 5 — xây Library quản lý Book và Borrower. Áp dụng record cho value object, class cho mutable state, encapsulation, validation, equals/hashCode.

Đây là mini-challenge khép lại Module 5. Bạn sẽ model một thư viện nhỏ: có sách (Book — record), có người mượn (Borrower — record), có thư viện (Library — class quản lý state). Kết hợp đủ các khái niệm: class, object, constructor, encapsulation, equals/hashCode, record, validation. Không dùng thư viện ngoài — chỉ JDK.

Sau bài này bạn có một module nhỏ đúng chuẩn: value object immutable cho dữ liệu, mutable class cho state có hành vi, API public gọn, nội bộ private.

🎯 Đề bài

Viết 3 type quản lý một thư viện:

1. record Book(String isbn, String title, String author)

  • Validation trong compact constructor: isbn, title, author đều không null, không blank.
  • Chính isbn định danh một cuốn sách — cho dù record so tất cả field, bạn không phải lo thêm gì (nhưng phải hiểu record mặc định so 3 field).

2. record Borrower(String id, String name)

  • Validate tương tự: cả 2 field không null/blank.

3. class Library

  • Field private:
    • Map<Book, Borrower> loans — tra ai đang mượn sách nào (null nghĩa là còn trong kho).
    • Set<Book> catalog — danh mục sách thư viện có.
  • Method public:
    • void addBook(Book book) — thêm sách vào catalog. Throw nếu đã có.
    • void borrow(Book book, Borrower borrower) — mượn sách. Throw nếu sách không có trong catalog hoặc đang được mượn.
    • void returnBook(Book book) — trả sách. Throw nếu sách không phải đang được mượn.
    • Optional<Borrower> whoBorrowed(Book book) — ai đang giữ sách; Optional.empty() nếu còn trong kho.
    • List<Book> availableBooks() — danh sách sách còn trong kho (immutable snapshot).
  • Override toString cho Library hiển thị tổng số sách + số sách đang mượn.

Output mẫu:

Library added 3 books
Alice borrowed Clean Code
Book Clean Code is held by Borrower[id=B001, name=Alice]
Available books: [Book[isbn=2, title=Effective Java, author=Bloch], Book[isbn=3, title=Java Concurrency, author=Goetz]]
Library{total=3, onLoan=1}
Alice returned Clean Code
Library{total=3, onLoan=0}

🔍 Phân tích I-P-O

Input: Tạo Book, Borrower, thao tác qua API Library.

Processing: Quản lý state trong Library (catalog, loans), validate mọi operation.

Output: Trạng thái library cập nhật, exception khi operation sai.

Key decisions

  • Book, Borrower là record: immutable, so nội dung, dùng làm key trong Map/Set — cần equals/hashCode (record tự sinh).
  • Library là class: có mutable state (catalog lớn dần, loans thay đổi mỗi lần mượn/trả). Record không phù hợp.
  • Encapsulation: catalogloansprivate, chỉ lộ qua method có validate.
  • Optional cho whoBorrowed: rõ hơn return null, caller buộc phải xử lý case "không có".

📦 Concept dùng trong bài

ConceptBàiDùng ở đây
Class và objectModule 5, bài 1Library
Constructor + thisModule 5, bài 2Library constructor khởi tạo field
Instance vs static fieldModule 5, bài 3catalog, loans là instance field
EncapsulationModule 5, bài 4Field private, method public, trả snapshot
equals/hashCodeModule 5, bài 5Record Book dùng làm key Map (cần contract)
RecordModule 5, bài 6Book, Borrower với compact constructor validate
Optional(preview)whoBorrowed — sẽ học sâu ở module sau

▶️ Starter code

import java.util.*;

public class Library {

    // TODO: record Book(String isbn, String title, String author) voi compact constructor validate

    // TODO: record Borrower(String id, String name) voi compact constructor validate

    // TODO: field private: Set<Book> catalog, Map<Book, Borrower> loans

    // TODO: constructor khoi tao hai collection rong

    // TODO: public void addBook(Book book)

    // TODO: public void borrow(Book book, Borrower borrower)

    // TODO: public void returnBook(Book book)

    // TODO: public Optional<Borrower> whoBorrowed(Book book)

    // TODO: public List<Book> availableBooks()

    // TODO: @Override public String toString()

    public static void main(String[] args) {
        Library lib = new Library();
        Book b1 = new Book("1", "Clean Code", "Martin");
        Book b2 = new Book("2", "Effective Java", "Bloch");
        Book b3 = new Book("3", "Java Concurrency", "Goetz");

        lib.addBook(b1);
        lib.addBook(b2);
        lib.addBook(b3);
        System.out.println("Library added 3 books");

        Borrower alice = new Borrower("B001", "Alice");
        lib.borrow(b1, alice);
        System.out.println("Alice borrowed " + b1.title());

        System.out.println("Book " + b1.title() + " is held by " + lib.whoBorrowed(b1).orElse(null));
        System.out.println("Available books: " + lib.availableBooks());
        System.out.println(lib);

        lib.returnBook(b1);
        System.out.println("Alice returned " + b1.title());
        System.out.println(lib);
    }
}
javac Library.java
java Library

Dành 25–30 phút tự làm. Gợi ý ở phần dưới nếu kẹt.

💡 Gợi ý

💡 Gợi ý — đọc khi bị kẹt

Record với compact constructor validate:

record Book(String isbn, String title, String author) {
    public Book {
        if (isbn == null || isbn.isBlank()) {
            throw new IllegalArgumentException("isbn must not be blank");
        }
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title must not be blank");
        }
        if (author == null || author.isBlank()) {
            throw new IllegalArgumentException("author must not be blank");
        }
    }
}

Library constructor khởi tạo empty collection:

private final Set<Book> catalog;
private final Map<Book, Borrower> loans;

public Library() {
    this.catalog = new HashSet<>();
    this.loans = new HashMap<>();
}

Chú ý: catalogloansfinal — reference không thay đổi; nhưng nội dung vẫn mutable (thêm/xoá entry).

addBook — throw nếu trùng:

public void addBook(Book book) {
    Objects.requireNonNull(book, "book must not be null");
    if (!catalog.add(book)) {
        throw new IllegalStateException("Book already in catalog: " + book.isbn());
    }
}

Set.add() trả false nếu phần tử đã có (dựa vào equals/hashCode của record).

borrow — validate catalog + available:

public void borrow(Book book, Borrower borrower) {
    Objects.requireNonNull(book);
    Objects.requireNonNull(borrower);
    if (!catalog.contains(book)) {
        throw new IllegalStateException("Book not in catalog: " + book.isbn());
    }
    if (loans.containsKey(book)) {
        throw new IllegalStateException("Book already on loan: " + book.isbn());
    }
    loans.put(book, borrower);
}

returnBook — remove từ loans:

public void returnBook(Book book) {
    if (loans.remove(book) == null) {
        throw new IllegalStateException("Book not on loan: " + book.isbn());
    }
}

Map.remove trả null nếu key không có — gộp check + action trong 1 dòng.

whoBorrowed — Optional:

public Optional<Borrower> whoBorrowed(Book book) {
    return Optional.ofNullable(loans.get(book));
}

availableBooks — snapshot immutable, loại sách đang mượn:

public List<Book> availableBooks() {
    return catalog.stream()
        .filter(b -> !loans.containsKey(b))
        .toList();   // Java 16+, immutable
}

toString:

@Override
public String toString() {
    return "Library{total=" + catalog.size() + ", onLoan=" + loans.size() + "}";
}

✅ Lời giải

✅ Lời giải — xem sau khi đã thử
import java.util.*;

public class Library {

    public record Book(String isbn, String title, String author) {
        public Book {
            if (isbn == null || isbn.isBlank())
                throw new IllegalArgumentException("isbn must not be blank");
            if (title == null || title.isBlank())
                throw new IllegalArgumentException("title must not be blank");
            if (author == null || author.isBlank())
                throw new IllegalArgumentException("author must not be blank");
        }
    }

    public record Borrower(String id, String name) {
        public Borrower {
            if (id == null || id.isBlank())
                throw new IllegalArgumentException("id must not be blank");
            if (name == null || name.isBlank())
                throw new IllegalArgumentException("name must not be blank");
        }
    }

    private final Set<Book> catalog;
    private final Map<Book, Borrower> loans;

    public Library() {
        this.catalog = new HashSet<>();
        this.loans = new HashMap<>();
    }

    public void addBook(Book book) {
        Objects.requireNonNull(book, "book must not be null");
        if (!catalog.add(book)) {
            throw new IllegalStateException("Book already in catalog: " + book.isbn());
        }
    }

    public void borrow(Book book, Borrower borrower) {
        Objects.requireNonNull(book);
        Objects.requireNonNull(borrower);
        if (!catalog.contains(book))
            throw new IllegalStateException("Book not in catalog: " + book.isbn());
        if (loans.containsKey(book))
            throw new IllegalStateException("Book already on loan: " + book.isbn());
        loans.put(book, borrower);
    }

    public void returnBook(Book book) {
        if (loans.remove(book) == null) {
            throw new IllegalStateException("Book not on loan: " +
                (book == null ? "null" : book.isbn()));
        }
    }

    public Optional<Borrower> whoBorrowed(Book book) {
        return Optional.ofNullable(loans.get(book));
    }

    public List<Book> availableBooks() {
        return catalog.stream()
            .filter(b -> !loans.containsKey(b))
            .toList();
    }

    @Override
    public String toString() {
        return "Library{total=" + catalog.size() + ", onLoan=" + loans.size() + "}";
    }

    public static void main(String[] args) {
        Library lib = new Library();
        Book b1 = new Book("1", "Clean Code", "Martin");
        Book b2 = new Book("2", "Effective Java", "Bloch");
        Book b3 = new Book("3", "Java Concurrency", "Goetz");

        lib.addBook(b1);
        lib.addBook(b2);
        lib.addBook(b3);
        System.out.println("Library added 3 books");

        Borrower alice = new Borrower("B001", "Alice");
        lib.borrow(b1, alice);
        System.out.println("Alice borrowed " + b1.title());

        System.out.println("Book " + b1.title() + " is held by " + lib.whoBorrowed(b1).orElse(null));
        System.out.println("Available books: " + lib.availableBooks());
        System.out.println(lib);

        lib.returnBook(b1);
        System.out.println("Alice returned " + b1.title());
        System.out.println(lib);
    }
}

Giải thích từng phần:

  • Book record — value object. 3 field là canonical component; record tự sinh equals/hashCode dựa trên cả 3 → 2 Book cùng isbn+title+author được coi là bằng nhau, dùng trực tiếp trong Set/Map key không cần viết thêm.

  • Borrower record — tương tự.

  • Compact constructor validate — kiểm nullisBlank() cho mọi string, throw IllegalArgumentException. Sau khi pass, compiler gán field.

  • catalog: Set<Book> — danh mục. HashSet đảm bảo thêm O(1) và phát hiện trùng qua equals/hashCode của record.

  • loans: Map<Book, Borrower> — ai đang mượn gì. Không có entry = sách còn trong kho.

  • addBookcatalog.add trả false nếu đã tồn tại (contract của Set). Tận dụng để throw nếu add trùng.

  • borrow — 2 check: catalog chứa book? loans đã có entry cho book? Cả 2 pass thì put entry.

  • returnBookloans.remove trả null nếu key không có → throw. Gộp check + mutate trong 1 step atomic.

  • whoBorrowedOptional.ofNullable wrap kết quả Map.get (có thể null) thành Optional. Caller phải .orElse/.isPresent() — không sợ quên check null.

  • availableBooks — stream filter lấy sách không có trong loans, .toList() trả immutable list. Caller nhận snapshot, không mutate được catalog qua list này.

  • toString — ngắn gọn, an toàn (không dump toàn bộ state vào log).

🎓 Mở rộng

Mức 1 — Hạn mức mượn per borrower:

Thêm field Map<Borrower, Integer> loanCount và method borrow từ chối nếu borrower đã mượn ≥ 5 sách.

Mức 2 — Lịch sử mượn:

Thêm record LoanRecord(Book book, Borrower borrower, LocalDateTime borrowedAt, LocalDateTime returnedAt)List<LoanRecord> history. Mỗi lần borrow/returnBook push record mới. Method historyOf(Borrower) trả lịch sử của 1 người.

Mức 3 — Unit test với JUnit 5:

@Test
void borrowBookNotInCatalogThrows() {
    Library lib = new Library();
    Book b = new Book("1", "X", "Y");
    Borrower br = new Borrower("B1", "A");
    assertThrows(IllegalStateException.class, () -> lib.borrow(b, br));
}

@Test
void availableBooksReflectsState() {
    Library lib = new Library();
    Book b1 = new Book("1", "X", "Y");
    lib.addBook(b1);
    assertEquals(List.of(b1), lib.availableBooks());
    lib.borrow(b1, new Borrower("B1", "A"));
    assertTrue(lib.availableBooks().isEmpty());
}

Mức 4 — Expose API chỉ qua interface:

public interface LibraryApi {
    void addBook(Book book);
    void borrow(Book book, Borrower borrower);
    void returnBook(Book book);
    Optional<Borrower> whoBorrowed(Book book);
    List<Book> availableBooks();
}

public class Library implements LibraryApi { ... }

Caller dùng LibraryApi lib = new Library(); — thay implementation (memory → database) mà không đổi caller. Pattern strong encapsulation cho module lớn.

✨ Điều bạn vừa làm được

Hoàn thành mini-challenge này, bạn đã:

  • Phân biệt rõ khi dùng record (immutable value object) vs class (mutable state + behaviour). Book/Borrower là record, Library là class — đúng ngữ cảnh.
  • Áp dụng encapsulation đầy đủ: field private final giữ collection, lộ qua method có validate; availableBooks trả snapshot immutable.
  • Tận dụng contract equals/hashCode của record để dùng trực tiếp làm key Map/Set — không phải viết thủ công.
  • Dùng Optional thay return null cho whoBorrowed — API rõ nghĩa, caller buộc xử lý case vắng.
  • Validate input trong compact constructor (throw IllegalArgumentException) và trong method (throw IllegalStateException) — phân biệt đúng exception cho "input sai" vs "state sai".
  • Viết toString an toàn (không dump collection) cho logging hữu ích nhưng không rò thông tin.

Chúc mừng — bạn đã hoàn thành Module 5! Bạn đã có công cụ mô hình hoá mọi "thực thể" trong domain. Module 6 sẽ bước vào kế thừa và đa hình: extends, super, @Override, abstract class, interface, sealed class — nơi nhiều class chia sẻ behaviour chung và JVM chọn method đúng tại runtime.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên