Chương 10. Clean Code, SOLID, Design Patterns và Testing
Fresher thuộc định nghĩa SOLID; người 2–4 năm phải chỉ ra được vi phạm SOLID trong chính code dự án mình và giải thích vì sao sửa như vậy. Tôi thường không hỏi "Strategy pattern là gì" mà đưa một đoạn if-else 200 dòng và hỏi "bạn sẽ refactor thế nào, và khi nào không nên refactor". Testing cũng vậy: tôi muốn biết bạn viết test để tự tin sửa code, không phải để đạt 80% coverage cho qua cổng CI.
Người phỏng vấn muốn kiểm tra
- Hiểu SOLID qua ví dụ thật, phân biệt được DIP, DI và IoC.
- Nhận ra pattern Spring đang dùng bên dưới (Proxy, Template Method, Observer, Factory) và áp dụng Strategy, Builder đúng chỗ.
- Biết khi nào pattern là over-engineering.
- Viết unit test với JUnit 5 và Mockito rõ ràng; chọn đúng loại test slice của Spring Boot.
- Có tiêu chí rõ ràng khi review code.
10.1 Clean code ở mức thực dụng
Clean code không phải là thẩm mỹ, mà là chi phí thay đổi code. Những điều tôi thấy tạo khác biệt nhất:
- Tên nói lên ý định:
calculateShippingFee()thay vìprocess(),activeUsersthay vìlist2. - Method ngắn, một mức trừu tượng; nếu phải viết comment "bước 1, bước 2" thì đó là tín hiệu tách method.
- Return sớm (guard clause) thay vì lồng
if4 tầng. - Không nuốt exception:
catch (Exception e) {}là lỗi nghiêm trọng, không phải lỗi phong cách. - Không magic number:
if (status == 3)phải thành enum hoặc hằng số có tên. - Bất biến khi có thể:
recordcho DTO,finalcho field,List.copyOfkhi trả collection ra ngoài.
Ví dụ: guard clause, magic number và tên có ý nghĩa
Hai thuật ngữ cần hiểu trước. Guard clause (mệnh đề chặn) là câu if đặt ở đầu method để loại sớm các trường hợp không hợp lệ rồi return hoặc ném exception ngay, nhờ đó phần logic chính không bị lồng trong nhiều tầng if. Magic number là một con số viết thẳng vào code mà người đọc không biết ý nghĩa, như 3 trong status == 3. Đoạn code dưới đây tính phí vận chuyển theo hai cách: bản cũ lồng if và dùng số trần, bản mới dùng guard clause, enum và hằng số có tên.
public class ShippingFeeDemo {
// Before: vague name, nested ifs, magic numbers
static int process(int status, int weightGram, boolean vip) {
int fee = 0;
if (status == 3) {
if (weightGram > 0) {
if (vip) { fee = 0; } else { fee = weightGram > 2000 ? 50000 : 30000; }
}
}
return fee;
}
enum OrderStatus { NEW, PAID, CANCELLED, READY_TO_SHIP }
static final int HEAVY_THRESHOLD_GRAM = 2_000;
static final int HEAVY_FEE = 50_000, STANDARD_FEE = 30_000;
// After: intention-revealing name, guard clauses, named constants
static int calculateShippingFee(OrderStatus status, int weightGram, boolean vip) {
if (status != OrderStatus.READY_TO_SHIP) return 0;
if (weightGram <= 0) throw new IllegalArgumentException("weight must be positive: " + weightGram);
if (vip) return 0;
return weightGram > HEAVY_THRESHOLD_GRAM ? HEAVY_FEE : STANDARD_FEE;
}
public static void main(String[] args) {
System.out.println(process(3, 2500, false)); // prints: 50000
System.out.println(calculateShippingFee(OrderStatus.READY_TO_SHIP, 2500, false)); // prints: 50000
System.out.println(process(3, -1, false)); // prints: 0 (bug hidden)
}
}Giải thích: với dữ liệu hợp lệ, hai method cho cùng kết quả 50000. Khác biệt nằm ở chi phí đọc và sửa:
- Bản cũ: người đọc phải tra xem
3nghĩa là gì, và phải giữ trong đầu ba tầngifđể biết nhánh nào trả gì. Khi nghiệp vụ thêm trạng thái mới và đánh số lại, mọi chỗ viết== 3âm thầm sai. - Bản cũ trả
0cho cân nặng âm, tức là một đơn nhập sai dữ liệu được miễn phí vận chuyển mà không ai biết. Bản mới némIllegalArgumentExceptionngay tại chỗ, lỗi dữ liệu lộ ra sớm. - Bản mới đọc từ trên xuống như một danh sách quy tắc: không phải trạng thái chờ giao thì 0, cân nặng sai thì báo lỗi, VIP thì miễn phí, còn lại tính theo ngưỡng. Thêm quy tắc mới là thêm một dòng, không phải thêm một tầng lồng.
10.2 SOLID qua ví dụ Java
Nhắc lại kiến thức
SOLID được xây trên các tính chất OOP ở N1.5: đóng gói (giấu dữ liệu sau method), kế thừa (class con dùng lại class cha) và đa hình (cùng một lời gọi, mỗi object xử lý theo cách riêng). Các nguyên tắc O, I, D dựa nhiều vào interface và composition ở N1.7: interface là "hợp đồng" chỉ nói làm gì, class implement nói làm thế nào, còn composition là giữ object khác làm field thay vì kế thừa nó.
Cả năm nguyên tắc cùng trả lời một câu hỏi: khi yêu cầu thay đổi, phải sửa bao nhiêu chỗ và có nguy cơ làm hỏng chỗ nào đang chạy ổn. Mỗi nguyên tắc dưới đây có cặp code vi phạm và code sửa, đều chạy được bằng Java thuần (không cần Spring).
S – Single Responsibility
Một class chỉ nên có một lý do để thay đổi, tức là chỉ phục vụ một "tác nhân" nghiệp vụ. Dấu hiệu vi phạm trong dự án thật: OrderService dài 2.000 dòng vừa tính giá, vừa gửi email, vừa xuất PDF, vừa gọi đối tác vận chuyển; constructor inject 12 dependency; mỗi lần sửa template email phải chạy lại toàn bộ test của đơn hàng. Cách sửa: tách PricingService, OrderNotifier, InvoiceExporter, để OrderService điều phối.
Nói bằng lời thường: nếu bạn mô tả class bằng một câu có nhiều chữ "và" ("tính giá và lưu đơn và gửi email"), class đó đang có nhiều lý do để thay đổi. Đoạn code dưới đây gộp ba việc vào một method.
import java.util.ArrayList;
import java.util.List;
public class SrpViolation {
// One class, three reasons to change: pricing rules, storage, email wording
static class OrderService {
final List<String> db = new ArrayList<>();
int place(String sku, int qty, int unitPrice, String email) {
int total = qty * unitPrice;
if (qty >= 10) total = total * 90 / 100; // pricing rule
db.add(sku + " x" + qty + " = " + total); // persistence
System.out.println("To " + email + ": your order total is " + total); // notification
return total;
}
}
public static void main(String[] args) {
new OrderService().place("SKU-1", 10, 20_000, "an@shop.vn");
// prints: To an@shop.vn: your order total is 180000
}
}Giải thích: code chạy đúng và in tổng tiền 180000 (giảm 10% cho đơn từ 10 sản phẩm). Vấn đề chỉ lộ ra khi yêu cầu thay đổi. Phòng marketing muốn đổi ngưỡng giảm giá, đội vận hành muốn đổi nội dung email, đội hạ tầng muốn lưu sang bảng khác: cả ba cùng sửa một method, dễ conflict khi merge, và mỗi lần sửa email lại có nguy cơ làm sai công thức tính tiền. Muốn test riêng quy tắc giảm giá cũng phải dựng cả phần lưu trữ lẫn gửi email. Bản sửa tách mỗi lý do thay đổi ra một class:
public class SrpFixed {
static class PricingService { // changes only when pricing rules change
int total(int qty, int unitPrice) {
int total = qty * unitPrice;
return qty >= 10 ? total * 90 / 100 : total;
}
}
static class OrderNotifier { // changes only when the message changes
void orderPlaced(String email, int total) {
System.out.println("To " + email + ": your order total is " + total);
}
}
static class OrderService { // only coordinates, owns no rule
private final PricingService pricing;
private final OrderNotifier notifier;
OrderService(PricingService pricing, OrderNotifier notifier) {
this.pricing = pricing;
this.notifier = notifier;
}
int place(String sku, int qty, int unitPrice, String email) {
int total = pricing.total(qty, unitPrice);
notifier.orderPlaced(email, total);
return total;
}
}
public static void main(String[] args) {
var service = new OrderService(new PricingService(), new OrderNotifier());
service.place("SKU-1", 10, 20_000, "an@shop.vn"); // prints: To an@shop.vn: your order total is 180000
System.out.println(new PricingService().total(5, 20_000)); // prints: 100000
}
}Giải thích: kết quả in ra y hệt bản cũ, nhưng giờ quy tắc giảm giá test được chỉ bằng một dòng new PricingService().total(5, 20_000) mà không cần email hay DB. Đổi nội dung email chỉ chạm OrderNotifier. SRP không có nghĩa là "mỗi class một method": PricingService có thể có nhiều method, miễn chúng cùng phục vụ một nhóm quy tắc về giá.
O – Open/Closed
Mở để mở rộng, đóng để sửa đổi: thêm hành vi mới bằng cách thêm class mới thay vì sửa một khối switch ở chỗ đã chạy ổn. Kỹ thuật chính là đa hình qua interface, thường hiện thực bằng Strategy (mục 10.4). Đừng áp dụng máy móc: nếu chỉ có 2 nhánh và không có dấu hiệu tăng thêm thì if-else là đủ.
Đoạn code dưới đây tính chiết khấu theo loại khách hàng bằng một khối switch. Nó chạy tốt cho tới khi có yêu cầu thêm loại khách hàng mới.
public class OcpViolation {
static int discount(String customerType, int amount) {
switch (customerType) {
case "REGULAR": return 0;
case "VIP": return amount * 10 / 100;
// new type "STUDENT" means editing this already-tested method again
default: throw new IllegalArgumentException("Unknown type: " + customerType);
}
}
public static void main(String[] args) {
System.out.println(discount("VIP", 1_000_000)); // prints: 100000
try {
discount("STUDENT", 1_000_000);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // prints: Unknown type: STUDENT
}
}
}Giải thích: khi phòng kinh doanh thêm loại "STUDENT", cách duy nhất là mở method discount ra sửa. Method này đã chạy ổn trên production; mỗi lần sửa là một lần có thể gõ nhầm làm hỏng nhánh VIP, và khối switch thường bị copy ở nhiều nơi (tính giá, hiển thị, báo cáo) nên phải nhớ sửa đủ cả. Bản dưới đây đưa mỗi loại chiết khấu vào một class implement cùng interface:
import java.util.List;
public class OcpFixed {
interface DiscountPolicy {
boolean supports(String customerType);
int discount(int amount);
}
record NoDiscount() implements DiscountPolicy {
public boolean supports(String t) { return t.equals("REGULAR"); }
public int discount(int amount) { return 0; }
}
record VipDiscount() implements DiscountPolicy {
public boolean supports(String t) { return t.equals("VIP"); }
public int discount(int amount) { return amount * 10 / 100; }
}
// New requirement: add one class, existing classes stay untouched
record StudentDiscount() implements DiscountPolicy {
public boolean supports(String t) { return t.equals("STUDENT"); }
public int discount(int amount) { return Math.min(amount * 5 / 100, 50_000); }
}
static int discount(List<DiscountPolicy> policies, String type, int amount) {
return policies.stream().filter(p -> p.supports(type)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown type: " + type))
.discount(amount);
}
public static void main(String[] args) {
List<DiscountPolicy> policies = List.of(new NoDiscount(), new VipDiscount(), new StudentDiscount());
System.out.println(discount(policies, "VIP", 1_000_000)); // prints: 100000
System.out.println(discount(policies, "STUDENT", 1_000_000)); // prints: 50000
}
}Giải thích: thêm loại khách hàng mới giờ là thêm một class StudentDiscount và đưa nó vào danh sách; NoDiscount, VipDiscount và hàm discount không bị sửa một ký tự nào, nên các test cũ vẫn giữ nguyên giá trị. Sinh viên được giảm 5% nhưng tối đa 50.000, nên với 1.000.000 kết quả là 50000. Trong Spring, danh sách policies chính là List<DiscountPolicy> được inject tự động (xem Strategy ở mục 10.4).
L – Liskov Substitution
Ở mọi chỗ dùng class cha, thay bằng class con thì chương trình vẫn đúng. Ví dụ kinh điển dưới đây cho Square kế thừa Rectangle vì "hình vuông là hình chữ nhật", và code viết cho Rectangle cho kết quả sai khi nhận vào một Square.
class Rectangle {
protected int width, height;
void setWidth(int w) { this.width = w; } // implicit contract: changes width only
void setHeight(int h) { this.height = h; } // implicit contract: changes height only
int area() { return width * height; }
}
class Square extends Rectangle {
// keeps "all sides equal" by changing both sides -> breaks the parent's contract
@Override void setWidth(int w) { this.width = w; this.height = w; }
@Override void setHeight(int h) { this.width = h; this.height = h; }
}
// Client code written for Rectangle:
// r.setWidth(5); r.setHeight(4); expects area() == 20, but Square returns 16Giải thích: với Rectangle, hai lệnh setWidth(5) rồi setHeight(4) cho hình 5 x 4, diện tích 20. Truyền một Square vào cùng đoạn code: setWidth(5) biến nó thành 5 x 5, rồi setHeight(4) biến nó thành 4 x 4, diện tích 16. Code không lỗi biên dịch, không ném exception, chỉ âm thầm sai. Nguyên nhân là Square thay đổi hành vi mà client của Rectangle dựa vào ("đặt chiều cao không làm đổi chiều rộng"). Hình vuông "là" hình chữ nhật trong toán học, nhưng không thay thế được về hành vi khi có setter. Cách sửa: cả hai implement interface Shape { int area(); } và là object bất biến (record Square(int side)), không kế thừa nhau.
Dấu hiệu vi phạm Liskov trong code thật: class con ném UnsupportedOperationException cho method của cha, class con làm yếu điều kiện đầu ra hoặc siết điều kiện đầu vào, hoặc code client phải instanceof để xử lý riêng từng class con.
Bản chạy được dưới đây đặt hai class vào cùng một đoạn code client để bạn thấy kết quả sai bằng số cụ thể.
public class LspDemo {
static class Rectangle {
protected int width, height;
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
int area() { return width * height; }
}
static class Square extends Rectangle {
@Override void setWidth(int w) { this.width = w; this.height = w; }
@Override void setHeight(int h) { this.width = h; this.height = h; }
}
// Client code written against Rectangle's contract
static int resizeAndArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
return r.area();
}
public static void main(String[] args) {
System.out.println(resizeAndArea(new Rectangle())); // prints: 20
System.out.println(resizeAndArea(new Square())); // prints: 16 (silently wrong)
}
}Giải thích: cùng một hàm resizeAndArea, truyền Rectangle cho 20, truyền Square cho 16. Hậu quả khi yêu cầu thay đổi: giả sử sau này có tính năng "tính tiền sơn theo diện tích" viết cho Rectangle, ai đó truyền Square vào và hóa đơn sai mà không có exception nào báo. Bản sửa bỏ kế thừa, dùng interface chung và object bất biến:
import java.util.List;
public class LspFixed {
sealed interface Shape permits Rect, Square {
int area();
}
record Rect(int width, int height) implements Shape {
public int area() { return width * height; }
Rect withHeight(int h) { return new Rect(width, h); } // returns a new object, no hidden side effect
}
record Square(int side) implements Shape {
public int area() { return side * side; }
}
static int totalArea(List<Shape> shapes) {
return shapes.stream().mapToInt(Shape::area).sum(); // correct for every implementation
}
public static void main(String[] args) {
System.out.println(new Rect(5, 5).withHeight(4).area()); // prints: 20
System.out.println(totalArea(List.of(new Rect(5, 4), new Square(4)))); // prints: 36
}
}Giải thích: Square không còn setter nào để phá, nên không có hợp đồng nào bị vi phạm. Mọi code dùng Shape chỉ dựa vào area(), và cả hai implementation đều trả đúng diện tích của chính nó: 20 + 16 = 36. Muốn "đổi chiều cao" thì withHeight tạo object mới, người gọi thấy rõ mình đang nhận một hình khác.
Ghi chú
Một số tài liệu viết "đừng ném NotImplementedException ở class con". Java không có class này (đó là của .NET); tương đương trong Java là UnsupportedOperationException. Chính List.of(...) ném exception này khi gọi add, và đó là một vi phạm Liskov có chủ đích mà JDK chấp nhận.
I – Interface Segregation
Client không nên phụ thuộc vào method mà nó không dùng. Ví dụ thực tế trong Spring Data: service chỉ cần đọc nhưng inject JpaRepository thì có trong tay cả deleteAll(). Có thể khai báo interface hẹp chỉ chứa đúng những method mà service đọc cần:
import java.util.List;
import java.util.Optional;
import org.springframework.data.repository.Repository;
// Repository is a marker interface with no methods (unlike JpaRepository)
public interface ProductReadRepository extends Repository<Product, Long> {
Optional<Product> findById(Long id); // same signature as CrudRepository, Spring reuses its implementation
List<Product> findByCategory(String category); // query derived from the method name
}Giải thích: Spring Data vẫn tạo proxy cài đặt interface này lúc khởi động, nhưng chỉ lộ ra hai method được khai báo. Service inject ProductReadRepository sẽ không thể gọi save() hay deleteAll(): lỗi xuất hiện ngay lúc biên dịch thay vì chờ tới code review. Đây là cách áp dụng Interface Segregation: mỗi client thấy đúng phần interface nó cần, và quyền ghi được giới hạn ở những service thực sự được phép ghi.
Dấu hiệu vi phạm: implementation có nhiều method rỗng hoặc ném exception, hoặc một thay đổi trong interface buộc phải sửa những class không liên quan.
Ví dụ Java thuần kinh điển hơn: một interface "máy văn phòng" gộp in, scan, fax, buộc máy in đơn giản phải cài cả những chức năng nó không có.
public class IspViolation {
interface Machine { // one fat interface for every device
void print(String doc);
void scan(String doc);
void fax(String doc);
}
static class BasicPrinter implements Machine {
public void print(String doc) { System.out.println("Printing " + doc); }
public void scan(String doc) { throw new UnsupportedOperationException("scan"); } // forced stub
public void fax(String doc) { throw new UnsupportedOperationException("fax"); } // forced stub
}
public static void main(String[] args) {
Machine m = new BasicPrinter();
m.print("invoice.pdf"); // prints: Printing invoice.pdf
try {
m.scan("contract.pdf"); // compiles fine, fails only at runtime
} catch (UnsupportedOperationException e) {
System.out.println("Runtime error: " + e.getMessage()); // prints: Runtime error: scan
}
}
}Giải thích: trình biên dịch cho phép gọi m.scan(...) vì kiểu Machine có method đó, nên lỗi chỉ lộ ra lúc chạy. Hậu quả khi yêu cầu thay đổi: thêm method email(String doc) vào Machine thì mọi class implement, kể cả máy in không liên quan, đều phải sửa để biên dịch lại. Bản sửa chia interface theo năng lực:
public class IspFixed {
interface Printer { void print(String doc); }
interface Scanner { void scan(String doc); }
static class BasicPrinter implements Printer {
public void print(String doc) { System.out.println("Printing " + doc); }
}
static class OfficeMachine implements Printer, Scanner { // implements only what it really supports
public void print(String doc) { System.out.println("Office printing " + doc); }
public void scan(String doc) { System.out.println("Scanning " + doc); }
}
// The client asks for exactly the capability it needs
static void printAll(Printer p, String... docs) {
for (String d : docs) p.print(d);
}
public static void main(String[] args) {
printAll(new BasicPrinter(), "invoice.pdf"); // prints: Printing invoice.pdf
Scanner s = new OfficeMachine();
s.scan("contract.pdf"); // prints: Scanning contract.pdf
// Scanner bad = new BasicPrinter(); // compile error: incompatible types
}
}Giải thích: giờ không thể có một Scanner không biết scan: dòng cuối bị comment nếu bỏ comment sẽ báo lỗi biên dịch incompatible types: BasicPrinter cannot be converted to Scanner. Lỗi chuyển từ lúc chạy sang lúc biên dịch, và thêm năng lực mới (Fax) là thêm interface mới, không đụng tới class cũ.
D – Dependency Inversion
Module cấp cao (nghiệp vụ) không phụ thuộc module cấp thấp (chi tiết hạ tầng); cả hai phụ thuộc vào abstraction, và abstraction thuộc về phía nghiệp vụ. OrderService phụ thuộc vào interface PaymentGateway do chính tầng nghiệp vụ định nghĩa, còn VnpayPaymentGateway ở tầng hạ tầng implement nó.
| Khái niệm | Là gì | Ví dụ |
|---|---|---|
| DIP | Nguyên tắc thiết kế về hướng phụ thuộc | Service phụ thuộc interface PaymentGateway |
| DI | Kỹ thuật đưa dependency từ ngoài vào | Constructor injection |
| IoC | Nguyên lý đảo quyền điều khiển cho framework | Spring container tạo và quản lý bean |
Bẫy thường gặp
"Dùng @Autowired là đã tuân thủ DIP." Nếu bạn inject thẳng VnpayPaymentGateway (class cụ thể) thì bạn có DI nhưng không có DIP. Ngoài ra nên dùng constructor injection thay vì field injection để dependency rõ ràng, field có thể final và dễ test không cần Spring.
Đoạn code dưới đây vi phạm DIP: tầng nghiệp vụ tự new class hạ tầng cụ thể.
public class DipViolation {
static class VnpayClient { // low-level infrastructure detail
String charge(long amount) { return "VNPAY-OK-" + amount; }
}
static class OrderService {
private final VnpayClient client = new VnpayClient(); // business code picks the concrete class itself
String checkout(long amount) { return client.charge(amount); }
}
public static void main(String[] args) {
System.out.println(new OrderService().checkout(150_000)); // prints: VNPAY-OK-150000
}
}Giải thích: code chạy, nhưng OrderService bị trói vào VNPAY. Khi có yêu cầu "thêm MoMo" hoặc "test luồng checkout mà không gọi cổng thanh toán thật", bạn buộc phải sửa OrderService, tức là sửa nghiệp vụ chỉ vì chi tiết hạ tầng đổi. Bản sửa đảo hướng phụ thuộc: nghiệp vụ định nghĩa interface, hạ tầng implement nó, và implementation được truyền từ ngoài vào.
public class DipFixed {
interface PaymentGateway { String charge(long amount); } // owned by the business layer
static class VnpayGateway implements PaymentGateway {
public String charge(long amount) { return "VNPAY-OK-" + amount; }
}
static class MomoGateway implements PaymentGateway {
public String charge(long amount) { return "MOMO-OK-" + amount; }
}
static class OrderService {
private final PaymentGateway gateway;
OrderService(PaymentGateway gateway) { this.gateway = gateway; } // injected from outside (DI)
String checkout(long amount) { return gateway.charge(amount); }
}
public static void main(String[] args) {
System.out.println(new OrderService(new VnpayGateway()).checkout(150_000)); // prints: VNPAY-OK-150000
System.out.println(new OrderService(new MomoGateway()).checkout(150_000)); // prints: MOMO-OK-150000
PaymentGateway fake = amount -> "FAKE-" + amount; // test double as a lambda
System.out.println(new OrderService(fake).checkout(1)); // prints: FAKE-1
}
}Giải thích: OrderService giờ chỉ biết PaymentGateway. Thêm MoMo là thêm một class; test thì truyền một lambda giả, không cần mạng, không cần Mockito. Trong Spring, dòng new OrderService(new VnpayGateway()) do container làm thay bạn qua constructor injection; đó là lý do DIP và DI hay đi cùng nhau nhưng không phải một.
10.3 Singleton: viết thế nào cho thread-safe
Nhắc lại kiến thức
volatile bảo đảm thread khác nhìn thấy ngay giá trị vừa ghi và cấm JVM sắp xếp lại lệnh quanh lần ghi đó; synchronized bảo đảm tại một thời điểm chỉ một thread chạy đoạn code được khóa. Chi tiết về happens-before ở mục 3.3 và 3.4. Singleton là một trong các pattern cơ bản đã giới thiệu ở N2.12.
Bài toán: nhiều thread cùng gọi getInstance() lần đầu, làm sao chắc chắn chỉ một object được tạo và mọi thread đều thấy object đã khởi tạo xong. Hai cách đầu dưới đây giao việc đó cho chính JVM.
import java.util.concurrent.atomic.AtomicLong;
// Option 1: enum - thread-safe, safe against reflection and serialization
enum IdGenerator {
INSTANCE; // created exactly once when the enum class is initialized
private final AtomicLong counter = new AtomicLong();
public long next() { return counter.incrementAndGet(); } // AtomicLong: no duplicate ids even with many threads
}
// Option 2: holder - lazy and thread-safe thanks to JVM class initialization
final class AppClock {
private AppClock() {}
// Holder is initialized on the first getInstance() call -> instance created then
private static final class Holder { static final AppClock INSTANCE = new AppClock(); }
public static AppClock getInstance() { return Holder.INSTANCE; }
}Giải thích: gọi IdGenerator.INSTANCE.next() từ nhiều thread sẽ nhận 1, 2, 3... không trùng lặp và luôn trên cùng một object. Với enum, JVM tự đảm bảo chỉ có một hằng INSTANCE; gọi constructor qua reflection bị chặn (IllegalArgumentException: Cannot reflectively create enum objects), còn deserialize chỉ trả lại đúng hằng đã có. Với holder, class Holder chưa được khởi tạo cho tới lần đầu getInstance() truy cập Holder.INSTANCE; JVM thực hiện khởi tạo class dưới một khóa nội bộ và chỉ đúng một lần (JLS 12.4.2), nên có được cả lazy lẫn thread-safe mà không cần synchronized ở mỗi lần gọi.
Cách thứ ba, double-checked locking, hay được yêu cầu viết tay để kiểm tra hiểu biết về Java Memory Model:
// Option 3: double-checked locking - volatile is mandatory
public final class Registry {
private static volatile Registry instance; // without volatile this is broken
private Registry() {}
public static Registry getInstance() {
Registry local = instance; // read the volatile field once into a local variable
if (local == null) { // first check: no lock, fast path for later calls
synchronized (Registry.class) {
local = instance;
if (local == null) { // second check: another thread may have created it while we waited
instance = local = new Registry();
}
}
}
return local;
}
}Giải thích: lần kiểm tra thứ nhất giúp mọi lần gọi sau khi đã có instance không phải lấy khóa. Lần kiểm tra thứ hai cần thiết vì hai thread có thể cùng thấy null ở lần một; thread vào synchronized sau phải thấy instance thread trước vừa tạo để không tạo thêm. Biến local chỉ để giảm số lần đọc field volatile. Thiếu volatile, JVM được phép sắp xếp lại lệnh để gán tham chiếu trước khi constructor chạy xong; thread khác thấy instance != null và dùng một object chưa khởi tạo đầy đủ. volatile thiết lập quan hệ happens-before giữa lần ghi và lần đọc.
Trong ứng dụng Spring, bạn gần như không tự viết Singleton: bean mặc định có scope singleton, một instance cho mỗi bean definition trong một ApplicationContext. Điều này khác Singleton của GoF (một instance cho mỗi ClassLoader). Tự viết Singleton còn làm code khó test vì không thay được bằng mock.
Bẫy thường gặp
"Bean singleton nên thread-safe." Sai. Spring không đồng bộ gì cả; bean singleton an toàn chỉ vì nó stateless. Thêm một field private List<Order> buffer vào service là tạo race condition giữa các request.
Nơi gặp Singleton trong JDK và Spring: Runtime.getRuntime() trả về đúng một object Runtime cho cả JVM; Collections.emptyList() luôn trả cùng một list rỗng bất biến; trong Spring là scope mặc định của bean (xem mục 4.2).
10.4 Các pattern hay hỏi và gắn với Spring
| Pattern | Ý tưởng | Ở đâu trong Spring/Java |
|---|---|---|
| Factory | Ẩn logic tạo object, trả về abstraction | BeanFactory, List.of, LocalDate.of |
| Builder | Tạo object nhiều tham số tùy chọn từng bước | Lombok @Builder, RestClient.builder() |
| Strategy | Họ thuật toán thay thế nhau được | Nhiều implementation của một interface được inject |
| Template Method | Khung thuật toán cố định, bước cụ thể do bên ngoài cung cấp | JdbcTemplate, TransactionTemplate |
| Proxy | Đối tượng đại diện chặn lời gọi để thêm hành vi | AOP, @Transactional, @Cacheable |
| Observer | Phát sự kiện, nhiều bên lắng nghe | ApplicationEventPublisher, @EventListener |
| Decorator | Bọc object để thêm hành vi, cùng interface | BufferedInputStream, TransactionAwareCacheDecorator |
| Adapter | Chuyển interface này sang interface khác | HandlerAdapter trong Spring MVC |
Nhắc lại kiến thức
Design pattern là "lời giải đã có tên" cho một vấn đề thiết kế lặp đi lặp lại; N2.12 đã giới thiệu định nghĩa và ví dụ ngắn của Singleton, Factory, Builder, Strategy, Observer. Proxy trong Spring (JDK dynamic proxy và CGLIB) được phân tích ở mục 4.4. Mục này đi theo cùng một khuôn cho từng pattern: vấn đề cần giải quyết, code khi chưa dùng pattern, code khi dùng pattern, và nơi bạn đã gặp nó trong JDK hoặc Spring.
Factory
Vấn đề: nhiều nơi trong code cần chọn và tạo object cụ thể theo một điều kiện (kênh gửi thông báo là email hay SMS). Nếu mỗi nơi tự viết if ... new ..., logic chọn bị copy khắp nơi, thêm kênh mới phải sửa tất cả. Factory gom việc "tạo object nào" vào một chỗ và trả về abstraction.
public class FactoryDemo {
interface Notifier { String send(String to, String msg); }
record EmailNotifier() implements Notifier {
public String send(String to, String msg) { return "EMAIL to " + to + ": " + msg; }
}
record SmsNotifier() implements Notifier {
public String send(String to, String msg) { return "SMS to " + to + ": " + msg; }
}
// Without a factory: this if-else is copied into checkout, refund, OTP services...
static String checkoutWithoutFactory(String channel) {
Notifier n;
if (channel.equals("EMAIL")) n = new EmailNotifier();
else if (channel.equals("SMS")) n = new SmsNotifier();
else throw new IllegalArgumentException(channel);
return n.send("0901", "Order confirmed");
}
// With a factory: the only place that maps a channel to a concrete class
static Notifier notifierFor(String channel) {
return switch (channel) {
case "EMAIL" -> new EmailNotifier();
case "SMS" -> new SmsNotifier();
default -> throw new IllegalArgumentException("Unknown channel: " + channel);
};
}
public static void main(String[] args) {
System.out.println(checkoutWithoutFactory("SMS")); // prints: SMS to 0901: Order confirmed
System.out.println(notifierFor("SMS").send("0901", "OTP 123456")); // prints: SMS to 0901: OTP 123456
}
}Giải thích: cả hai cách đều in ra tin nhắn SMS. Khác biệt là khi thêm kênh Zalo: không có factory, bạn phải tìm mọi chỗ có chuỗi if tương tự để sửa; có factory, bạn sửa đúng notifierFor, còn code gọi chỉ biết kiểu Notifier. Nơi gặp: JDK có List.of(...), LocalDate.of(...), NumberFormat.getInstance() (static factory method trả về object phù hợp mà người gọi không cần biết class cụ thể); Spring có BeanFactory.getBean(...) và interface FactoryBean.
Strategy thay khối if-else lớn
Tình huống: PaymentService.pay() có một khối if (method == COD) ... else if (method == VNPAY) ... else if (method == MOMO) ... dài hàng trăm dòng, mỗi lần thêm cổng thanh toán lại phải sửa chính method này. Đoạn code dưới đây tách mỗi nhánh thành một class riêng.
public interface PaymentStrategy {
PaymentMethod method(); // which payment method this strategy handles (COD, VNPAY...)
PaymentResult pay(Order order);
}
@Service
public class PaymentService {
private final Map<PaymentMethod, PaymentStrategy> strategies;
// Spring injects every bean implementing PaymentStrategy into the List
public PaymentService(List<PaymentStrategy> all) {
this.strategies = all.stream()
.collect(Collectors.toMap(PaymentStrategy::method, Function.identity())); // duplicate key -> fails at startup
}
public PaymentResult pay(Order order) {
PaymentStrategy s = strategies.get(order.paymentMethod()); // map lookup instead of an if-else chain
if (s == null) throw new IllegalArgumentException("Không hỗ trợ: " + order.paymentMethod());
return s.pay(order);
}
}Giải thích: giả sử có ba bean CodPayment, VnpayPayment, MomoPayment cùng implement PaymentStrategy. Khi khởi động, Spring truyền cả ba vào constructor dưới dạng List, constructor biến list đó thành Map từ PaymentMethod sang strategy. Khi đơn hàng có paymentMethod = VNPAY, pay() tra map lấy VnpayPayment và giao việc cho nó; phương thức không có trong map thì nhận ngay một lỗi rõ nghĩa thay vì NullPointerException. Thêm phương thức thanh toán mới chỉ cần thêm một @Component implement PaymentStrategy, không sửa PaymentService. Spring cũng có thể inject thẳng Map<String, PaymentStrategy> với key là tên bean, nhưng tôi thích key là enum để tránh phụ thuộc vào tên bean. Collectors.toMap còn tự ném lỗi khi hai strategy trùng method, phát hiện cấu hình sai ngay lúc khởi động.
Để thấy rõ pattern đã thay thế cái gì, đây là phiên bản "chưa dùng Strategy" rút gọn bằng Java thuần:
public class PaymentIfElse {
enum Method { COD, VNPAY, MOMO }
static String pay(Method m, long amount) {
if (m == Method.COD) {
return "COD: collect " + amount + " on delivery";
} else if (m == Method.VNPAY) {
return "VNPAY: redirect, amount " + amount; // imagine 80 lines of signing and HTTP calls here
} else if (m == Method.MOMO) {
return "MOMO: create QR, amount " + amount; // and another 80 lines here
}
throw new IllegalArgumentException("Unsupported: " + m);
}
public static void main(String[] args) {
System.out.println(pay(Method.VNPAY, 200_000)); // prints: VNPAY: redirect, amount 200000
}
}Giải thích: với ba nhánh ngắn thì code này dễ đọc, không có gì sai. Nó trở thành vấn đề khi mỗi nhánh dài hàng chục dòng, mỗi cổng có dependency riêng (HTTP client, khóa ký), và nhiều người cùng sửa một method. So với bản Strategy ở trên: mỗi nhánh thành một class test riêng được, thêm cổng mới không chạm vào code cổng cũ. Nơi gặp Strategy: JDK có Comparator truyền vào List.sort(...) (thuật toán so sánh là strategy), RejectedExecutionHandler của ThreadPoolExecutor; Spring có PasswordEncoder (BCrypt, Argon2 thay nhau được) và danh sách HandlerMethodArgumentResolver trong Spring MVC.
Builder
Builder hợp với object nhiều field tùy chọn, tránh constructor 10 tham số dễ truyền nhầm thứ tự. Lombok @Builder sinh code sẵn; lưu ý field có giá trị mặc định phải đánh @Builder.Default, nếu không builder sẽ bỏ qua giá trị khởi tạo. Với JPA entity, @Builder cần đi cùng @NoArgsConstructor và @AllArgsConstructor vì Hibernate cần constructor không tham số.
Vấn đề: constructor có nhiều tham số cùng kiểu (int, String) thì người gọi rất dễ truyền nhầm thứ tự mà trình biên dịch không phát hiện. Đoạn code dưới đây cho thấy lỗi đó và cách Builder khắc phục.
public class BuilderDemo {
// Without a builder: 6 positional arguments, two ints are easy to swap
record HttpRequestSpec(String url, String method, int connectTimeoutMs, int readTimeoutMs,
int retries, boolean gzip) {}
static final class RequestBuilder {
private final String url;
private String method = "GET"; // sensible defaults
private int connectTimeoutMs = 1_000, readTimeoutMs = 3_000, retries = 0;
private boolean gzip = false;
RequestBuilder(String url) { this.url = url; }
RequestBuilder method(String m) { this.method = m; return this; }
RequestBuilder readTimeoutMs(int ms) { this.readTimeoutMs = ms; return this; }
RequestBuilder retries(int r) { this.retries = r; return this; }
HttpRequestSpec build() {
if (retries < 0) throw new IllegalStateException("retries must be >= 0"); // validate once
return new HttpRequestSpec(url, method, connectTimeoutMs, readTimeoutMs, retries, gzip);
}
}
public static void main(String[] args) {
// intent: read timeout 5s, but the two ints were swapped
var bad = new HttpRequestSpec("/pay", "POST", 5_000, 1_000, 3, true);
var good = new RequestBuilder("/pay").method("POST").readTimeoutMs(5_000).retries(3).build();
System.out.println(bad.readTimeoutMs() + " vs " + good.readTimeoutMs()); // prints: 1000 vs 5000
}
}Giải thích: người viết dòng bad muốn read timeout 5 giây nhưng đặt nhầm vị trí, kết quả read timeout chỉ còn 1 giây, và lỗi chỉ lộ ra khi cổng thanh toán chậm trên production. Với builder, mỗi giá trị đi kèm tên (readTimeoutMs(5_000)), field không đặt thì lấy mặc định, và build() là chỗ duy nhất kiểm tra tính hợp lệ. Nơi gặp: JDK có StringBuilder, HttpRequest.newBuilder(), Stream.builder(); Spring có RestClient.builder(), UriComponentsBuilder, ResponseEntity.status(...).body(...).
Template Method, Proxy, Observer
JdbcTemplate lo phần cố định (lấy connection, tạo statement, xử lý exception, đóng tài nguyên), bạn chỉ cung cấp SQL và RowMapper. Đó là Template Method dạng callback.
Proxy là nền của AOP: Spring tạo proxy (CGLIB mặc định trong Spring Boot, hoặc JDK dynamic proxy khi dựa trên interface) bọc bean, chặn lời gọi để mở transaction, kiểm tra cache, kiểm tra quyền. Đây là lý do self-invocation bỏ qua @Transactional.
Observer: service publish OrderPlacedEvent, các listener gửi email, cộng điểm thưởng mà service không cần biết. Mặc định listener chạy đồng bộ trong cùng thread và cùng transaction; muốn chạy sau khi commit dùng @TransactionalEventListener, muốn bất đồng bộ thêm @Async. Event trong JVM mất khi app chết; việc quan trọng thì dùng outbox và message broker.
Dưới đây là ba ví dụ Java thuần, mỗi ví dụ đi theo khuôn: vấn đề, code chưa dùng pattern, code dùng pattern, nơi gặp trong JDK/Spring.
Template Method. Vấn đề: nhiều quy trình có chung khung (mở tài nguyên, xử lý, đóng tài nguyên) và chỉ khác một bước ở giữa. Copy khung đó cho mỗi quy trình thì sớm muộn sẽ có bản quên bước đóng tài nguyên.
import java.util.List;
public class TemplateMethodDemo {
// Without the pattern: every exporter repeats open/close; a copy may forget [close]
static String exportCsvWithoutTemplate(List<String> rows) {
StringBuilder out = new StringBuilder("[open]");
for (String r : rows) out.append(r).append(";");
return out.append("[close]").toString();
}
// With the pattern: the fixed skeleton lives in one final method
abstract static class Exporter {
final String export(List<String> rows) {
StringBuilder out = new StringBuilder("[open]"); // fixed step
out.append(header()); // optional hook
for (String r : rows) out.append(formatRow(r)); // varying step
return out.append("[close]").toString(); // fixed step, never forgotten
}
String header() { return ""; }
abstract String formatRow(String row);
}
static class CsvExporter extends Exporter {
@Override String header() { return "name;"; }
@Override String formatRow(String row) { return row + ";"; }
}
public static void main(String[] args) {
System.out.println(exportCsvWithoutTemplate(List.of("An", "Binh"))); // prints: [open]An;Binh;[close]
System.out.println(new CsvExporter().export(List.of("An", "Binh"))); // prints: [open]name;An;Binh;[close]
}
}Giải thích: export là final nên class con không thể phá khung; class con chỉ điền formatRow và tùy chọn header. Thêm JsonExporter là viết thêm một class ngắn, phần mở/đóng tự có. Nơi gặp: JDK có AbstractList (bạn chỉ cài get và size, các method còn lại như indexOf, iterator dựng sẵn trên hai method đó); Spring có JdbcTemplate (dạng callback), OncePerRequestFilter (bạn chỉ viết doFilterInternal, xem mục 11.5).
Proxy. Vấn đề: việc "chung" như đo thời gian, mở transaction, kiểm tra quyền cần chạy quanh rất nhiều method nghiệp vụ. Dán code đó vào từng method thì nghiệp vụ lẫn với hạ tầng.
import java.lang.reflect.Proxy;
public class ProxyDemo {
interface PriceService { long priceOf(String sku); }
static class RealPriceService implements PriceService {
public long priceOf(String sku) { return 120_000; }
}
// Without a proxy: cross-cutting code pasted into every business method
static class PriceServiceWithLogging implements PriceService {
public long priceOf(String sku) {
System.out.println("start priceOf");
long p = 120_000;
System.out.println("end priceOf");
return p;
}
}
public static void main(String[] args) {
PriceService target = new RealPriceService();
// With a proxy: one handler wraps every method of the interface
PriceService proxy = (PriceService) Proxy.newProxyInstance(
PriceService.class.getClassLoader(), new Class<?>[]{PriceService.class},
(p, method, methodArgs) -> {
System.out.println("before " + method.getName()); // like Spring opening a transaction
Object result = method.invoke(target, methodArgs);
System.out.println("after " + method.getName()); // like Spring committing
return result;
});
System.out.println(proxy.priceOf("SKU-1")); // prints: before priceOf | after priceOf | 120000
}
}Giải thích: người gọi cầm proxy và tưởng đó là PriceService bình thường; mọi lời gọi đi qua handler, handler in "before", gọi object thật, in "after". RealPriceService hoàn toàn sạch. Đây chính là cách @Transactional hoạt động, và cũng giải thích vì sao gọi method trong cùng class (this.priceOf) không đi qua proxy nên không có transaction. Nơi gặp: JDK có java.lang.reflect.Proxy (dùng ở trên); Spring có AOP, @Transactional, @Cacheable, và repository của Spring Data (bạn chỉ viết interface, Spring tạo proxy cài đặt).
Observer. Vấn đề: sau khi đặt đơn cần gửi email, cộng điểm, báo kho. Nếu OrderService gọi thẳng từng việc, mỗi yêu cầu "làm thêm X sau khi đặt đơn" lại phải sửa OrderService.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class ObserverDemo {
record OrderPlaced(long orderId, String email) {}
// Without the pattern: the publisher calls every follow-up action directly
static void placeOrderWithoutObserver(long id, String email) {
System.out.println("saved " + id);
System.out.println("email to " + email); // each new requirement = edit this method
System.out.println("add loyalty points " + id);
}
// With the pattern: listeners subscribe, the publisher just announces the event
static final List<Consumer<OrderPlaced>> listeners = new ArrayList<>();
static void placeOrder(long id, String email) {
System.out.println("saved " + id);
OrderPlaced event = new OrderPlaced(id, email);
listeners.forEach(l -> l.accept(event)); // publisher does not know who listens
}
public static void main(String[] args) {
listeners.add(e -> System.out.println("email to " + e.email()));
listeners.add(e -> System.out.println("add loyalty points " + e.orderId()));
placeOrder(42, "an@shop.vn"); // prints: saved 42 | email to an@shop.vn | add loyalty points 42
}
}Giải thích: hai cách in ra cùng ba dòng, nhưng ở cách thứ hai, thêm "báo kho" là thêm một listener, placeOrder không đổi. Lưu ý listener ở đây chạy tuần tự trong cùng thread, giống hệt @EventListener mặc định của Spring: listener ném exception thì người publish cũng nhận exception. Nơi gặp: JDK có PropertyChangeListener, java.util.concurrent.Flow; Spring có ApplicationEventPublisher, @EventListener, @TransactionalEventListener.
Decorator và Adapter
Decorator bọc một object cùng interface để thêm hành vi mà không sửa class gốc, ví dụ CachingExchangeRateClient bọc HttpExchangeRateClient. Ưu điểm so với kế thừa: kết hợp linh hoạt lúc runtime (cache rồi log rồi retry), không bùng nổ số class con.
Adapter dùng khi tích hợp SDK đối tác: bạn định nghĩa interface SmsSender của mình, viết TwilioSmsAdapter chuyển lời gọi sang SDK. Đổi nhà cung cấp chỉ cần viết adapter mới.
Decorator. Vấn đề: muốn thêm cache, log, retry cho một client theo nhiều tổ hợp khác nhau. Dùng kế thừa thì mỗi tổ hợp là một class con (CachingHttpClient, LoggingCachingHttpClient...), số class tăng theo cấp số nhân.
import java.util.HashMap;
import java.util.Map;
public class DecoratorDemo {
interface RateClient { double rate(String currency); }
static class HttpRateClient implements RateClient {
int calls = 0;
public double rate(String currency) { calls++; return 25_400.0; } // pretend: slow HTTP call
}
// Without the pattern: one subclass per combination of features
static class CachingHttpRateClient extends HttpRateClient {
private final Map<String, Double> cache = new HashMap<>();
@Override public double rate(String c) { return cache.computeIfAbsent(c, super::rate); }
}
// With the pattern: wrappers share the interface and can be stacked in any order
record CachingRateClient(RateClient inner, Map<String, Double> cache) implements RateClient {
public double rate(String c) { return cache.computeIfAbsent(c, inner::rate); }
}
record LoggingRateClient(RateClient inner) implements RateClient {
public double rate(String c) { System.out.println("rate(" + c + ")"); return inner.rate(c); }
}
public static void main(String[] args) {
var http = new HttpRateClient();
RateClient client = new LoggingRateClient(new CachingRateClient(http, new HashMap<>()));
client.rate("USD");
client.rate("USD");
System.out.println("HTTP calls: " + http.calls); // prints: rate(USD) | rate(USD) | HTTP calls: 1
}
}Giải thích: lớp ngoài cùng là log nên cả hai lần gọi đều được in; lớp cache ở giữa trả kết quả lần hai từ bộ nhớ nên HTTP chỉ bị gọi một lần. Đảo thứ tự (cache bọc ngoài log) thì chỉ lần gọi đầu được log, vì lần hai cache trả lời trước khi tới lớp log. Thêm retry là thêm một record RetryingRateClient, không cần class con mới cho từng tổ hợp. Nơi gặp: JDK có BufferedInputStream(new FileInputStream(...)), Collections.unmodifiableList(list); Spring có TransactionAwareCacheDecorator, ContentCachingRequestWrapper.
Adapter. Vấn đề: SDK của đối tác có tên method, định dạng dữ liệu khác với code của bạn. Gọi thẳng SDK ở khắp nơi thì kiểu dữ liệu của SDK lan vào nghiệp vụ, đổi nhà cung cấp là sửa hàng chục file.
public class AdapterDemo {
// Third-party SDK we cannot change: different names, expects E.164 phone format
static class VendorSmsSdk {
String sendMessage(String fromNumber, String toNumber, String body) {
return "SID-" + toNumber;
}
}
// Our own interface, shaped by our business needs
interface SmsSender { boolean send(String phone, String text); }
// Without the pattern: services call the SDK directly and repeat the conversion
static boolean sendOtpWithoutAdapter(VendorSmsSdk sdk, String phone) {
return sdk.sendMessage("+1555", "+84" + phone.substring(1), "OTP 1234") != null;
}
// With the pattern: the adapter translates our call into the SDK call
record VendorSmsAdapter(VendorSmsSdk sdk, String from) implements SmsSender {
public boolean send(String phone, String text) {
String e164 = "+84" + phone.substring(1); // format conversion lives here only
return sdk.sendMessage(from, e164, text) != null;
}
}
public static void main(String[] args) {
System.out.println(sendOtpWithoutAdapter(new VendorSmsSdk(), "0901234567")); // prints: true
SmsSender sender = new VendorSmsAdapter(new VendorSmsSdk(), "+1555");
System.out.println(sender.send("0901234567", "OTP 1234")); // prints: true
}
}Giải thích: kết quả như nhau, nhưng với adapter, nghiệp vụ chỉ biết SmsSender.send(phone, text). Đổi sang nhà cung cấp khác là viết adapter mới và đổi bean, code gửi OTP không đổi; trong test, thay SmsSender bằng một fake in ra console. Nơi gặp: JDK có Arrays.asList(array) (mảng thành List), InputStreamReader (luồng byte thành luồng ký tự); Spring có HandlerAdapter (DispatcherServlet gọi mọi kiểu handler qua một interface chung), MessageListenerAdapter.
10.5 Anti-pattern hay gặp
- God class/God service: một class làm mọi thứ, ai cũng sửa, conflict liên tục.
- Over-engineering: interface cho mọi class dù chỉ có một implementation và không có lý do test; Factory của Factory; áp pattern cho có.
- Service Locator: gọi
applicationContext.getBean(...)rải rác thay vì inject, che giấu dependency. - Anemic logic rải rác: cùng một quy tắc tính giảm giá được copy ở 3 service.
- Exception dùng cho luồng điều khiển và nuốt exception.
- Primitive obsession: truyền
Stringcho mọi thứ (email, phone, currency) thay vì value object. - Kế thừa sâu chỉ để tái sử dụng code; ưu tiên composition.
Mẹo từ người phỏng vấn
Khi được hỏi về pattern, hãy kể một lần bạn bỏ một pattern vì nó không đáng. Điều đó thuyết phục hơn liệt kê 23 pattern GoF.
10.6 Testing: kim tự tháp và công cụ
Nhắc lại kiến thức
Code chỉ test được dễ dàng khi dependency được truyền từ ngoài vào qua constructor (DI, mục 10.2 phần D): khi đó test có thể truyền object giả thay cho DB hay HTTP client thật. Class tự new dependency bên trong (như DipViolation ở trên) thì gần như không thể unit test.
Test pyramid: nhiều unit test (nhanh, cô lập), ít hơn integration test (có DB thật, có Spring context), rất ít end-to-end test (chậm, dễ vỡ). Mục tiêu là phản hồi nhanh khi sửa code và tin được kết quả.
Ba loại test nói bằng lời thường
- Unit test kiểm tra một mẩu logic nhỏ (thường là một class) tách khỏi mọi thứ bên ngoài: không DB, không mạng, không Spring context. Ví dụ: kiểm tra
PricingService.total(10, 20_000)trả 180000. Chạy trong vài mili giây, chạy hàng nghìn test mỗi lần build. - Integration test kiểm tra nhiều thành phần thật ghép với nhau, thường có DB thật hoặc Spring context. Ví dụ: lưu một
Orderqua repository rồi đọc lại bằng queryfindByStatustrên PostgreSQL chạy trong Docker. Chậm hơn (vài giây), bắt được lỗi mà unit test không thấy: sai tên cột, sai cú pháp SQL, sai cấu hình transaction. - End-to-end (e2e) test đi qua cả hệ thống như người dùng thật: gọi API qua gateway, service lưu DB, gửi message sang service khác, kiểm tra kết quả cuối. Ví dụ: gọi
POST /ordersrồi kiểm tra đơn xuất hiện trongGET /orders/{id}với trạng tháiPAID. Chậm nhất, dễ vỡ vì phụ thuộc nhiều thứ, nên chỉ giữ cho vài luồng quan trọng nhất.
Hình kim tự tháp nghĩa là đáy rộng (nhiều unit test), đỉnh hẹp (ít e2e). Đảo ngược kim tự tháp (chủ yếu e2e) dẫn tới bộ test chạy 40 phút và hay đỏ vì lý do không liên quan tới code vừa sửa.
Test double bằng lời thường
Test double là tên chung cho mọi object "đóng thế" dependency thật trong test, giống diễn viên đóng thế trong phim. Nói bằng lời thường:
- Stub: người đóng thế chỉ biết đọc thoại có sẵn. Hỏi "kho còn bao nhiêu SKU-1?" thì luôn trả "5". Stub không ghi nhớ ai đã hỏi gì.
- Mock: người đóng thế kiêm thư ký, ghi lại mọi lời được nói với mình để cuối cảnh kiểm tra "đã gửi email cho khách đúng một lần chưa".
- Spy: diễn viên thật được gắn micro. Object thật vẫn chạy code thật, nhưng mọi lời gọi được ghi lại, và bạn có thể ghi đè một vài method.
- Fake: một bản cài đặt đơn giản nhưng chạy được thật, ví dụ repository lưu vào
HashMapthay cho DB.
Đoạn code dưới đây tự viết stub và mock bằng Java thuần, không dùng Mockito, để bạn thấy bản chất chúng chỉ là class implement cùng interface.
import java.util.ArrayList;
import java.util.List;
public class TestDoublesDemo {
interface InventoryClient { int available(String sku); }
interface EmailSender { void send(String to, String body); }
static class OrderService {
private final InventoryClient inventory;
private final EmailSender email;
OrderService(InventoryClient inventory, EmailSender email) { this.inventory = inventory; this.email = email; }
boolean place(String sku, int qty, String customer) {
if (inventory.available(sku) < qty) return false;
email.send(customer, "Order " + sku + " x" + qty);
return true;
}
}
// Stub: canned answer, remembers nothing
static class StubInventory implements InventoryClient { public int available(String sku) { return 5; } }
// Hand-written mock: records calls so the test can verify them
static class RecordingEmail implements EmailSender {
final List<String> sent = new ArrayList<>();
public void send(String to, String body) { sent.add(to + ":" + body); }
}
public static void main(String[] args) {
var email = new RecordingEmail();
var service = new OrderService(new StubInventory(), email);
System.out.println(service.place("SKU-1", 2, "an@shop.vn")); // prints: true
System.out.println(email.sent); // prints: [an@shop.vn:Order SKU-1 x2]
System.out.println(service.place("SKU-1", 9, "an@shop.vn")); // prints: false
System.out.println(email.sent.size()); // prints: 1 (no email for the failed order)
}
}Giải thích: StubInventory luôn trả 5 nên đặt 2 sản phẩm thành công, đặt 9 thất bại. RecordingEmail ghi lại lời gọi, nhờ đó ta kiểm tra được "đơn thành công gửi đúng một email, đơn thất bại không gửi". Mockito làm đúng việc này nhưng tự sinh class lúc chạy: given(...).willReturn(...) tạo hành vi stub, verify(...) đọc lại nhật ký lời gọi như email.sent.
Spy trong Mockito bọc một object thật; mọi method chạy code thật trừ những method bạn chủ động ghi đè:
import java.util.ArrayList;
import java.util.List;
import org.mockito.Mockito;
class SpySnippet {
void demo() {
List<String> list = Mockito.spy(new ArrayList<>());
list.add("a"); // real ArrayList.add runs
Mockito.verify(list).add("a"); // and the call was recorded
Mockito.doReturn(100).when(list).size(); // override only size()
System.out.println(list.size()); // prints: 100
System.out.println(list.get(0)); // prints: a (real data still there)
}
}Giải thích: list.add("a") chạy thật nên phần tử "a" có trong list, get(0) trả "a"; chỉ size() bị ghi đè thành 100. Với spy phải dùng doReturn(...).when(spy).method() chứ không dùng when(spy.method()), vì cú pháp thứ hai gọi method thật ngay lúc stub. Spy hữu ích khi làm việc với code cũ khó tách; nếu cần spy thường xuyên, đó là dấu hiệu class nên được tách nhỏ.
| Loại test double | Là gì | Dùng khi |
|---|---|---|
| Stub | Trả giá trị định sẵn, không kiểm tra tương tác | Cần dữ liệu đầu vào cho code đang test |
| Mock | Ghi nhận lời gọi để verify | Cần kiểm tra side effect (đã gửi email chưa) |
| Spy | Bọc object thật, chỉ ghi đè một phần | Legacy code khó tách, dùng hạn chế |
| Fake | Implementation đơn giản chạy được | Repository in-memory cho test nhanh |
Trong Mockito, cùng một object @Mock có thể đóng vai stub (given(...).willReturn(...)) hoặc mock (verify(...)); phân biệt là ở cách bạn dùng nó.
Ví dụ dưới đây kiểm tra một quy tắc nghiệp vụ của OrderService: khi kho báo hết hàng thì phải ném OutOfStockException và tuyệt đối không được lưu đơn.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock InventoryClient inventoryClient; // fake, no real HTTP call
@Mock OrderRepository orderRepository; // fake, no database needed
@InjectMocks OrderService orderService; // real object, gets the 2 mocks above injected
@Test
void placeOrder_shouldFail_whenOutOfStock() {
// given: use the mock as a stub, stock = 0
given(inventoryClient.available("SKU-1")).willReturn(0);
// when: call the behavior under test, capture the exception
var ex = assertThrows(OutOfStockException.class,
() -> orderService.place(new PlaceOrderCmd("SKU-1", 2)));
// then: check the result and side effects
assertThat(ex.getMessage()).contains("SKU-1");
verify(orderRepository, never()).save(any()); // real mock usage: verify save was NOT called
}
}Giải thích: MockitoExtension tạo hai mock, rồi @InjectMocks tạo một OrderService thật và truyền hai mock đó vào constructor. Khi test chạy, orderService.place hỏi tồn kho và nhận 0 (vì đã stub), nên phải ném OutOfStockException; assertThrows bắt exception đó và trả về để kiểm tra message. Dòng verify(..., never()) bắt được lỗi logic kiểu "lưu đơn trước rồi mới kiểm tra kho". Nếu code thật không ném exception, hoặc có gọi save, test fail và chỉ rõ lý do. Test không khởi động Spring nên chạy trong vài chục mili giây.
Tên test mô tả hành vi, thân test chia given-when-then, mỗi test kiểm tra một hành vi. MockitoExtension mặc định strict stubs: stub không dùng đến sẽ làm test fail, giúp dọn test thừa.
Một test JUnit 5 + Mockito đầy đủ
Class cần test dưới đây áp dụng mã giảm giá: tìm coupon, kiểm tra còn lượt, trừ một lượt và trả về số tiền sau giảm. Đây là code Java thuần, dependency CouponRepository được truyền qua constructor.
import java.math.BigDecimal;
import java.util.Optional;
public class CouponService {
public interface CouponRepository {
Optional<Coupon> findByCode(String code);
void save(Coupon coupon);
}
public record Coupon(String code, BigDecimal percent, int remaining) {
Coupon useOnce() { return new Coupon(code, percent, remaining - 1); }
}
private final CouponRepository repo;
public CouponService(CouponRepository repo) { this.repo = repo; }
// Returns the discounted amount and consumes one use of the coupon
public BigDecimal apply(String code, BigDecimal amount) {
Coupon c = repo.findByCode(code)
.orElseThrow(() -> new IllegalArgumentException("Coupon not found: " + code));
if (c.remaining() <= 0) throw new IllegalStateException("Coupon exhausted: " + code);
repo.save(c.useOnce());
BigDecimal discount = amount.multiply(c.percent()).divide(BigDecimal.valueOf(100));
return amount.subtract(discount);
}
}Test đầy đủ, có import, chia rõ given-when-then:
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CouponServiceTest {
@Mock CouponService.CouponRepository repo; // Mockito creates a fake implementation
@InjectMocks CouponService service; // real object built with the mock above
@Test
void apply_shouldDiscountAndConsumeOneUse_whenCouponValid() {
// given: coupon SALE10 gives 10% and has 3 uses left
given(repo.findByCode("SALE10"))
.willReturn(Optional.of(new CouponService.Coupon("SALE10", BigDecimal.TEN, 3)));
// when
BigDecimal result = service.apply("SALE10", new BigDecimal("200000"));
// then: check the returned value and the saved state
assertThat(result).isEqualByComparingTo("180000");
ArgumentCaptor<CouponService.Coupon> saved = ArgumentCaptor.forClass(CouponService.Coupon.class);
verify(repo).save(saved.capture());
assertThat(saved.getValue().remaining()).isEqualTo(2);
}
}Giải thích: khi chạy, MockitoExtension tạo repo giả và @InjectMocks gọi constructor CouponService(repo). Phần given dạy mock trả coupon 10% còn 3 lượt. Phần when gọi đúng một hành vi. Phần then kiểm tra hai điều người dùng quan tâm: số tiền phải trả là 180000 (dùng isEqualByComparingTo vì BigDecimal("180000") và BigDecimal("180000.0") khác nhau với equals), và coupon được lưu lại với 2 lượt. ArgumentCaptor "bắt" object được truyền vào save để kiểm tra nội dung của nó. Nếu ai đó sửa code quên gọi save, verify fail với thông báo Wanted but not invoked: repo.save(<Capturing argument>). Trường hợp coupon hết lượt được viết theo đúng khuôn của OrderServiceTest ở trên: assertThrows và verify(repo, never()).save(any()).
Ví dụ test tồi và cách sửa
Test dưới đây chạy xanh, tăng coverage, nhưng gần như không bảo vệ được gì.
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class CouponServiceBadTest {
static CouponService.CouponRepository repo = mock(CouponService.CouponRepository.class); // shared by all tests
CouponService service = new CouponService(repo);
@Test
void test1() { // name says nothing
when(repo.findByCode(any()))
.thenReturn(Optional.of(new CouponService.Coupon("X", BigDecimal.TEN, 3)));
BigDecimal r = service.apply("X", new BigDecimal("200000"));
System.out.println(r); // a human has to read the console
verify(repo, times(1)).findByCode(any()); // checks how, not what
assertTrue(r != null); // passes even if the discount is wrong
}
}Giải thích: các lỗi cụ thể:
- Tên
test1không nói hành vi nào đang được kiểm tra; khi test đỏ trên CI, người đọc phải mở code mới biết chuyện gì hỏng. repolàstatic, dùng chung giữa mọi test trong class. Stub và lịch sử lời gọi của test này rò sang test khác; thêm test thứ hai gọifindByCodethìtimes(1)có thể fail tùy thứ tự chạy.assertTrue(r != null)đúng cả khi code tính sai thành 200000 hay 0.System.out.printlnkhông phải assertion.verify(... findByCode ...)kiểm tra cách code làm (đã gọi find), không kiểm tra kết quả; refactor sang cache là test đỏ dù hành vi vẫn đúng. Ngược lại, điều quan trọng thật (coupon được trừ lượt) thì không kiểm tra.
Bản sửa chính là CouponServiceTest ở trên: mock tạo mới cho mỗi test qua @Mock, tên test mô tả hành vi, assert giá trị cụ thể 180000 và trạng thái được lưu.
Test slice của Spring Boot
| Annotation | Nạp gì | Dùng để test |
|---|---|---|
@WebMvcTest | Controller, filter, MockMvc, không có service thật | Mapping, validation, JSON, status code |
@DataJpaTest | JPA, repository, transaction rollback sau mỗi test | Query, mapping entity |
@SpringBootTest | Toàn bộ context | Luồng tích hợp nhiều tầng |
Ví dụ dưới đây test một query của repository trên PostgreSQL thật chạy trong Docker, thay vì trên H2.
@DataJpaTest // JPA layer only; each test runs in a transaction rolled back afterwards
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // do not replace with an embedded DB
@Testcontainers
class OrderRepositoryTest {
@Container // static: one container for the whole class, stopped afterwards
@ServiceConnection // wires the container's URL, user, password into the DataSource
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired OrderRepository repo;
@Test
void findByStatus_returnsOnlyPendingOrders() {
repo.save(Order.pending("u1"));
repo.save(Order.paid("u2"));
assertThat(repo.findByStatus(OrderStatus.PENDING)).hasSize(1); // only u1's order
}
}Giải thích: khi chạy, Testcontainers kéo image postgres:16-alpine và khởi động một container; @ServiceConnection nối DataSource của test vào container đó, và Replace.NONE đảm bảo Spring không tự thay DataSource bằng H2. Test lưu hai đơn (một PENDING, một PAID) rồi kiểm tra query chỉ trả về một đơn. Nhờ @DataJpaTest, dữ liệu này bị rollback sau test nên các test không làm bẩn dữ liệu của nhau. Testcontainers chạy PostgreSQL, Kafka, Redis thật trong Docker, tránh chuyện test pass trên H2 nhưng lỗi trên PostgreSQL vì khác cú pháp, khác kiểu dữ liệu. @ServiceConnection (Spring Boot 3.1+) tự cấu hình URL, user, password từ container.
Ghi chú
Từ Spring Boot 3.4, @MockBean và @SpyBean bị deprecated, thay bằng @MockitoBean và @MockitoSpyBean của Spring Framework 6.2. Nhiều tài liệu cũ vẫn dùng annotation cũ.
Coverage và TDD
Coverage cho biết dòng nào chưa được chạy, không cho biết code đúng. Một test gọi method mà không assert gì vẫn tăng coverage. Tôi xem branch coverage ở phần nghiệp vụ quan trọng, không ép 90% cho DTO và config. Muốn đo chất lượng test thật thì mutation testing (PIT) cố tình sửa code và xem test có bắt được không.
TDD là vòng lặp red-green-refactor: viết test fail, viết code tối thiểu để pass, rồi refactor khi đã có lưới an toàn. Tôi không yêu cầu ứng viên làm TDD mọi lúc; tôi muốn nghe bạn biết nó hữu ích nhất với logic nghiệp vụ nhiều nhánh và khi sửa bug (viết test tái hiện bug trước).
10.7 Code review: nhìn gì
Thứ tự ưu tiên của tôi khi review một pull request:
- Đúng nghiệp vụ: đọc ticket, code có làm đúng yêu cầu và xử lý trường hợp biên (null, rỗng, trùng, đồng thời) không.
- An toàn dữ liệu và bảo mật: SQL injection, thiếu kiểm tra quyền, log lộ dữ liệu nhạy cảm, transaction đặt sai chỗ.
- Hiệu năng hiển nhiên: N+1 query, gọi API trong vòng lặp, thiếu index cho query mới, tải cả bảng vào bộ nhớ.
- Xử lý lỗi: nuốt exception, thiếu timeout, retry không idempotent.
- Test: có test cho logic mới và cho bug vừa sửa không.
- Dễ đọc và nhất quán: tên, cấu trúc, trùng lặp. Phong cách thuần túy để formatter và linter lo.
Review là về code, không về người. Comment nên nói lý do và phân loại rõ đâu là bắt buộc sửa, đâu là gợi ý.
10.8 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Hiểu sâu): Hãy cho một ví dụ vi phạm Liskov và cách sửa.
Square kế thừa Rectangle: client đặt width 5, height 4 và kỳ vọng diện tích 20, nhưng Square trả 16. Hai class không thay thế nhau được về hành vi. Sửa bằng cách bỏ quan hệ kế thừa, cho cả hai implement Shape và làm bất biến. Trong dự án, tôi từng thấy ReadOnlyAccount extends Account rồi override withdraw() ném exception; code gọi account.withdraw() nổ lúc runtime.
Câu hỏi đào sâu
Làm sao phát hiện vi phạm Liskov sớm? Viết bộ test hợp đồng (contract test) cho interface và chạy nó với mọi implementation; implementation nào fail là vi phạm.
Câu hỏi (Hiểu sâu): DI khác DIP như thế nào?
DIP là nguyên tắc thiết kế: nghiệp vụ phụ thuộc abstraction, chi tiết hạ tầng implement abstraction đó. DI là kỹ thuật cung cấp dependency từ bên ngoài, Spring là một DI container. Có thể có DI mà không có DIP (inject class cụ thể), và có DIP mà không dùng framework (tự truyền implementation qua constructor).
Bẫy thường gặp
Trả lời "giống nhau, đều là tiêm phụ thuộc". Người phỏng vấn hỏi câu này chính là để lọc câu trả lời đó.
Câu hỏi (Cơ bản): Viết Singleton thread-safe. Trong Spring có nên tự viết không?
Tôi chọn enum hoặc holder idiom; nếu phải dùng double-checked locking thì field bắt buộc volatile. Trong Spring tôi không tự viết vì bean mặc định đã là singleton trong context, và Singleton tự viết khó mock, khó test. Chỉ dùng cho tiện ích thuần không phụ thuộc Spring.
Câu hỏi đào sâu
Vì sao thiếu volatile thì double-checked locking hỏng? Vì instance = new Registry() không nguyên tử; lệnh gán tham chiếu có thể được sắp xếp trước khi constructor chạy xong, thread khác thấy object chưa khởi tạo hoàn chỉnh.
Câu hỏi (Thực chiến): Bạn refactor một hàm if-else 200 dòng tính phí vận chuyển theo đơn vị vận chuyển thế nào?
Trước hết viết test bao phủ hành vi hiện tại để có lưới an toàn. Sau đó tách mỗi nhánh thành một class implement ShippingFeeStrategy, đăng ký làm bean, và ShippingService chọn strategy qua Map theo mã đơn vị. Thêm đơn vị mới chỉ cần thêm class. Nếu các nhánh chủ yếu khác nhau ở tham số (đơn giá, phụ phí) thì tôi đưa tham số vào bảng cấu hình thay vì tạo class.
Bẫy thường gặp
Refactor mà không có test, hoặc tạo 15 class cho 15 nhánh chỉ khác nhau một con số.
Câu hỏi (Cơ bản): Spring dùng những design pattern nào?
Proxy cho AOP, @Transactional, @Cacheable; Factory qua BeanFactory và FactoryBean; Singleton ở scope mặc định của bean; Template Method trong JdbcTemplate, RestTemplate, TransactionTemplate; Observer qua ApplicationEvent; Adapter qua HandlerAdapter; Front Controller qua DispatcherServlet; Decorator trong TransactionAwareCacheDecorator.
Câu hỏi đào sâu
JDK dynamic proxy khác CGLIB thế nào? JDK proxy chỉ tạo được proxy theo interface; CGLIB tạo class con nên proxy được class không có interface, nhưng không chặn được method final hoặc private. Spring Boot mặc định dùng CGLIB.
Câu hỏi (Cơ bản): Mock, stub và spy khác nhau thế nào?
Stub cung cấp dữ liệu định sẵn cho code đang test; mock dùng để kiểm tra tương tác đã xảy ra (verify); spy bọc object thật và chỉ giả lập một phần. Tôi ưu tiên kiểm tra trạng thái và kết quả trả về, chỉ verify khi side effect là điều quan trọng (gửi email, publish event), vì verify quá nhiều làm test gắn chặt vào cách cài đặt.
Bẫy thường gặp
Mock cả object mình đang test, hoặc mock những thứ đơn giản như DTO, List. Test như vậy chỉ kiểm tra lại Mockito.
Câu hỏi (Hiểu sâu): Khi nào dùng @WebMvcTest, @DataJpaTest, @SpringBootTest?
@WebMvcTest để test controller: mapping, validation, JSON, status code, với service được mock. @DataJpaTest để test query và mapping entity, nên chạy với Testcontainers thay vì H2. @SpringBootTest cho vài luồng tích hợp quan trọng. Slice test khởi động nhanh hơn nhiều so với nạp toàn bộ context.
Câu hỏi đào sâu
Vì sao bộ test tích hợp càng ngày càng chậm? Thường vì mỗi class test dùng cấu hình khác nhau (@MockitoBean khác nhau, @DirtiesContext), phá cơ chế cache context của Spring và buộc khởi động lại context nhiều lần.
Câu hỏi (Hiểu sâu): Đặt @Transactional trên class test có vấn đề gì?
Test tự rollback, tiện dọn dữ liệu, nhưng che giấu lỗi: code chạy trong một transaction duy nhất nên LazyInitializationException không xuất hiện, @TransactionalEventListener(AFTER_COMMIT) không bao giờ chạy vì không có commit. Với @SpringBootTest(webEnvironment = RANDOM_PORT), request chạy ở thread server khác nên rollback của test không áp dụng lên dữ liệu request đó ghi.
Mẹo từ người phỏng vấn
Nói được hạn chế này cho thấy bạn thực sự từng debug test tích hợp, không chỉ copy annotation.
Câu hỏi (Thực chiến): Dự án của bạn yêu cầu coverage 80%, bạn nghĩ sao?
Coverage là chỉ số tối thiểu hữu ích để phát hiện chỗ chưa test, nhưng là mục tiêu tồi nếu đuổi theo con số: người ta sẽ viết test không assert. Tôi ưu tiên test kỹ logic nghiệp vụ và các bug đã từng xảy ra, xem branch coverage ở những phần đó, và bỏ qua DTO, config.
Bẫy thường gặp
Tự hào "dự án em coverage 95%" nhưng không trả lời được test đó bắt được bug nào.
Câu hỏi (Thực chiến): Khi review code của đồng nghiệp, bạn chú ý điều gì?
Đúng nghiệp vụ và trường hợp biên trước, sau đó bảo mật và an toàn dữ liệu, hiệu năng hiển nhiên như N+1 hay thiếu index, xử lý lỗi, test, cuối cùng mới là tính dễ đọc. Formatting giao cho công cụ. Tôi ghi rõ comment nào bắt buộc sửa, comment nào chỉ là gợi ý, và luôn giải thích lý do.
Câu hỏi đào sâu
PR 2.000 dòng thì review thế nào? Đề nghị tách nhỏ; nếu không thể thì review theo luồng nghiệp vụ, tập trung vào phần thay đổi hành vi, và yêu cầu tác giả giải thích trực tiếp.
Câu hỏi (Cơ bản): Single Responsibility nói bằng lời thường là gì? Dấu hiệu nhận biết vi phạm?
Một class chỉ nên có một lý do để thay đổi, tức là chỉ phục vụ một nhóm người yêu cầu thay đổi. Cách kiểm tra nhanh: mô tả class bằng một câu, nếu câu có nhiều chữ "và" (tính giá và lưu đơn và gửi email) thì class đang gánh nhiều trách nhiệm. Dấu hiệu trong dự án: class hàng nghìn dòng, constructor nhận hơn 7–8 dependency, nhiều người cùng sửa một file và hay conflict, muốn test một quy tắc nhỏ phải dựng cả DB và email. Cách sửa là tách theo lý do thay đổi như SrpFixed ở mục 10.2, để class gốc chỉ điều phối.
Câu hỏi (Cơ bản): Vì sao nên dùng guard clause và bỏ magic number?
Vì cả hai giảm chi phí đọc và giảm lỗi âm thầm khi yêu cầu thay đổi. Guard clause loại trường hợp không hợp lệ ngay đầu method, phần logic chính nằm ở một mức thụt lề, đọc từ trên xuống như danh sách quy tắc. Magic number như status == 3 buộc người đọc đi tra nghĩa, và khi trạng thái được đánh số lại thì mọi chỗ so sánh số trần sai mà trình biên dịch không báo; dùng enum OrderStatus.READY_TO_SHIP thì đổi tên hay xóa trạng thái sẽ lộ lỗi ngay lúc biên dịch.
Câu hỏi (Đọc code): Đoạn code dưới đây vi phạm nguyên tắc SOLID nào? Hậu quả là gì?
import java.util.ArrayList;
import java.util.List;
class ReportService {
private final List<String> files = new ArrayList<>();
String monthlyRevenue(List<Integer> amounts) {
int total = amounts.stream().mapToInt(Integer::intValue).sum();
String html = "<h1>Revenue</h1><p>" + total + "</p>"; // formatting
files.add("/reports/revenue.html"); // storage
System.out.println("Mail to boss@shop.vn: " + html); // delivery
return html;
}
}Đáp án:
Vi phạm Single Responsibility: một method vừa tính doanh thu, vừa định dạng HTML, vừa lưu file, vừa gửi mail. Có ít nhất bốn lý do để thay đổi: công thức doanh thu, giao diện báo cáo (đổi sang PDF), nơi lưu (đổi sang S3), kênh gửi (đổi sang Slack). Hậu quả: đổi giao diện báo cáo có thể làm sai con số; muốn test công thức doanh thu phải chấp nhận side effect ghi file và in mail. Cách sửa: RevenueCalculator trả con số, ReportRenderer tạo HTML/PDF, ReportStorage và ReportSender lo lưu và gửi, ReportService chỉ gọi lần lượt.
Câu hỏi (Đọc code): Đoạn code dưới đây có vấn đề thiết kế gì?
import java.util.List;
class Bird {
void fly() { System.out.println("flying"); }
}
class Penguin extends Bird {
@Override void fly() { throw new UnsupportedOperationException("penguins cannot fly"); }
}
class Migration {
static void migrate(List<Bird> flock) {
for (Bird b : flock) b.fly(); // explodes when the list contains a Penguin
}
}Đáp án:
Vi phạm Liskov Substitution: Penguin không thay thế được Bird ở chỗ code gọi fly(). Hàm migrate viết đúng theo hợp đồng của Bird, nhưng một Penguin trong danh sách làm nó ném UnsupportedOperationException lúc chạy. Gốc rễ là mô hình kế thừa sai: "biết bay" không phải thuộc tính của mọi loài chim. Sửa bằng cách tách năng lực ra interface (đồng thời là Interface Segregation): interface Bird { }, interface Flyable { void fly(); }, Sparrow implements Bird, Flyable, Penguin implements Bird, và migrate(List<Flyable> flock). Khi đó đưa Penguin vào migrate bị lỗi biên dịch thay vì lỗi runtime.
Câu hỏi (Đọc code): Vì sao đoạn code dưới đây khó unit test? Sửa thế nào?
import java.time.LocalDate;
class BirthdayVoucherService {
private final SmtpMailer mailer = new SmtpMailer("smtp.shop.vn", 587); // hard-wired dependency
boolean sendIfBirthday(String email, LocalDate birthday) {
LocalDate today = LocalDate.now(); // hidden dependency on the clock
if (today.getMonthValue() == birthday.getMonthValue() && today.getDayOfMonth() == birthday.getDayOfMonth()) {
mailer.send(email, "Happy birthday! Here is 50k voucher");
return true;
}
return false;
}
}
class SmtpMailer {
SmtpMailer(String host, int port) { }
void send(String to, String body) { /* real network call */ }
}Đáp án:
Class vi phạm Dependency Inversion hai lần: tự new một mailer thật và tự đọc đồng hồ hệ thống. Hệ quả khi test: mỗi lần chạy test sẽ cố kết nối SMTP thật; và kết quả phụ thuộc vào ngày chạy, test "hôm nay là sinh nhật" chỉ xanh đúng một ngày trong năm. Cách sửa: định nghĩa interface Mailer { void send(String to, String body); }, nhận Mailer và java.time.Clock qua constructor, dùng LocalDate.now(clock). Test truyền Clock.fixed(Instant.parse("2026-09-24T00:00:00Z"), ZoneOffset.UTC) và một mailer ghi lại lời gọi, kiểm tra được cả hai nhánh bất kể ngày chạy.
Câu hỏi (Hiểu sâu): Interface Segregation khác Single Responsibility thế nào?
SRP nhìn từ phía class cài đặt (class này có mấy lý do để đổi), ISP nhìn từ phía client dùng (client này bị buộc phụ thuộc vào bao nhiêu method không dùng). Một class có thể tuân thủ SRP mà vẫn lộ ra một interface quá rộng cho từng client: ví dụ ProductRepository chỉ lo dữ liệu sản phẩm (đúng SRP), nhưng service chỉ đọc lại nhận cả deleteAll() (vi phạm ISP). Ngược lại, tách interface theo client (ProductReadRepository) không bắt buộc phải tách class cài đặt: một class vẫn implement nhiều interface hẹp. Hai nguyên tắc bổ sung cho nhau, cùng mục tiêu giảm phạm vi ảnh hưởng khi thay đổi.
Câu hỏi (Hiểu sâu): Vì sao người ta nói "ưu tiên composition hơn kế thừa"?
Vì kế thừa gắn chặt class con vào chi tiết cài đặt của class cha, còn composition chỉ phụ thuộc vào hợp đồng công khai của object được giữ. Như đã nói ở N1.7, kế thừa là quan hệ "là một" được cố định lúc biên dịch: class cha đổi cách cài đặt (ví dụ addAll gọi lại add) là class con có thể sai, như bài đếm phần tử HashSet kinh điển. Kế thừa còn dễ dẫn tới vi phạm Liskov (Square/Rectangle) và bùng nổ class con khi kết hợp tính năng (xem Decorator ở 10.4). Composition là "có một": giữ object khác làm field, gọi qua interface, thay được lúc runtime và thay được bằng fake khi test. Kế thừa vẫn hợp lý khi quan hệ "là một" đúng cả về hành vi, và class cha được thiết kế để kế thừa (Template Method, class abstract có hook rõ ràng).
Câu hỏi đào sâu
Vậy Spring dùng kế thừa ở đâu mà vẫn ổn? OncePerRequestFilter, AbstractRoutingDataSource: class cha là abstract, khung thuật toán final, chỉ mở đúng các method dành cho class con. Đó là kế thừa có thiết kế, khác với kế thừa chỉ để tái sử dụng vài dòng code.
Câu hỏi (Hiểu sâu): Vì sao constructor injection làm code dễ test hơn field injection?
Vì với constructor injection, test có thể tạo object bằng new và truyền dependency giả vào, không cần Spring hay reflection. Field injection (@Autowired private PaymentGateway gateway;) để field ở trạng thái null khi bạn new class trong test, nên buộc phải khởi động Spring context hoặc dựa vào @InjectMocks dùng reflection. Constructor injection còn cho phép field là final (không bị gán lại), làm lộ rõ số dependency (constructor 10 tham số là tín hiệu vi phạm SRP), và phát hiện vòng phụ thuộc ngay lúc khởi động. Đây là điểm DIP (phụ thuộc interface), DI (truyền từ ngoài vào) và testing gặp nhau: new OrderService(fakeGateway) như ở DipFixed.
Câu hỏi (Cơ bản): Factory khác gọi new trực tiếp ở điểm nào? Kể một ví dụ trong JDK.
Factory gom quyết định "tạo class cụ thể nào" vào một chỗ và trả về abstraction, nên người gọi không bị trói vào class cụ thể. Với new EmailNotifier(), người gọi biết và phụ thuộc đúng class đó; thêm kênh mới phải sửa mọi nơi có logic chọn. Với notifierFor("SMS"), chỉ factory biết class cụ thể. Factory còn có thể trả object có sẵn thay vì tạo mới, đặt tên có ý nghĩa và kiểm tra tham số. Ví dụ JDK: List.of(1, 2) trả về một implementation nội bộ bất biến mà bạn không cần biết tên; Integer.valueOf(100) trả object từ cache thay vì tạo mới; LocalDate.of(2026, 9, 24) kiểm tra ngày hợp lệ.
Câu hỏi (Cơ bản): Khi nào dùng Builder, khi nào constructor hoặc record là đủ?
Dùng Builder khi object có nhiều tham số, nhiều tham số tùy chọn hoặc cùng kiểu dễ nhầm thứ tự; từ 3–4 tham số bắt buộc trở xuống thì constructor hoặc record là đủ. Builder cho phép đặt tên từng giá trị, có giá trị mặc định, và kiểm tra ràng buộc một lần trong build(). Cái giá là thêm code (hoặc thêm Lombok) và object có thể được tạo thiếu field bắt buộc nếu builder không kiểm tra. Với record Money(BigDecimal amount, String currency) thì builder là thừa.
Câu hỏi (Hiểu sâu): Decorator và Proxy có cấu trúc giống nhau, vậy khác nhau ở đâu?
Cả hai đều bọc một object cùng interface; khác nhau ở mục đích và ai quyết định việc bọc. Decorator thêm tính năng cho object (cache, nén, log) và thường do chính code của bạn lắp ghép tường minh, có thể chồng nhiều lớp theo thứ tự tùy chọn: new LoggingRateClient(new CachingRateClient(http, ...)). Proxy kiểm soát truy cập tới object (transaction, bảo mật, lazy loading, gọi từ xa), thường được framework tạo ra và người gọi không biết mình đang cầm proxy: bean @Transactional mà bạn inject thực chất là proxy CGLIB. Ranh giới trong thực tế khá mờ; khi phỏng vấn, nói được mục đích khác nhau là đủ.
Câu hỏi (Hiểu sâu): Strategy khác Template Method thế nào? Khi nào chọn cái nào?
Strategy thay cả thuật toán bằng composition (truyền object vào), Template Method cố định khung và cho class con thay một vài bước bằng kế thừa. Strategy linh hoạt hơn: đổi thuật toán lúc runtime, kết hợp tự do, test từng strategy riêng. Template Method hợp khi các biến thể dùng chung phần lớn quy trình và bạn muốn bắt buộc khung đó (luôn mở và đóng tài nguyên). Xu hướng hiện đại là Template Method dạng callback: JdbcTemplate.query(sql, rowMapper) giữ khung cố định nhưng nhận bước thay đổi dưới dạng object/lambda, tức là kết hợp ưu điểm của cả hai mà không cần kế thừa.
Câu hỏi (Đọc code): Đoạn code dưới đây in ra gì?
import java.util.function.UnaryOperator;
public class DecoratorOrder {
interface Text { String render(); }
public static void main(String[] args) {
Text base = () -> "hello";
UnaryOperator<Text> upper = t -> () -> t.render().toUpperCase();
UnaryOperator<Text> brackets = t -> () -> "[" + t.render() + "]";
UnaryOperator<Text> shorten = t -> () -> t.render().substring(0, 3);
System.out.println(shorten.apply(brackets.apply(upper.apply(base))).render());
System.out.println(brackets.apply(shorten.apply(upper.apply(base))).render());
}
}Đáp án:
Dòng 1 in [HE, dòng 2 in [HEL]: cùng ba decorator, chỉ đổi thứ tự bọc mà kết quả khác nhau. Lần từ trong ra ngoài. Dòng 1: upper biến "hello" thành "HELLO", brackets bọc thành "[HELLO]", shorten ở ngoài cùng cắt 3 ký tự đầu nên còn "[HE" (dấu ngoặc cũng bị tính là một ký tự). Dòng 2: upper cho "HELLO", shorten cắt còn "HEL", brackets ở ngoài cùng bọc thành "[HEL]". Bài học: decorator ngoài cùng nhận kết quả của mọi lớp bên trong và xử lý sau cùng, nên thứ tự lắp ghép là một phần của hành vi. Ví dụ thực tế ở mục 10.4: cache bọc ngoài log thì lần gọi thứ hai không được log; retry bọc ngoài circuit breaker khác hẳn circuit breaker bọc ngoài retry.
Câu hỏi (Hiểu sâu): Vì sao gọi method @Transactional từ một method khác trong cùng class thì không có transaction? Liên hệ với Proxy pattern.
Vì transaction được thêm bởi proxy bọc bên ngoài bean, còn lời gọi nội bộ this.method() đi thẳng vào object thật, không qua proxy. Giống ProxyDemo ở mục 10.4: chỉ lời gọi qua biến proxy mới in "before/after"; nếu bên trong RealPriceService có method A gọi this.priceOf(), handler không hề biết. Spring inject cho bạn proxy (CGLIB, xem mục 4.4), controller gọi orderService.placeOrder() đi qua proxy nên có transaction; nhưng placeOrder() gọi this.saveAudit() được đánh @Transactional(REQUIRES_NEW) thì annotation bị bỏ qua. Cách sửa: tách saveAudit sang bean khác, hoặc dùng TransactionTemplate tường minh.
Câu hỏi đào sâu
Tự inject chính mình (@Lazy @Autowired OrderService self) có giải quyết được không? Có, vì self là proxy; nhưng đó là dấu hiệu class đang làm quá nhiều việc. Tôi ưu tiên tách bean vì vừa sửa được lỗi, vừa đúng SRP.
Câu hỏi (Thực chiến): Công ty muốn đổi nhà cung cấp SMS trong 2 tuần, và có thể đổi tiếp năm sau. Bạn thiết kế thế nào?
Định nghĩa interface SmsSender của riêng mình, mỗi nhà cung cấp là một Adapter, chọn adapter bằng cấu hình. Nghiệp vụ (gửi OTP, thông báo đơn) chỉ gọi smsSender.send(phone, text); toàn bộ khác biệt về SDK, định dạng số điện thoại, mã lỗi nằm trong VendorASmsAdapter, VendorBSmsAdapter. Dùng @ConditionalOnProperty(name = "sms.provider", havingValue = "vendor-b") hoặc một factory đọc cấu hình để chọn bean, nhờ đó chuyển đổi và quay lại chỉ cần đổi config. Trong giai đoạn chuyển, có thể viết thêm một decorator FallbackSmsSender gửi qua nhà cung cấp mới, lỗi thì thử nhà cung cấp cũ. Test: contract test chung cho mọi adapter, và fake SmsSender cho test nghiệp vụ.
Câu hỏi (Thực chiến): Listener gửi email xác nhận đơn, nhưng có khách nhận email trong khi đơn bị rollback. Vì sao và sửa thế nào?
Vì @EventListener mặc định chạy đồng bộ ngay khi publish, tức là trước khi transaction commit; nếu sau đó có lỗi và rollback, email đã đi mất. Đây là Observer chạy trong cùng thread và cùng transaction như đã nói ở mục 10.4. Sửa bằng @TransactionalEventListener(phase = AFTER_COMMIT) để listener chỉ chạy khi commit thành công, thường kèm @Async để việc gửi email chậm không kéo dài request. Nếu email là bắt buộc (không được mất khi app chết ngay sau commit), dùng outbox: ghi bản ghi "cần gửi email" vào bảng trong cùng transaction với đơn, một job hoặc CDC đọc bảng đó và gửi.
Câu hỏi (Đọc code): Builder dưới đây cho ra status bằng bao nhiêu? Vì sao?
import lombok.Builder;
@Builder
class CreateOrderRequest {
private String sku;
private int quantity;
private String status = "NEW"; // expected default
}
// CreateOrderRequest req = CreateOrderRequest.builder().sku("SKU-1").quantity(2).build();Đáp án:
status bằng null, không phải "NEW". Lombok @Builder tạo constructor nhận đủ mọi field và builder truyền giá trị của chính builder vào; field status trong builder chưa được gán nên là null, giá trị khởi tạo = "NEW" trên field bị bỏ qua. Sửa bằng @Builder.Default private String status = "NEW";. Lỗi này hay lộ ra ở production dưới dạng NullPointerException hoặc vi phạm ràng buộc NOT NULL khi insert DB. Tốt hơn nữa là dùng enum OrderStatus thay cho chuỗi.
Câu hỏi (Cơ bản): Unit test, integration test và end-to-end test khác nhau thế nào? Cho ví dụ.
Khác nhau ở phạm vi thành phần thật tham gia: unit test một class với dependency giả, integration test vài thành phần thật ghép lại (DB, Spring context), e2e test cả hệ thống qua API như người dùng. Ví dụ với tính năng coupon: unit test CouponService.apply với repository mock, kiểm tra 200000 giảm 10% còn 180000; integration test CouponRepository trên PostgreSQL bằng Testcontainers, kiểm tra ràng buộc unique của mã coupon; e2e test gọi POST /orders có mã coupon và kiểm tra tổng tiền trong response. Tỷ lệ theo kim tự tháp: nhiều unit, vừa phải integration, rất ít e2e.
Câu hỏi (Hiểu sâu): Fake khác mock thế nào? Khi nào dùng fake?
Fake là một cài đặt đơn giản nhưng chạy thật (repository lưu vào HashMap), còn mock là object được lập trình trả lời theo kịch bản và ghi lại lời gọi. Fake có hành vi nhất quán: save rồi findById trả lại đúng object, không cần viết given cho từng lời gọi, nên test ngắn và ít gắn vào chi tiết cài đặt. Dùng fake khi dependency có trạng thái và được gọi nhiều lần trong một luồng (repository, cache, hàng đợi in-memory). Dùng mock khi cần kiểm tra side effect cụ thể (đã gửi email đúng một lần) hoặc cần giả lập lỗi (willThrow(new SocketTimeoutException())). Rủi ro của fake: nếu nó lệch hành vi so với bản thật (ví dụ không kiểm tra unique), test xanh nhưng production lỗi; vì vậy vẫn cần integration test cho repository thật.
Câu hỏi (Đọc code): Test dưới đây sai ở đâu?
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class PriceCalculatorTest {
@Test
void shouldApplyVat() {
PriceCalculator calc = mock(PriceCalculator.class); // mocks the class under test
when(calc.withVat(new BigDecimal("100"))).thenReturn(new BigDecimal("110"));
assertEquals(new BigDecimal("110"), calc.withVat(new BigDecimal("100")));
}
}Đáp án:
Test mock chính class đang cần test, nên nó chỉ kiểm tra rằng Mockito trả về đúng giá trị vừa được dạy; code thật của withVat không hề chạy. Dù withVat tính sai VAT thành 108 hay ném exception, test vẫn xanh. Sửa: tạo object thật new PriceCalculator() (chỉ mock dependency của nó nếu có), gọi withVat(new BigDecimal("100")) và so sánh với kết quả kỳ vọng tính tay, ví dụ isEqualByComparingTo("110") nếu VAT 10%. Lưu ý thêm: assertEquals với BigDecimal so sánh cả scale, 110 và 110.00 bị coi là khác nhau.
Câu hỏi (Thực chiến): Một test thỉnh thoảng đỏ trên CI vào lúc gần nửa đêm, chạy lại thì xanh. Bạn điều tra và sửa thế nào?
Test flaky theo thời gian gần như luôn do code đọc thẳng đồng hồ hệ thống (LocalDate.now(), Instant.now()) hoặc do múi giờ khác nhau giữa máy dev và CI. Ví dụ code tính "đơn đặt hôm nay" với LocalDate.now(), còn test tạo dữ liệu bằng Instant.now() theo UTC: lúc 23:30 giờ Việt Nam là 16:30 UTC cùng ngày, nhưng lúc 00:30 giờ Việt Nam lại là 17:30 UTC ngày hôm trước, hai bên lệch ngày. Sửa: inject java.time.Clock vào service và dùng LocalDate.now(clock); test truyền Clock.fixed(...) với một thời điểm cố định, kể cả các mốc biên như 23:59:59 và 00:00:00. Trong Spring, khai báo bean Clock.systemDefaultZone() cho production. Các nguồn flaky khác cần loại trừ: thứ tự chạy test với dữ liệu dùng chung, Thread.sleep chờ việc bất đồng bộ (thay bằng Awaitility), cổng mạng cố định.
Câu hỏi (Hiểu sâu): Vì sao không nên verify mọi lời gọi trong unit test?
Vì verify kiểm tra cách code làm việc, không phải kết quả; verify quá nhiều làm test vỡ mỗi khi refactor dù hành vi không đổi. Ví dụ verify findByCode được gọi đúng một lần: thêm cache vào service là test đỏ, dù người dùng không thấy khác biệt gì. Nguyên tắc: ưu tiên kiểm tra giá trị trả về và trạng thái (state-based testing); chỉ verify những tương tác là hành vi cần đảm bảo, tức là side effect ra ngoài hệ thống: đã lưu, đã gửi email, đã publish event, không gọi cổng thanh toán khi hết hàng.
Câu hỏi (Thực chiến): Test repository chạy xanh trên H2 nhưng lên staging (PostgreSQL) thì lỗi. Vì sao, và bạn đổi cách test thế nào?
H2 chỉ giả lập PostgreSQL ở mức cú pháp cơ bản; khác biệt về kiểu dữ liệu, hàm, native query và hành vi khóa làm test trên H2 không chứng minh được gì cho PostgreSQL. Ví dụ hay gặp: native query dùng ON CONFLICT, jsonb, ILIKE hoặc hàm ngày giờ riêng của PostgreSQL; kiểu timestamptz; so sánh chuỗi phân biệt hoa thường khác nhau; migration Flyway viết cho PostgreSQL không chạy trên H2. Cách sửa: dùng Testcontainers chạy đúng image PostgreSQL cùng phiên bản với production, @AutoConfigureTestDatabase(replace = NONE) và @ServiceConnection như ví dụ ở mục 10.6, để Flyway chạy migration thật trước test. Chi phí vài giây khởi động container đổi lấy niềm tin rằng test xanh nghĩa là production chạy được.
Câu hỏi đào sâu
Làm sao để nhiều class test không khởi động nhiều container? Khai báo container static trong một class cha hoặc interface dùng chung, và giữ cấu hình Spring giống nhau để context được cache; Testcontainers còn có chế độ reuse cho máy dev.
Câu hỏi (Cơ bản): Given-when-then là gì? Vì sao mỗi test chỉ nên kiểm tra một hành vi?
Given-when-then là cách chia thân test thành ba phần: chuẩn bị dữ liệu và dependency (given), gọi đúng một hành vi (when), kiểm tra kết quả (then). Chia như vậy giúp người đọc thấy ngay test đang nói điều gì, giống một câu nghiệp vụ: "cho coupon còn 3 lượt, khi áp dụng cho đơn 200000, thì trả 180000 và còn 2 lượt". Mỗi test một hành vi để khi test đỏ, tên test cho biết ngay quy tắc nào hỏng; một test kiểm tra năm hành vi sẽ dừng ở assertion đầu tiên fail và che mất các lỗi sau. "Một hành vi" không có nghĩa là "một assert": kiểm tra số tiền và số lượt còn lại trong cùng một test vẫn là một hành vi.
10.9 Tóm tắt trọng tâm
- Clean code được đo bằng chi phí thay đổi: tên rõ ý, guard clause và enum thay số trần giúp người sau sửa nhanh và ít gây lỗi âm thầm.
- SRP nghĩa là một class chỉ có một lý do để thay đổi, vì gộp nhiều trách nhiệm khiến mọi thay đổi nhỏ đều có nguy cơ làm hỏng phần không liên quan.
- OCP nghĩa là thêm hành vi bằng class mới thay vì sửa khối
switchđã chạy ổn, vì code cũ đã được test không nên bị mở ra mỗi lần có yêu cầu mới. - LSP đòi class con giữ đúng hợp đồng hành vi của class cha, vì vi phạm không báo lỗi biên dịch mà cho kết quả sai âm thầm như Square/Rectangle.
- ISP chia interface theo nhu cầu của client, nhờ đó lỗi "gọi chức năng không có" bị chặn lúc biên dịch thay vì nổ lúc chạy.
- DIP bắt nghiệp vụ phụ thuộc interface do chính nó định nghĩa; DI chỉ là cách truyền dependency vào, có DI chưa chắc đã có DIP.
- Singleton thread-safe dùng enum hoặc holder; double-checked locking bắt buộc
volatilevì phép gán tham chiếu có thể bị sắp xếp lại trước khi constructor chạy xong. - Mỗi pattern giải quyết một vấn đề cụ thể: Factory gom việc chọn class, Builder chống nhầm tham số, Strategy thay if-else lớn, Template Method cố định khung, Proxy và Decorator bọc thêm hành vi, Observer tách người phát khỏi người nghe, Adapter cách ly SDK bên ngoài.
- Spring dùng Proxy cho
@Transactional, vì vậy lời gọi nội bộthis.method()không có transaction. - Pattern chỉ đáng dùng khi có dấu hiệu thay đổi thật; áp pattern cho có là over-engineering và làm code khó đọc hơn.
- Unit test nhanh và cô lập, integration test bắt lỗi ghép nối với DB và Spring, e2e test chỉ nên giữ cho vài luồng chính vì chậm và dễ vỡ.
- Stub cung cấp dữ liệu, mock ghi lại lời gọi để verify, spy bọc object thật, fake là cài đặt đơn giản chạy được; ưu tiên kiểm tra kết quả hơn verify tương tác.
- Test tốt có tên mô tả hành vi, chia given-when-then, assert giá trị cụ thể và không chia sẻ trạng thái giữa các test.
- Coverage chỉ cho biết dòng nào chưa chạy; test repository nên chạy trên DB thật bằng Testcontainers thay vì H2.