Java — Từ Zero đến Senior/OOP cơ bản — class, object, encapsulation/Mini-challenge: Model hoá thư viện với record + class
7/7
~30 phútOOP cơ bản — class, object, encapsulation

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.