Bỏ qua điều hướng
EN
Tất cả bài viết

Java cơ bản #12: dự án cuối — ứng dụng quản lý chi tiêu với Maven và JUnit

Gom toàn bộ kiến thức vào một dự án thật: cấu trúc Maven và pom.xml, chia lớp model–kho dữ liệu–service–giao diện, lưu file CSV, thống kê bằng Stream, cảnh báo ngân sách, viết kiểm thử JUnit 5, đóng gói file jar chạy được — và lộ trình học tiếp sau Java cơ bản.

15 phút đọc

Ôn tập bài nàyThẻ ghi nhớ & câu hỏi ôn tập7 thẻ ghi nhớ · 6 câu trắc nghiệm
Mục lục

Mười một bài vừa qua mỗi bài một mảnh: biến, vòng lặp, lớp, interface, record, collection, ngoại lệ, file, stream. Bài cuối ghép chúng thành một ứng dụng hoàn chỉnh với cấu trúc như dự án thật ở công ty: công cụ build Maven, code chia lớp rõ ràng, dữ liệu lưu file, và kiểm thử tự động bằng JUnit.

Ứng dụng: quản lý chi tiêu cá nhân chạy trên terminal — ghi khoản chi, xem thống kê theo tháng và danh mục, cảnh báo khi vượt ngân sách.

Maven: công cụ build

Dự án thật cần thư viện bên ngoài (JUnit, thư viện JSON, driver database…), cần biên dịch hàng trăm file, chạy kiểm thử, đóng gói. Maven (và Gradle) tự động hoá tất cả: khai báo thư viện trong một file, Maven tự tải về từ kho Maven Central.

cai-maven.sh
# Windows: winget install Apache.Maven  ·  macOS: brew install maven  ·  Ubuntu: sudo apt install maven
mvn -v        # kiểm tra: Apache Maven 3.9.x, Java version: 25

IntelliJ IDEA và VS Code có sẵn Maven — tạo dự án mới chọn "Maven" là được.

Cấu trúc dự án chuẩn

cau-truc
chi-tieu/
├── pom.xml                                   cấu hình dự án, thư viện
└── src/
    ├── main/java/vn/dtcode/chitieu/
    │   ├── GiaoDich.java                     model (record)
    │   ├── KhoGiaoDich.java                  đọc/ghi file CSV
    │   ├── ChiTieuService.java               nghiệp vụ: thêm, thống kê, ngân sách
    │   └── App.java                          giao diện terminal (menu)
    └── test/java/vn/dtcode/chitieu/
        └── ChiTieuServiceTest.java           kiểm thử JUnit

Mọi dự án Maven đều theo cấu trúc này — mở dự án Java bất kỳ ở công ty, bạn biết ngay code ở đâu, kiểm thử ở đâu.

pom.xml

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
 
    <!-- Toạ độ dự án: nhóm (tên miền đảo ngược), tên, phiên bản -->
    <groupId>vn.dtcode</groupId>
    <artifactId>chi-tieu</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
 
    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
 
    <dependencies>
        <!-- Thư viện kiểm thử, chỉ dùng khi chạy test (scope test) -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.13.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.3</version>
            </plugin>
            <plugin>
                <!-- Ghi lớp chứa main vào file jar để chạy được bằng java -jar -->
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.4.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>vn.dtcode.chitieu.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
LệnhLàm gì
mvn compileBiên dịch code trong src/main
mvn testBiên dịch và chạy mọi kiểm thử trong src/test
mvn packageChạy test rồi đóng gói thành target/chi-tieu-1.0.0.jar
mvn cleanXoá thư mục target/

Thiết kế: chia lớp theo trách nhiệm

Các lớp và phụ thuộc

Đang vẽ sơ đồ…

flowchart LR
  App["App<br/>(menu, nhập/xuất terminal)"] --> Service["ChiTieuService<br/>(quy tắc nghiệp vụ)"]
  Service --> Kho["KhoGiaoDich<br/>(đọc/ghi CSV)"]
  Service --> GD["GiaoDich<br/>(record)"]
  Kho --> GD

Nguyên tắc từ bài 5 và 7: tách tính toán khỏi giao diện. ChiTieuService không biết gì về terminal — vì vậy kiểm thử được tự động, và sau này thay giao diện terminal bằng web (Spring Boot) mà không sửa nghiệp vụ.

Model

