Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

coding convention và chuẩn mực java

Tung Bui Thanh (TungDaDev)10 mins read

Java coding convention.webp

Ở các dự án phần mềm lớn, một codebase sống khỏe 10 năm và một đống legacy không ai dám đụng khác nhau không phải vì vài người giỏi đột xuất. Khác ở kỷ luật và sự đồng nhất về chuẩn kỹ thuật (Engineering Standards & Conventions).

Nhiều người vẫn nghĩ "Coding Convention" chỉ là chuyện đặt tên biến, thụt lề 2 hay 4 spaces.

Nhìn từ góc của Lead Engineer, Coding Convention là cả một lớp phòng thủ: từ phong cách viết code (Style Guide), tư duy bất biến (Immutability), chuẩn hóa API (Problem Details, RFC 9457), cho tới kiểm tra ranh giới kiến trúc tự động (Architectural Fitness Functions) để chặn code vi phạm ngay trên pipeline CI/CD.


# bỏ tư duy cũ: vì sao package-by-layer (service/impl) là anti-pattern?

Một tàn dư lớn của thời J2EE năm 2005 là chia thư mục theo tầng kỹ thuật (Package-by-Layer) và thói quen tạo cặp XxxService và XxxServiceImpl theo quán tính:

# CẤU TRÚC PHÂN TẦNG CỔ ĐIỂN (ANTI-PATTERN CHO MICROSERVICES HIỆN ĐẠI)
com.company.project/
├── controller/
│   ├── UserController.java
│   └── OrderController.java
├── service/
│   ├── UserService.java
│   ├── OrderService.java
│   └── impl/
│       ├── UserServiceImpl.java   <-- Chỉ có đúng 1 implementation duy nhất!
│       └── OrderServiceImpl.java  <-- Tạo interface thừa thãi, phá vỡ YAGNI!
├── repository/
└── model/

