Backend

99 Ngày Java — Ngày 30: Mini project OOP — Quản lý thư viện

SSite Admin
26 tháng 08, 2026 12 phút đọc 0 lượt xem
99 Ngày Java — Ngày 30: Mini project OOP — Quản lý thư viện

Hai mươi ngày OOP — từ class đầu tiên (Ngày 11) đến immutability hôm qua — kết thúc bằng một bài tổng hợp: xây hệ thống quản lý thư viện hoàn chỉnh bằng Java thuần. Không kiến thức mới, chỉ có câu hỏi quan trọng hơn: đứng trước một đề bài nghiệp vụ, bạn có biến được nó thành class, package và method đúng chỗ không? Ta sẽ đi đủ bốn bước: phân tích đề, tổ chức package, dựng domain model với đủ 4 trụ cột OOP, rồi ghép repository – service – app theo đúng kiến trúc phân tầng mà series Spring đang dùng hằng ngày.

Sketchnote Ngày 30: Mini project OOP quản lý thư viện — phân tích đề bài, tổ chức package model/repository/service/app, 4 trụ cột OOP và kiến trúc phân tầng

Đề bài và cách phân tích

// ĐỀ BÀI — Hệ thống quản lý thư viện (console, chưa cần DB hay framework)
//
// Nghiệp vụ:
//  1. Quản lý đầu sách: thêm sách, tìm theo ISBN/tiêu đề, xem tình trạng
//  2. Thành viên: SINH VIÊN mượn tối đa 3 cuốn / 14 ngày,
//                 GIẢNG VIÊN mượn tối đa 10 cuốn / 60 ngày
//  3. Mượn/trả: sách đang được mượn thì không ai mượn được nữa;
//               trả muộn tính phí 5.000đ/ngày
//  4. Không cho mượn khi: hết hạn mức, có sách quá hạn chưa trả
//
// Phân tích domain — gạch chân DANH TỪ và ĐỘNG TỪ trong đề:
//  Danh từ  → ứng viên class : Sách (Book), Thành viên (Member), Phiếu mượn (Loan)
//  Động từ  → ứng viên method: mượn (borrow), trả (return), tìm (find)
//  Biến thể → ứng viên đa hình: sinh viên/giảng viên KHÁC nhau ở hạn mức & thời hạn
//
// Bài này KHÔNG dạy kiến thức mới — nó là bài KIỂM TRA:
// 20 ngày OOP (Ngày 11–29) có thành phản xạ thiết kế hay chưa.
  • Kỹ thuật khởi động cổ điển mà hiệu quả: gạch chân danh từ (ứng viên class), động từ (ứng viên method) và biến thể (ứng viên đa hình) trong đề — 5 phút phân tích này định hình toàn bộ thiết kế phía sau.

  • Nhận diện biến thể là mấu chốt: sinh viên và giảng viên giống nhau về hành vi (đều mượn, trả) nhưng khác nhau về tham số (hạn mức, thời hạn) — tín hiệu kinh điển cho abstract class + override, thay vì rải if (loai == SINH_VIEN) khắp nơi.

  • Phạm vi có chủ đích: console + in-memory, chưa DB hay framework — để toàn bộ sự chú ý dồn vào thiết kế lớp; và như phần cuối sẽ thấy, thiết kế đúng thì gắn Spring + DB vào sau gần như không phải sửa domain.

Tổ chức package — chia theo vai trò

// Tổ chức package — chia theo VAI TRÒ, mỗi tầng chỉ gọi xuống tầng dưới
vn.motdev.library
├── model          // domain: Book, Member, Loan, các enum & record
│   ├── Book.java
│   ├── Member.java        (abstract — Ngày 16)
│   ├── StudentMember.java
│   ├── TeacherMember.java
│   ├── Loan.java
│   ├── Isbn.java          (record — Ngày 20)
│   └── BookStatus.java    (enum — Ngày 23)
├── repository     // lưu trữ: interface + bản in-memory
│   ├── BookRepository.java        (interface — Ngày 17)
│   └── InMemoryBookRepository.java
├── service        // nghiệp vụ: quy tắc mượn/trả nằm ở ĐÂY
│   └── LibraryService.java
└── app            // điểm vào: main() lắp ráp và chạy thử
    └── LibraryApp.java