src/main/java/vn/dtcode/chitieu/GiaoDich.java
package vn.dtcode.chitieu;
 
import java.time.LocalDate;
 
/** Một khoản chi. Bất biến; kiểm tra dữ liệu ngay khi tạo nên mọi GiaoDich tồn tại đều hợp lệ. */
public record GiaoDich(LocalDate ngay, String danhMuc, long soTien, String ghiChu) {
 
    public GiaoDich {
        if (ngay == null) throw new IllegalArgumentException("Thiếu ngày");
        if (danhMuc == null || danhMuc.isBlank()) throw new IllegalArgumentException("Thiếu danh mục");
        if (soTien <= 0) throw new IllegalArgumentException("Số tiền phải dương");
        danhMuc = danhMuc.strip().toLowerCase();
        ghiChu = ghiChu == null ? "" : ghiChu.strip().replace(",", ";");   // dấu phẩy sẽ làm vỡ cột CSV
    }
 
    static GiaoDich tuCsv(String dong) {
        String[] cot = dong.split(",", -1);
        if (cot.length != 4) throw new IllegalArgumentException("Sai định dạng: " + dong);
        return new GiaoDich(LocalDate.parse(cot[0]), cot[1], Long.parseLong(cot[2]), cot[3]);
    }
 
    String sangCsv() {
        return String.join(",", ngay.toString(), danhMuc, String.valueOf(soTien), ghiChu);
    }
}

Kho dữ liệu (lưu file CSV)

src/main/java/vn/dtcode/chitieu/KhoGiaoDich.java
package vn.dtcode.chitieu;
 
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
 
/** Đọc/ghi danh sách giao dịch ra file CSV. Lớp duy nhất biết dữ liệu nằm ở file. */
public class KhoGiaoDich {
    private static final String TIEU_DE = "ngay,danh_muc,so_tien,ghi_chu";
    private final Path file;
 
    public KhoGiaoDich(Path file) {
        this.file = file;
    }
 
    public List<GiaoDich> docTatCa() {
        if (!Files.exists(file)) return new ArrayList<>();
        try {
            List<GiaoDich> ketQua = new ArrayList<>();
            List<String> dong = Files.readAllLines(file, StandardCharsets.UTF_8);
            for (int i = 1; i < dong.size(); i++) {
                if (!dong.get(i).isBlank()) ketQua.add(GiaoDich.tuCsv(dong.get(i)));
            }
            return ketQua;
        } catch (IOException e) {
            throw new UncheckedIOException("Không đọc được " + file, e);   // giữ nguyên nhân gốc
        }
    }
 
    public void luuTatCa(List<GiaoDich> ds) {
        List<String> dong = new ArrayList<>();
        dong.add(TIEU_DE);
        for (GiaoDich g : ds) dong.add(g.sangCsv());
        try {
            Path tam = file.resolveSibling(file.getFileName() + ".tmp");
            Files.write(tam, dong, StandardCharsets.UTF_8);
            // Ghi ra file tạm rồi đổi tên: mất điện giữa chừng cũng không làm hỏng file cũ
            Files.move(tam, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new UncheckedIOException("Không ghi được " + file, e);
        }
    }
}

Service: nghiệp vụ

src/main/java/vn/dtcode/chitieu/ChiTieuService.java
package vn.dtcode.chitieu;
 
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
 
/** Quy tắc nghiệp vụ. Không đọc bàn phím, không in màn hình → kiểm thử tự động được. */
public class ChiTieuService {
    private final KhoGiaoDich kho;
    private final List<GiaoDich> giaoDich;
    private final Map<String, Long> nganSach = new TreeMap<>();
 
    public ChiTieuService(KhoGiaoDich kho) {
        this.kho = kho;
        this.giaoDich = new ArrayList<>(kho.docTatCa());
    }
 
    public GiaoDich them(LocalDate ngay, String danhMuc, long soTien, String ghiChu) {
        GiaoDich g = new GiaoDich(ngay, danhMuc, soTien, ghiChu);   // record tự kiểm tra dữ liệu
        giaoDich.add(g);
        kho.luuTatCa(giaoDich);
        return g;
    }
 
    public List<GiaoDich> trongThang(YearMonth thang) {
        return giaoDich.stream()
            .filter(g -> YearMonth.from(g.ngay()).equals(thang))
            .sorted(Comparator.comparing(GiaoDich::ngay))
            .toList();
    }
 
