Java Foundations
39/39
Bài 39 / 39~30 phútOOP cơ bản — class, object, encapsulationLộ trình · chặng 03/21Miễn phí… lượt xem

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

Thực hành xây dựng hệ thống quản lý thư viện mini kết hợp giữa Record (Book, Borrower) và Class (Library). Áp dụng tính đóng gói và equals/hashCode.

Đây là mini-challenge khép lại Module 5. Bạn sẽ mô hình hóa một thư viện nhỏ: có sách (Book bằng Record), có người mượn (Borrower bằng Record), và có thư viện (Library bằng Class quản lý state). Thử thách kết hợp đầy đủ các khái niệm: khởi tạo object, tính đóng gói, equals và hashCode, và validation.

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 và 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: catalog và loans là private, 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

Java
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);
    }
}
Bash
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:

Java
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:

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

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

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

addBook — throw nếu trùng:

Java
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:

Java
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:

Java
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:

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

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

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

toString:

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

✅ Lời giải

✅ Lời giải — xem sau khi đã thử
Java
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 null và isBlank() 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.

  • addBook — catalog.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.

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

  • whoBorrowed — Optional.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) và 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:

Java
@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:

Java
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.

Thử ngẫmLibraryApi tách khỏi Library implementation — trong dự án thật của bạn có chỗ nào đang expose trực tiếp implementation class thay vì interface, khiến đổi implementation sau này phải sửa code gọi?

✨ Đ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 và toàn bộ khóa Java Foundations! Bạn đã có công cụ mô hình hoá mọi "thực thể" trong domain. Khóa học tiếp theo sẽ bước vào kế thừa và đa hình (OOP chuyên sâu & Functional Programming): 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 tiếp theo: Tư Duy Trừu Tượng Hóa: Trụ Cột Quan Trọng Nhất Của OOP

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 3/21 · OOP: class, kế thừa, đa hình, abstraction — bài 7/16 của chặng

Thảo luận & góp ý

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