// Vì sao chia thế?
// - model KHÔNG biết gì về repository/service → tái dùng được, test không cần gì kèm
// - service chỉ biết INTERFACE repository → mai này thay in-memory bằng DB,
//   service không đổi một dòng (chính là kiến trúc Spring ta sẽ gặp lại)
// - app là nơi DUY NHẤT biết mọi lớp cụ thể để lắp ráp (new InMemory...)
  • Bốn package, một luật: chỉ gọi xuốngappservicerepositorymodel, không bao giờ ngược lại. model không import gì của ba tầng trên nên tái dùng và test độc lập được.

  • Tầng service phụ thuộc interface BookRepository chứ không phụ thuộc bản in-memory — ngày thay bằng PostgreSQL, service không đổi một dòng. Đây chính là Dependency Inversion, và là lý do Spring tiêm bean qua interface (series Spring Ngày 5–6).

  • Người từ series Spring sẽ thấy quen: controller → service → repository bên đó chính là kiến trúc này thêm tầng HTTP — bài hôm nay chứng minh phân tầng là tư duy OOP thuần, không phải phát minh của framework.

Domain model — đóng gói và bất biến đúng chỗ

// model — đóng gói (Ngày 13) + bất biến (Ngày 29) đúng chỗ

// Value object: record bất biến, validate trong compact constructor
public record Isbn(String value) {
    private static final Pattern DANG = Pattern.compile("\\d{13}");   // Ngày 28!
    public Isbn {
        if (value == null || !DANG.matcher(value).matches())
            throw new IllegalArgumentException("ISBN phải đủ 13 chữ số: " + value);
    }
}

public enum BookStatus { AVAILABLE, BORROWED }     // Ngày 23

// Entity: CÓ vòng đời, trạng thái đổi — nhưng chỉ đổi qua method NGHIỆP VỤ
public class Book {
    private final Isbn isbn;                       // định danh: bất biến
    private final String tieuDe;
    private BookStatus status = BookStatus.AVAILABLE;

    public Book(Isbn isbn, String tieuDe) {
        this.isbn = Objects.requireNonNull(isbn);
        this.tieuDe = Objects.requireNonNull(tieuDe);
    }

    // KHÔNG có setStatus! — chuyển trạng thái là nghiệp vụ, có quy tắc:
    void choMuon() {
        if (status == BookStatus.BORROWED)
            throw new IllegalStateException("Sách đang được mượn: " + tieuDe);
        status = BookStatus.BORROWED;
    }
    void nhanTra() { status = BookStatus.AVAILABLE; }

    public boolean dangSan() { return status == BookStatus.AVAILABLE; }
    public Isbn isbn() { return isbn; }
    public String tieuDe() { return tieuDe; }

    @Override public boolean equals(Object o) {    // Ngày 24: định danh = ISBN
        return o instanceof Book b && isbn.equals(b.isbn);
    }
    @Override public int hashCode() { return isbn.hashCode(); }
}

// Phiếu mượn: record — dữ liệu chốt tại thời điểm mượn, không bao giờ sửa
public record Loan(Isbn isbn, String memberId, LocalDate ngayMuon, LocalDate hanTra) {
    public boolean quaHan(LocalDate homNay) { return homNay.isAfter(hanTra); }
    public long soNgayTre(LocalDate homNay) {      // Ngày 26: Period/ChronoUnit
        return quaHan(homNay) ? ChronoUnit.DAYS.between(hanTra, homNay) : 0;
    }
}
  • Ba "loài" trong domain, ba cách xử lý: value object (Isbn) — record bất biến validate trong constructor (Ngày 20, 29), sai là không tồn tại; entity (Book) — có vòng đời, trạng thái đổi qua method nghiệp vụ chứ không qua setter; bản ghi sự kiện (Loan) — record chốt tại thời điểm mượn.

  • Điểm đắt giá nhất: Book không có setStatus. choMuon() mang theo quy tắc "đang mượn thì không cho mượn nữa" — đóng gói thực thụ là gói quy tắc, không phải đổi public thành getter/setter (Ngày 13).

  • equals/hashCode theo định danh nghiệp vụ (ISBN — Ngày 24) để Book sống tử tế trong HashMap của repository; Isbn tái dùng luôn regex Ngày 28 — kiến thức cũ tự tìm về chỗ của nó.

Kế thừa và đa hình — đúng một chỗ cần

// Kế thừa (Ngày 14) + abstract (Ngày 16) + đa hình (Ngày 15) — đúng một chỗ cần

public abstract class Member {
    private final String id;
    private final String hoTen;
    private final List<Loan> dangMuon = new ArrayList<>();