    public long tongThang(YearMonth thang) {
        return trongThang(thang).stream().mapToLong(GiaoDich::soTien).sum();
    }
 
    /** Tổng chi theo danh mục trong tháng, danh mục xếp A→Z. */
    public Map<String, Long> theoDanhMuc(YearMonth thang) {
        return trongThang(thang).stream().collect(Collectors.groupingBy(
            GiaoDich::danhMuc, TreeMap::new, Collectors.summingLong(GiaoDich::soTien)));
    }
 
    public void datNganSach(String danhMuc, long hanMuc) {
        if (hanMuc <= 0) throw new IllegalArgumentException("Hạn mức phải dương");
        nganSach.put(danhMuc.strip().toLowerCase(), hanMuc);
    }
 
    /** Danh mục đã chi vượt ngân sách trong tháng → số tiền vượt. */
    public Map<String, Long> vuotNganSach(YearMonth thang) {
        Map<String, Long> daChi = theoDanhMuc(thang);
        Map<String, Long> vuot = new TreeMap<>();
        nganSach.forEach((dm, han) -> {
            long chi = daChi.getOrDefault(dm, 0L);
            if (chi > han) vuot.put(dm, chi - han);
        });
        return vuot;
    }
}

Giao diện terminal

src/main/java/vn/dtcode/chitieu/App.java
package vn.dtcode.chitieu;
 
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeParseException;
 
public class App {
    public static void main(String[] args) {
        var service = new ChiTieuService(new KhoGiaoDich(Path.of("chi-tieu.csv")));
        service.datNganSach("ăn uống", 3_000_000);
        service.datNganSach("giải trí", 1_000_000);
 
        while (true) {
            IO.println("""
 
                === QUẢN LÝ CHI TIÊU ===
                1. Thêm khoản chi
                2. Xem tháng này
                3. Thống kê theo danh mục
                0. Thoát""");
            String chon = IO.readln("Chọn: ");
            if (chon == null || chon.strip().equals("0")) break;
            try {
                switch (chon.strip()) {
                    case "1" -> themKhoanChi(service);
                    case "2" -> xemThang(service, YearMonth.now());
                    case "3" -> thongKe(service, YearMonth.now());
                    default -> IO.println("Lựa chọn không hợp lệ");
                }
            } catch (IllegalArgumentException | DateTimeParseException e) {
                IO.println("Lỗi: " + e.getMessage());          // nhập sai không làm sập chương trình
            }
        }
        IO.println("Tạm biệt!");
    }
 
    private static void themKhoanChi(ChiTieuService service) {
        String ngayNhap = IO.readln("Ngày (yyyy-mm-dd, bỏ trống = hôm nay): ").strip();
        LocalDate ngay = ngayNhap.isEmpty() ? LocalDate.now() : LocalDate.parse(ngayNhap);
        String danhMuc = IO.readln("Danh mục: ");
        long soTien = Long.parseLong(IO.readln("Số tiền: ").strip());
        String ghiChu = IO.readln("Ghi chú: ");
        GiaoDich g = service.them(ngay, danhMuc, soTien, ghiChu);
        IO.println("Đã thêm: %s · %s · %,d đ".formatted(g.ngay(), g.danhMuc(), g.soTien()));
    }
 
    private static void xemThang(ChiTieuService service, YearMonth thang) {
        for (GiaoDich g : service.trongThang(thang)) {
            IO.println("%s  %-12s %,12d  %s".formatted(g.ngay(), g.danhMuc(), g.soTien(), g.ghiChu()));
        }
        IO.println("Tổng tháng %s: %,d đ".formatted(thang, service.tongThang(thang)));
    }
 
    private static void thongKe(ChiTieuService service, YearMonth thang) {
        service.theoDanhMuc(thang).forEach((dm, tien) -> IO.println("%-12s %,12d".formatted(dm, tien)));
        service.vuotNganSach(thang).forEach((dm, vuot) ->
            IO.println("⚠ Vượt ngân sách '%s' %,d đ".formatted(dm, vuot)));
    }
}

Kiểm thử với JUnit 5

Mỗi lần sửa code, bạn có dám chắc mình không làm hỏng chức năng cũ? Kiểm thử tự động trả lời câu hỏi đó trong vài giây. Mỗi phương thức @Test dựng tình huống, gọi code, rồi khẳng định (assert) kết quả:

src/test/java/vn/dtcode/chitieu/ChiTieuServiceTest.java
package vn.dtcode.chitieu;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
 
class ChiTieuServiceTest {
    private static final YearMonth THANG_10 = YearMonth.of(2026, 10);
 