# vì sao kiến trúc này hỏng khi hệ thống lớn lên?

  1. Vỡ tính bao đóng (Low Cohesion, High Coupling): Sửa một nghiệp vụ liên quan đến Order phải nhảy qua 5 thư mục khác nhau. Không thể dùng phạm vi truy cập package-private của Java để bảo vệ logic nội bộ.
  2. Interface rác (Anemic Abstraction): Tạo interface chỉ để có đúng một class implement, không có đa hình (polymorphism) thực sự, vi phạm nguyên lý YAGNI (You Aren't Gonna Need It). Thói quen này một phần có gốc từ thời Spring cần interface để tạo JDK dynamic proxy cho @Transactional; Spring Boot 2 trở đi mặc định dùng CGLIB proxy theo class, nên lý do đó không còn. Interface vẫn đáng có khi thật sự có nhiều implementation, hoặc khi nó là ranh giới giữa các module (như port trong Hexagonal bên dưới).

# cách làm hiện nay: package-by-feature / vertical slice / Hexagonal

Tổ chức mã nguồn theo Package-by-Feature (Vertical Slice):

com.company.order/
├── domain/             # Thuần POJO: Business Logic, Domain Exceptions
│   ├── Order.java
│   └── Money.java
├── application/        # UseCases / Application Services
│   ├── PlaceOrderUseCase.java
│   └── OrderResponseDto.java
└── infrastructure/     # Spring Controllers, JPA Repositories, Kafka Adapters
    ├── OrderRestController.java
    └── JpaOrderRepository.java

# phòng thủ lỗi: bất biến (immutability) & chặn nullpointer lúc compile

NullPointerException (NPE) vẫn là một trong những lỗi runtime hay gặp nhất trên Production. Thay vì trông chờ mọi người tự nhớ viết if (obj != null), nhiều team chuyển sang kiểm soát tĩnh:

# chuẩn hóa JSpecify annotations

Dùng chuẩn JSpecify (org.jspecify.annotations) để khai báo rõ biến nào có thể null. Cách JSpecify khuyên dùng là đánh @NullMarked cho cả package (trong package-info.java) hoặc class: mặc định mọi kiểu đều non-null, chỉ chỗ nào có thể null mới cần @Nullable. NullAway cũng chỉ kiểm tra code nằm trong vùng @NullMarked (hoặc package khai báo qua option AnnotatedPackages), không đánh dấu thì nó bỏ qua. Spring Framework 7 / Spring Boot 4 cũng đã chuyển toàn bộ API sang JSpecify theo đúng cách này:

package com.tungdadev.payment;
 
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
 
@NullMarked // Mọi kiểu trong class mặc định là non-null
public class PaymentProcessor {
 
    // Tham số bắt buộc không được null, kết quả có thể null
    public @Nullable TransactionResult process(
            PaymentRequest request,
            @Nullable DiscountCode discount) {
 
        // NullAway sẽ báo lỗi biên dịch ngay tại đây nếu bạn gọi discount.apply()
        // mà chưa kiểm tra null!
        if (discount != null) {
            request.applyDiscount(discount.percentage());
        }
        return execute(request);
    }
}

# nguyên tắc bất biến (immutability first)

  • Dùng Java Records cho DTOs, Event Messages và Value Objects.
  • Đừng trả về collection nội bộ có thể bị sửa (Mutable Leaks). Luôn bọc bằng List.copyOf() hoặc Collections.unmodifiableList():
public record UserGroup(String groupId, List<String> memberIds) {
    // Compact constructor thực hiện Defensive Copying
    public UserGroup {
        memberIds = List.copyOf(memberIds); // Tạo bản sao bất biến, chống side-effect từ bên ngoài!
    }
}

Hai điểm khác nhau cần nhớ: List.copyOf() tạo bản sao thật, list gốc bị sửa sau đó cũng không ảnh hưởng, nhưng nó ném NullPointerException nếu list là null hoặc chứa phần tử null. Collections.unmodifiableList() chỉ là lớp vỏ chỉ-đọc bọc quanh list gốc: người gọi không sửa được, nhưng list gốc đổi thì họ thấy đổi theo.


# chuẩn hóa API: RFC 9457 ProblemDetail & idempotency

Một sai lầm phổ biến là mỗi dev tự đặt một cấu trúc JSON lỗi riêng ({ "status": "FAIL", "msg": "..." }), làm khổ team Frontend và Mobile.

Từ Spring Framework 6 / Spring Boot 3 (baseline Java 17), có sẵn class ProblemDetail để trả lỗi theo chuẩn Problem Details for HTTP APIs. Chuẩn này ban đầu là RFC 7807, tới tháng 7/2023 được thay bằng RFC 9457 (giữ nguyên format, chủ yếu làm rõ thêm và thêm registry cho các type dùng chung):

@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(InsufficientFundsException.class)
    public ProblemDetail handleInsufficientFunds(InsufficientFundsException ex, HttpServletRequest req) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
        problem.setTitle("Tài Khoản Không Đủ Số Dư");
        problem.setType(URI.create("https://api.tungdadev.com/errors/insufficient-funds"));
        problem.setProperty("currentBalance", ex.getBalance());
        problem.setProperty("requiredAmount", ex.getRequired());
        problem.setProperty("traceId", MDC.get("traceId"));
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

Payload JSON trả về theo chuẩn:

{
  "type": "https://api.tungdadev.com/errors/insufficient-funds",
  "title": "Tài Khoản Không Đủ Số Dư",
  "status": 422,
  "detail": "Giao dịch 500,000 VND bị từ chối do số dư hiện tại chỉ còn 120,000 VND",
  "instance": "/api/v1/wallets/transfer",
  "currentBalance": 120000,
  "requiredAmount": 500000,
  "traceId": "c8a1b2c3-4d5e-6f7a",
  "timestamp": "2026-03-26T21:40:00Z"
}

# kiểm soát kiến trúc tự động với ArchUnit

Làm sao để 50 dev trong một dự án không ai vi phạm các nguyên tắc như:

  • Không được dùng @Autowired field injection?
  • Không được gọi trực tiếp JpaRepository từ Controller?
  • Tầng Domain không được chứa bất kỳ thư viện bên ngoài hay annotation của Spring/Hibernate?

Không thể chỉ dựa vào code review bằng mắt. ArchUnit biến các quy tắc kiến trúc thành Unit Test chạy tự động trong CI/CD pipeline.

# viết test kiến trúc

package com.tungdadev.architecture;
 
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;
 
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
 
@AnalyzeClasses(packages = "com.tungdadev", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureFitnessTest {
 
    // QUY TẮC 1: CẤM Field Injection (@Autowired) - Bắt buộc dùng Constructor Injection
    @ArchTest
    public static final ArchRule no_field_injection = noFields()
            .should().beAnnotatedWith("org.springframework.beans.factory.annotation.Autowired")
            .because("Field injection phá vỡ tính bao đóng và gây khó khăn khi viết Unit Test. Bắt buộc dùng Constructor Injection!");
 
    // QUY TẮC 2: Tầng Controller KHÔNG ĐƯỢC PHÉP truy cập trực tiếp tầng Repository
    @ArchTest
    public static final ArchRule controllers_must_not_access_repositories = noClasses()
            .that().resideInAPackage("..controller..")
            .should().dependOnClassesThat().resideInAPackage("..repository..")
            .because("Controller chỉ được phép giao tiếp qua UseCase/Service, không được truy cập trực tiếp hạ tầng lưu trữ!");
 
    // QUY TẮC 3: Tầng Domain phải thuần khiết (Hexagonal Core Isolation)
    @ArchTest
    public static final ArchRule domain_model_must_be_pure_java = classes()
            .that().resideInAPackage("..domain..")
            .should().onlyDependOnClassesThat().resideInAnyPackage(
                    "java..",
                    "org.jspecify..",
                    "..domain.."
            )
            .because("Tầng Domain là tài sản cốt lõi của doanh nghiệp, không được phụ thuộc vào Spring, Hibernate hay bên thứ ba!");
 
    // QUY TẮC 4: Cấm chu trình phụ thuộc vòng (Cyclic Dependencies) giữa các package
    @ArchTest
    public static final ArchRule no_cyclic_dependencies = slices()
            .matching("com.tungdadev.(*)..")
            .should().beFreeOfCycles()
            .because("Cyclic dependencies biến codebase thành Spaghetti Code và làm vỡ cấu trúc module!");
}

Hai lưu ý khi đem về dự án thật. Pattern package trong rule phải khớp với cấu trúc của bạn: với cấu trúc package-by-feature ở trên, controller và JPA repository cùng nằm trong infrastructure, nên rule 2 viết theo ..controller../..repository.. sẽ không bắt được gì. Và các bản ArchUnit gần đây mặc định cho rule fail nếu vế that() không khớp class nào (archRule.failOnEmptyShould=true), nên rule viết sai pattern sẽ báo lỗi ngay chứ không âm thầm pass. Đó là tính năng tốt, đừng vội tắt nó.


# tự động hóa CI/CD: Spotless & SonarQube quality gates

Để hết cãi nhau về khoảng trắng hay vị trí dấu ngoặc trên Pull Request, cứ giao cho máy:

// build.gradle.kts - Tự động định dạng code với Spotless theo chuẩn Google Java Style
// (version ở thời điểm viết bài, nên kiểm tra bản mới nhất)
plugins {
    id("com.diffplug.spotless") version "6.25.0"
}
 
spotless {
    java {
        googleJavaFormat("1.22.0").aosp() // Chuẩn Google với thụt lề 4 spaces
        removeUnusedImports()
        trimTrailingWhitespace()
        endWithNewline()
    }
}

Mỗi lần commit, Git Pre-commit Hook hoặc CI pipeline chạy ./gradlew spotlessCheck. Code sai chuẩn thì build bị từ chối ngay, không ai phải mất thời gian review thủ công.


# lời kết

Coding Convention không phải giáo điều để làm chậm dev. Nó giống một hệ số an toàn (Safety Factor) cho cả dự án.

Một team làm việc tốt thường:

  1. Không tranh luận về format: Giao việc định dạng cho Spotless & Google Java Format.
  2. Không phân tầng máy móc: Dựng kiến trúc theo Domain-Driven Design / Vertical Slice.
  3. Chặn NPE từ gốc: Dùng JSpecify kết hợp NullAway.
  4. Bảo vệ kiến trúc bằng code: Dùng ArchUnit biến nguyên tắc thiết kế thành test, vi phạm là fail build.

Tài liệu tham khảo:


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.