    protected Member(String id, String hoTen) {
        this.id = Objects.requireNonNull(id);
        this.hoTen = Objects.requireNonNull(hoTen);
    }

    // Hai "điểm biến thiên" của đề bài → hai abstract method:
    public abstract int hanMuc();                  // mượn tối đa mấy cuốn?
    public abstract int soNgayMuon();              // được giữ bao nhiêu ngày?

    // Quy tắc CHUNG viết MỘT lần ở lớp cha (template — Ngày 16):
    public boolean duocMuonThem(LocalDate homNay) {
        boolean coQuaHan = dangMuon.stream().anyMatch(l -> l.quaHan(homNay));
        return !coQuaHan && dangMuon.size() < hanMuc();
    }

    void ghiNhanMuon(Loan loan) { dangMuon.add(loan); }
    void ghiNhanTra(Isbn isbn) { dangMuon.removeIf(l -> l.isbn().equals(isbn)); }

    public List<Loan> dangMuon() { return List.copyOf(dangMuon); }   // Ngày 29!
    public String id() { return id; }
    public String hoTen() { return hoTen; }
}

public class StudentMember extends Member {
    public StudentMember(String id, String hoTen) { super(id, hoTen); }
    @Override public int hanMuc() { return 3; }
    @Override public int soNgayMuon() { return 14; }
}

public class TeacherMember extends Member {
    public TeacherMember(String id, String hoTen) { super(id, hoTen); }
    @Override public int hanMuc() { return 10; }
    @Override public int soNgayMuon() { return 60; }
}

// Đa hình trả công: service viết "member.hanMuc()" — KHÔNG một chữ if/instanceof nào;
// mai thêm MemberVip chỉ là MỘT class mới, không sửa dòng code cũ nào (Open/Closed)
  • Công thức: quy tắc chung (duocMuonThem — có sách quá hạn thì cấm, còn hạn mức thì cho) viết một lần ở lớp cha; hai điểm biến thiên (hanMuc, soNgayMuon) thành abstract method cho con override — đúng mẫu template method Ngày 16.

  • Phần thưởng của đa hình nhìn thấy được: service gọi member.hanMuc()không một if/instanceof nào; thêm hạng thành viên VIP là một class mới, không sửa dòng cũ — nguyên tắc Open/Closed bằng ví dụ cụ thể.

  • Chú ý dangMuon() trả List.copyOf — bài Ngày 29 áp dụng ngay: danh sách phiếu mượn là trạng thái nội bộ, phát tham chiếu thật ra ngoài là mở cửa sau cho aliasing bug.

Repository, service và điểm lắp ráp

// repository — trừu tượng hóa (Ngày 17): service chỉ biết interface
public interface BookRepository {
    void save(Book book);
    Optional<Book> findByIsbn(Isbn isbn);          // Optional thay cho null
    List<Book> findByTieuDe(String tuKhoa);
}

public class InMemoryBookRepository implements BookRepository {
    private final Map<Isbn, Book> data = new HashMap<>();   // Ngày 24: key nhờ equals/hashCode

    @Override public void save(Book book) { data.put(book.isbn(), book); }
    @Override public Optional<Book> findByIsbn(Isbn isbn) {
        return Optional.ofNullable(data.get(isbn));
    }
    @Override public List<Book> findByTieuDe(String tuKhoa) {
        return data.values().stream()
                .filter(b -> b.tieuDe().toLowerCase().contains(tuKhoa.toLowerCase()))
                .toList();
    }
}

// service — nghiệp vụ mượn/trả gom về MỘT nơi
public class LibraryService {
    private static final BigDecimal PHI_TRE = new BigDecimal("5000");   // Ngày 27!

    private final BookRepository books;
    public LibraryService(BookRepository books) { this.books = books; }

    public Loan muon(Member member, Isbn isbn, LocalDate homNay) {
        Book book = books.findByIsbn(isbn)
                .orElseThrow(() -> new NoSuchElementException("Không có sách " + isbn.value()));
        if (!book.dangSan())
            throw new IllegalStateException("Sách đang được mượn: " + book.tieuDe());
        if (!member.duocMuonThem(homNay))
            throw new IllegalStateException("Hết hạn mức hoặc đang có sách quá hạn");

        book.choMuon();
        Loan loan = new Loan(isbn, member.id(), homNay,
                homNay.plusDays(member.soNgayMuon()));      // đa hình quyết định hạn trả
        member.ghiNhanMuon(loan);
        return loan;
    }