    @TempDir
    Path thuMucTam;                      // JUnit tạo thư mục tạm riêng cho mỗi test, tự xoá sau đó
 
    private ChiTieuService service;
 
    @BeforeEach
    void chuanBi() {                     // chạy trước MỖI test → các test độc lập với nhau
        service = new ChiTieuService(new KhoGiaoDich(thuMucTam.resolve("test.csv")));
        service.them(LocalDate.of(2026, 10, 1), "Ăn uống", 50_000, "phở");
        service.them(LocalDate.of(2026, 10, 2), "ăn uống ", 2_000_000, "liên hoan");
        service.them(LocalDate.of(2026, 10, 5), "đi lại", 30_000, "xe buýt");
        service.them(LocalDate.of(2026, 9, 30), "ăn uống", 70_000, "tháng trước");
    }
 
    @Test
    void tongThangChiTinhGiaoDichTrongThang() {
        assertEquals(2_080_000, service.tongThang(THANG_10));
    }
 
    @Test
    void gomTheoDanhMucSauKhiChuanHoaTen() {
        Map<String, Long> ketQua = service.theoDanhMuc(THANG_10);
        assertEquals(Map.of("ăn uống", 2_050_000L, "đi lại", 30_000L), ketQua);
    }
 
    @Test
    void baoVuotNganSach() {
        service.datNganSach("ăn uống", 1_000_000);
        service.datNganSach("đi lại", 500_000);
        assertEquals(Map.of("ăn uống", 1_050_000L), service.vuotNganSach(THANG_10));
    }
 
    @Test
    void tuChoiSoTienKhongDuong() {
        var loi = assertThrows(IllegalArgumentException.class,
            () -> service.them(LocalDate.of(2026, 10, 9), "sách", 0, ""));
        assertTrue(loi.getMessage().contains("dương"));
    }
 
    @Test
    void duLieuDuocLuuVaDocLaiTuFile() {
        var serviceMoi = new ChiTieuService(new KhoGiaoDich(thuMucTam.resolve("test.csv")));
        assertEquals(4, serviceMoi.trongThang(THANG_10).size() + serviceMoi.trongThang(YearMonth.of(2026, 9)).size());
    }
 
    @Test
    void ghiChuCoDauPhayKhongLamVoFile() {
        service.them(LocalDate.of(2026, 10, 10), "mua sắm", 100_000, "áo, quần");
        var serviceMoi = new ChiTieuService(new KhoGiaoDich(thuMucTam.resolve("test.csv")));
        assertEquals(2_180_000, serviceMoi.tongThang(THANG_10));
    }
}

Chạy:

chay-test.sh
mvn test
# [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
# [INFO] BUILD SUCCESS

Đặc điểm của kiểm thử tốt, thể hiện ở trên:

  • Tên test nói điều được kiểm tra — test hỏng thì đọc tên là biết chức năng nào hỏng.
  • Độc lập: @BeforeEach dựng lại dữ liệu cho từng test; @TempDir không đụng file thật.
  • Kiểm tra cả trường hợp lỗi (assertThrows) và trường hợp biên (giao dịch tháng trước, tên danh mục khác hoa thường, ghi chú có dấu phẩy).
  • Test cuối được viết khi phát hiện lỗi: ghi chú có dấu phẩy làm vỡ CSV. Viết test tái hiện lỗi trước, sửa code sau — lỗi đó không bao giờ quay lại.

Đóng gói và chạy

dong-goi.sh
mvn package                                 # chạy test rồi tạo target/chi-tieu-1.0.0.jar
java -jar target/chi-tieu-1.0.0.jar         # chạy ứng dụng
chay-thu
=== QUẢN LÝ CHI TIÊU ===
1. Thêm khoản chi
2. Xem tháng này
3. Thống kê theo danh mục
0. Thoát
Chọn: 1
Ngày (yyyy-mm-dd, bỏ trống = hôm nay):
Danh mục: Ăn uống
Số tiền: 3500000
Ghi chú: tiệc sinh nhật
Đã thêm: 2026-10-28 · ăn uống · 3,500,000 đ
Chọn: 3
ăn uống         3,500,000
⚠ Vượt ngân sách 'ăn uống' 500,000 đ

Mở rộng dự án (bài tập)