    public BigDecimal tra(Member member, Isbn isbn, LocalDate homNay) {
        Book book = books.findByIsbn(isbn).orElseThrow();
        Loan loan = member.dangMuon().stream()
                .filter(l -> l.isbn().equals(isbn)).findFirst()
                .orElseThrow(() -> new IllegalStateException("Thành viên không mượn cuốn này"));

        book.nhanTra();
        member.ghiNhanTra(isbn);
        return PHI_TRE.multiply(BigDecimal.valueOf(loan.soNgayTre(homNay)));
    }
}

// app — main() lắp ráp: nơi duy nhất biết lớp cụ thể
BookRepository repo = new InMemoryBookRepository();
LibraryService thuVien = new LibraryService(repo);
repo.save(new Book(new Isbn("9786041231234"), "Effective Java"));

Member sv = new StudentMember("SV001", "Nam");
Loan loan = thuVien.muon(sv, new Isbn("9786041231234"), LocalDate.now());
// → hanTra = hôm nay + 14 (sinh viên); đổi sang TeacherMember → +60, không sửa service
  • LibraryService.muon đọc như chính đề bài: tìm sách → kiểm tra sẵn sàng → kiểm tra quyền mượn → chuyển trạng thái → tạo phiếu. Mỗi quy tắc nghiệp vụ nằm đúng nhà: quy tắc về sách trong Book, về thành viên trong Member, về luồng trong service.

  • Optional thay cho null ở ranh giới repository, BigDecimal cho tiền phạt (Ngày 27), LocalDate truyền vào làm tham số thay vì gọi LocalDate.now() bên trong — chi tiết nhỏ nhưng là thứ khiến service test được với ngày giả.

  • Điểm lắp ráp duy nhất ở app: new InMemoryBookRepository() đưa vào constructor của service — chính là dependency injection thủ công; Spring chỉ tự động hóa bước new này, còn tư duy thiết kế thì hôm nay đã có đủ.

Bài tập mở rộng

  • Thêm MemberVip (hạn mức 20, 90 ngày, miễn phí trễ) — đếm xem bạn phải sửa bao nhiêu dòng code cũ; nếu nhiều hơn 0, xem lại thiết kế.

  • Viết LoanRepository + method danhSachQuaHan(LocalDate) trả về mọi phiếu quá hạn toàn thư viện — tầng nào nên chứa logic này?

  • Thay quy tắc phí trễ bằng interface ChinhSachPhi (composition thay kế thừa — Ngày 17): sinh viên giảm 50%, thử cắm chính sách mới không sửa service.

  • Viết test cho LibraryService với repository giả (in-memory là đủ): mượn quá hạn mức, mượn sách đang mượn, trả muộn 3 ngày — mỗi test một quy tắc.

  • Nâng cấp LibraryApp thành menu console vòng lặp (Ngày 7) đọc lệnh từ Scanner — thêm/tìm/mượn/trả chạy được end-to-end.

Kết luận

Bài kiểm tra 20 ngày OOP gói trong bốn phản xạ: phân tích đề bằng danh từ – động từ – biến thể; chia package theo vai trò với luật chỉ-gọi-xuống và phụ thuộc qua interface; domain model dùng đúng công cụ cho đúng loài — record cho value object, entity đổi trạng thái qua method nghiệp vụ, không setter bừa bãi; và đa hình đặt đúng một chỗ có biến thể thay vì if/else rải rác. Giai đoạn 3 khép lại ở đây. Ngày 31 mở Giai đoạn 4 — Collections & Generics: bức tranh toàn cảnh Collection/Map, chọn cấu trúc dữ liệu nào cho bài toán nào và Big-O đằng sau mỗi lựa chọn. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 30: Checkpoint — CRUD API hoàn chỉnh

Task API gom Ngày 11–29 vào một: DTO + validation, ProblemDetail, phân trang, transaction ở service, auditing — và checklist review để tự chấm mọi CRUD API.

26 thg 8, 202610 phút1
99 Ngày Spring — Ngày 29: Auditing với Spring Data JPA

BaseEntity với @CreatedDate/@LastModifiedDate, AuditorAware cho @CreatedBy, những chỗ auditing không chạy (bulk update, JDBC) — và cách test tất định bằng DateTimeProvider.

25 thg 8, 20269 phút7
99 Ngày Java — Ngày 29: Immutable & defensive copy

Công thức lớp bất biến, vì sao final chưa đủ và defensive copy hai chiều, List.copyOf vs unmodifiableList — và món quà thread-safe không cần lock.

25 thg 8, 202611 phút8