  1. Thêm chức năng xoá/sửa giao dịch (gợi ý: thêm id vào record).
  2. Thêm khoản thu, tính số dư cuối tháng (gợi ý: sealed interface với hai record KhoanThu, KhoanChi — bài 7).
  3. Lưu ngân sách vào file riêng thay vì đặt cứng trong main.
  4. Xuất báo cáo tháng ra file bao-cao-2026-10.txt.
  5. Viết thêm test cho mọi chức năng mới — mục tiêu: không sửa code nào mà không có test bảo vệ.

Học gì tiếp theo

Bạn đã có nền tảng Java vững. Lộ trình gợi ý sau loạt bài này:

Giai đoạnNội dungTài liệu
Java nâng caoĐa luồng, ExecutorService, virtual threads (Java 21+), CompletableFuturedev.java, Java Concurrency in Practice
Viết code chuyên nghiệpClean code, design pattern cơ bản, Effective JavaBloch — Effective Java
DatabaseSQL, JDBC, rồi JPA/HibernateTài liệu PostgreSQL, Baeldung
Web backendSpring Boot: REST API, Spring Data JPA, Spring Security, kiểm thửspring.io/guides
Công cụGit, Docker, CI, Gradle
Thuật toánCấu trúc dữ liệu, độ phức tạp, luyện LeetCode/Codeforces bằng Java

Gợi ý dự án tiếp theo: biến ứng dụng chi tiêu thành REST API bằng Spring Boot lưu vào PostgreSQL — ChiTieuService gần như dùng lại nguyên vẹn, đó là phần thưởng cho việc tách nghiệp vụ khỏi giao diện ngay từ đầu.

Tổng kết cả loạt

  • Nền tảng (bài 1–5): JDK/JVM, kiểu dữ liệu, điều khiển luồng, mảng, chuỗi, phương thức.
  • Hướng đối tượng (bài 6–8): lớp, đóng gói, kế thừa, đa hình, interface, sealed, record, enum, equals/hashCode.
  • Thư viện chuẩn (bài 9–11): Collections, Generics, ngoại lệ, java.nio.file, lambda, Stream, Optional.
  • Dự án (bài 12): Maven, chia lớp theo trách nhiệm, JUnit, đóng gói jar.

Nguồn tham khảo: Maven — "Maven in 5 Minutes", POM Reference (maven.apache.org); JUnit 5 User Guide (junit.org); dev.java — "Getting Started with Maven"; Bloch — Effective Java; spring.io/guides.

Đang tải câu hỏi luyện tập…

Bài này thế nào?

Bài viết có hữu ích với bạn?

Đánh giá giúp mình biết nên viết thêm về chủ đề nào.

Đang tải đánh giá…
Chọn số sao từ 1 đến 5

Bình luận

Đăng nhập để bình luận không cần nhập tên và được duyệt nhanh hơn.

0/2000
Đang tải bình luận…

Đọc tiếp

Bài số № 54

Java cơ bản #11: lambda, tham chiếu phương thức, Stream API và Optional

Lập trình kiểu hàm trong Java: lambda và functional interface (Predicate, Function, Consumer, Supplier), tham chiếu phương thức ::, Stream với filter–map–reduce, sorted, collect, groupingBy, Optional thay cho null — cùng lời khuyên khi nào vòng lặp vẫn tốt hơn stream.

14 phút đọc

Bài số № 53

Java cơ bản #10: xử lý ngoại lệ và đọc ghi file với java.nio.file

Khi chương trình gặp lỗi: đọc stack trace, try/catch/finally, checked và unchecked exception, throw và throws, tự định nghĩa ngoại lệ nghiệp vụ, try-with-resources tự đóng tài nguyên; đọc ghi file văn bản và CSV bằng Files, Path, UTF-8 — kèm các thói quen xử lý lỗi nên tránh.

15 phút đọc

Bài số № 52

Java cơ bản #9: Collections — List, Set, Map, Generics và sắp xếp với Comparator

Bộ sưu tập co giãn thay cho mảng: ArrayList, HashSet, HashMap và khi nào dùng loại nào; generics và kiểu bao (Integer), duyệt và xoá an toàn, danh sách bất biến List.of, sắp xếp bằng Comparable/Comparator, Deque làm ngăn xếp và hàng đợi, cùng độ phức tạp của từng thao tác.

16 phút đọc