Có những thiết kế trông hợp lý khi mới viết, thậm chí được cả đội khen là "gọn", nhưng sau một năm lại trở thành nguồn gốc của mọi lần sửa lỗi chậm chạp. Thuật ngữ anti-pattern mô tả đúng loại thiết kế đó: một cách làm phổ biến, bề ngoài có vẻ phù hợp, nhưng tác hại lớn hơn lợi ích. Andrew Koenig đặt ra từ này năm 1995, lấy cảm hứng từ cuốn Design Patterns, và cuốn AntiPatterns (1998) đã phổ biến nó. Bài này chọn ba anti-pattern mà hầu như dự án Java nghiệp vụ nào cũng từng gặp: God object, anemic domain model và service locator. Với mỗi cái, ta tái hiện triệu chứng bằng code chạy thật trên JDK 21, đo hoặc chứng minh tác hại, rồi nêu cách sửa và những trường hợp nó không hẳn là anti-pattern.
Bài này dựa trên mạch thiết kế mà ta đã đi qua: Nguyên lý SOLID trong Java qua ví dụ thực tế cho nguyên tắc, Dependency Injection: tự viết IoC container để hiểu Spring cho cách nối các phần, và DDD trong Java: Entity, Value Object và Aggregate cho mô hình miền. Ba anti-pattern dưới đây chính là những cách vi phạm các bài đó phổ biến nhất.
Anti-pattern là gì và vì sao cần gọi tên chúng
Theo mô tả trên Wikipedia về anti-pattern, một anti-pattern có hai đặc điểm: nó trông phù hợp và hiệu quả nhưng nhược điểm lấn át ưu điểm, và tồn tại một giải pháp khác đã được ghi nhận, lặp lại được và hiệu quả hơn. Đặc điểm thứ hai quan trọng: gọi tên một anti-pattern chỉ có ích khi ta cũng biết thay nó bằng gì. Nếu không, đó chỉ là lời chê.
Cũng cần thận trọng: một anti-pattern không phải điều cấm kỵ tuyệt đối. Phần lớn anti-pattern đều có ngữ cảnh mà chúng chấp nhận được (một script chạy một lần, một prototype). Bài dưới đây luôn chỉ ra ranh giới đó, vì nếu không, "chống anti-pattern" dễ biến thành tín điều.
Anti-pattern God object: một lớp biết và làm mọi thứ
God object (còn gọi là God class, Blob) là một lớp ôm quá nhiều trách nhiệm: tính giá, trừ kho, ghi database, gửi email, dựng hoá đơn, cập nhật báo cáo. Trên refactoring.guru, mùi code Large Class được mô tả là lớp có quá nhiều trường, phương thức hoặc dòng code, và lý do thường gặp là nhà phát triển thấy thêm tính năng vào lớp có sẵn đỡ tốn công hơn tạo lớp mới. Không ai quyết định tạo ra anti-pattern này; nó lớn dần qua từng "thêm một hàm nhỏ".
Triệu chứng của anti-pattern trong code
Ví dụ tối giản OrderManager trong code/s6-33/Demo1GodObject.java:
static class OrderManager {
private final Map<String, BigDecimal> stock = new HashMap<>();
private final List<String> emailOutbox = new ArrayList<>();
private final List<String> sqlLog = new ArrayList<>();
private final Map<String, Integer> voucherUsed = new HashMap<>();
private BigDecimal revenue = BigDecimal.ZERO;
BigDecimal calculateTotal(List<String[]> lines, String voucher) { ... } // tính giá, khuyến mãi
void reserveStock(String sku, int qty) { ... } // kho
void saveOrder(String id, BigDecimal total) { ... } // database
void sendConfirmation(String email, String id) { ... } // email
String renderInvoiceHtml(String id, BigDecimal total) { ... } // giao diện
void updateRevenueReport(BigDecimal total) { ... } // báo cáo
boolean validateVoucher(String voucher) { ... } // voucher
}Lớp này vi phạm nguyên lý trách nhiệm đơn (S trong SOLID): nó có ít nhất sáu lý do để thay đổi. Thay cách tính khuyến mãi, đổi nhà cung cấp email hay đổi mẫu hoá đơn đều buộc sửa cùng một file, nên hai người làm hai tính năng khác nhau xung đột merge liên tục. Kiểm thử cũng khổ: muốn kiểm tra riêng quy tắc giá, ta phải dựng cả OrderManager với kho, hộp thư, log SQL, bộ đếm voucher và báo cáo doanh thu.
Cách sửa: tách theo trách nhiệm
Cách sửa kinh điển cho anti-pattern God object là Extract Class: tách mỗi trách nhiệm thành lớp hoặc interface riêng, rồi để một lớp điều phối mỏng nối chúng lại. Trong bản sửa, Pricing chỉ tính giá, OrderRepository và Notifier là interface cho phần hạ tầng, Discount là một strategy, còn PlaceOrder chỉ điều phối:
static class PlaceOrder { // chỉ điều phối
PlaceOrder(Pricing p, OrderRepository r, Notifier n) { ... }
BigDecimal handle(String id, String email, List<Line> lines, Discount d) {
BigDecimal total = pricing.total(lines, d);
repo.save(id, total); notifier.orderConfirmed(email, id);
return total;
}
}Kết quả chạy thật (JDK 21.0.12, Apple M4, 16 GB):
Số trường và phương thức (reflection):
OrderManager fields=5 methods=7
Pricing fields=0 methods=1
PlaceOrder fields=3 methods=1
tổng không giảm: 390000
tổng giảm 10%: 351000.00
đã lưu: [DH001=390000, DH002=351000.00], đã gửi mail: [a@example.com/DH001, a@example.com/DH002]
God object tính giá: 270000.0 (phải khởi tạo cả kho, outbox, sqlLog, voucherUsed, revenue)Bản sửa kiểm thử được bằng hai lambda: một lambda thay repository, một thay notifier, nên đổi quy tắc giảm giá chỉ là truyền một Discount khác mà không chạm tới database hay email. Số đếm bằng reflection chỉ minh hoạ quy mô (ví dụ này nhỏ; God object thật có hàng chục trường và hàng trăm phương thức), nhưng chiều hướng đã rõ: mỗi lớp mới nhỏ, tập trung, dễ đọc.
Cách phát hiện anti-pattern God object và khi nào chấp nhận được
- Dấu hiệu: tên lớp có đuôi
Manager,Helper,Utils,Servicechung chung; file dài hàng nghìn dòng; nhiều nhóm phương thức không dùng chung trường nào; lớp có nhiều import từ các tầng khác nhau (JDBC, mail, HTML cùng lúc). - Phép thử cohesion: nếu có thể chia các trường và phương thức thành các cụm rời nhau, mỗi cụm là một lớp tiềm năng.
- Chấp nhận được: lớp
mainhoặc composition root nối mọi thứ (nhưng không chứa logic), hoặc code tạm. Lớp lớn chưa chắc là God object nếu mọi thành viên cùng phục vụ một khái niệm.
Anti-pattern anemic domain model: đối tượng miền chỉ có getter và setter
Martin Fowler mô tả anti-pattern anemic domain model ngày 25/11/2003 trong bài AnemicDomainModel. Các đối tượng mang tên nghiệp vụ (Order, Customer), liên kết với nhau đầy đủ, nhưng gần như không có hành vi: chúng là "túi getter và setter", còn logic nằm ở các service tính toán rồi cập nhật dữ liệu. Fowler coi đây là phong cách thủ tục, đi ngược nguyên tắc hướng đối tượng là gắn dữ liệu với hành vi. Quan trọng hơn, ông nhận xét ta trả toàn bộ chi phí của mô hình miền (ánh xạ O/R, nhiều lớp) mà không nhận lợi ích, vì logic phức tạp đã bị chuyển ra ngoài đối tượng.
Tái hiện anti-pattern: quy tắc nằm ở service thì bị lách
Demo2Anemic.java đặt hai thiết kế cạnh nhau với cùng quy tắc "đơn đã thanh toán không được thêm dòng". Phiên bản anemic để quy tắc ở OrderService, dữ liệu ở OrderData có setter công khai:
static class OrderService {
void addLine(OrderData o, BigDecimal price) {
if ("PAID".equals(o.getStatus())) throw new IllegalStateException("Đơn đã thanh toán");
o.getLines().add(price);
}
}
...
svc.addLine(data, new BigDecimal("100000"));
data.setStatus("PAID");
svc.addLine(data, new BigDecimal("50000")); // bị chặn
data.getLines().add(new BigDecimal("-999999")); // một đoạn code khác lách service
data.setStatus("PAID"); data.setLines(new ArrayList<>());Phiên bản rich model đưa quy tắc vào chính Order, không có setter, trả về bản sao chỉ đọc của danh sách. Kết quả chạy thật:
== Anemic ==
qua service: bị chặn: Đơn đã thanh toán
lách bằng getLines().add(): tổng = -899999 (đơn PAID bị sửa, giá âm)
đơn rỗng vẫn PAID: status=PAID, lines=[]
== Rich ==
addLine sau pay: Đơn đã thanh toán
lines().add(): UnsupportedOperationException
pay đơn rỗng: Đơn rỗng
giá âm: Giá phải dương
tổng: 100000Ở bản anemic, quy tắc chỉ có hiệu lực nếu mọi nơi nhớ đi qua OrderService.addLine. Một dòng getLines().add(...) ở batch job, controller hay test là đủ phá nó: đơn đã thanh toán bị sửa, xuất hiện giá âm, và đơn rỗng vẫn mang trạng thái PAID. Ở bản rich, tránh được anti-pattern này vì không có đường nào khác ngoài addLine và pay, nên trạng thái sai không tồn tại. Đây cũng chính là động lực của Aggregate đã nói ở bài DDD.
Cách sửa anti-pattern anemic và các cạm bẫy
- Chuyển hành vi vào đối tượng: mọi thay đổi trạng thái đi qua phương thức có tên nghiệp vụ (
pay(),cancel()) thay vìsetStatus. - Bỏ setter công khai, không lộ collection khả biến: trả
List.copyOfhoặc view chỉ đọc. - Dùng Value Object thay cho kiểu nguyên thuỷ (tiền, số điện thoại) để kiểm tra hợp lệ ngay trong constructor.
- Giữ service ở đúng vai trò: theo Fowler, Service Layer vẫn hợp lệ khi đi cùng một mô hình miền giàu hành vi. Evans mô tả tầng ứng dụng là mỏng, chỉ điều phối chứ không chứa quy tắc nghiệp vụ. Cảnh báo chỉ áp dụng khi service nắm phần lớn logic.
- Đừng ép: ứng dụng CRUD thuần gần như không có quy tắc, nên lớp getter/setter ở đó là đủ. DTO và đối tượng trao đổi qua ranh giới (JSON, message) cũng được phép là "túi dữ liệu"; vấn đề chỉ nảy sinh khi đó là mô hình miền.
Lưu ý: dùng Lombok @Data cho entity JPA sinh setter cho mọi trường, tức là tạo anemic model rất dễ. Thư viện không tạo ra anti-pattern, nhưng làm đường dẫn tới đó quá dễ.
Anti-pattern service locator: phụ thuộc bị giấu trong thân hàm
Service locator, một anti-pattern gây tranh cãi, là một registry mà code gọi để lấy phụ thuộc: Locator.resolve(Validator.class). Nó có vẻ giống Dependency Injection vì cũng tách lớp khỏi cài đặt cụ thể. Nhưng Mark Seemann trong bài Service Locator is an Anti-Pattern (03/02/2010) lập luận rằng nó giấu phụ thuộc của lớp, đẩy lỗi từ thời điểm biên dịch sang lúc chạy và làm việc đánh giá thay đổi khó hơn. Ví dụ của ông: OrderProcessor có constructor mặc định, tạo được bình thường, nhưng gọi Process mới nổ vì IOrderValidator chưa được đăng ký.
Tái hiện anti-pattern: lỗi chỉ lộ khi chạy
Demo3ServiceLocator.java dựng đúng kịch bản đó bằng Java, cùng bản constructor injection để so sánh:
static class LocatorOrderProcessor {
String process(String order) {
if (!Locator.resolve(Validator.class).ok(order)) return "từ chối";
return Locator.resolve(Shipper.class).ship(order); // phụ thuộc nằm sâu trong thân hàm
}
}
static class InjectedOrderProcessor {
InjectedOrderProcessor(Validator v, Shipper s) { ... } // phụ thuộc hiện ra ở chữ ký
}== Service Locator ==
new LocatorOrderProcessor(): thành công, không báo thiếu gì
process() lúc chạy: Chưa đăng ký: Validator
sau khi đăng ký Validator, vẫn lỗi: Chưa đăng ký: Shipper
đủ đăng ký: đã giao DH001
-- trạng thái tĩnh rò giữa các test --
test B (quên đăng ký, nhưng test A đã đăng ký trước đó): đã giao DH002
test B chạy riêng: Chưa đăng ký: Validator
== Constructor injection ==
thiếu phụ thuộc: lỗi ngay khi khởi tạo (NullPointerException)
khởi tạo đủ, kiểm thử không cần registry: đã giao DH003Có ba hiện tượng đáng chú ý. Thứ nhất, việc khởi tạo thành công không nói gì về việc lớp có dùng được hay không; lỗi đến từng cái một lúc gọi (thiếu Validator, sửa xong lại thiếu Shipper). Thứ hai, người dùng lớp phải đọc thân hàm mới biết cần đăng ký gì. Thứ ba, và nguy hiểm nhất với kiểm thử, anti-pattern này làm test phụ thuộc thứ tự: registry tĩnh dùng chung toàn JVM, nên test B quên đăng ký vẫn xanh nếu test A chạy trước, rồi đỏ khi chạy riêng. Constructor injection thì ngược lại: phụ thuộc hiện ra ngay ở chữ ký, thiếu là lỗi ngay khi khởi tạo, và test chỉ cần truyền lambda.
Một góc nhìn cân bằng: ý kiến của Fowler
Bức tranh không một chiều. Trong bài kinh điển Inversion of Control Containers and the Dependency Injection pattern, Fowler cho rằng với lớp ứng dụng, phụ thuộc vào một locator "không phải vấn đề lớn", và việc kiểm thử không thiên về injection vì cả hai đều thay thế được nếu locator được thiết kế để thay thế. Điểm ông thấy khác biệt thật sự là với thành phần tái sử dụng mà người khác triển khai: ở đó chỉ một giả định tối thiểu về locator đã là vấn đề, nên injection tốt hơn. Cuối phần đó ông viết rằng lựa chọn giữa hai bên ít quan trọng hơn nguyên tắc tách cấu hình khỏi việc sử dụng.
Hai ý kiến này không mâu thuẫn như nghe có vẻ. Về anti-pattern này, cả hai cùng đồng ý với nguyên tắc tách việc cấu hình khỏi việc dùng. Seemann phản đối cách locator được dùng rải rác trong logic nghiệp vụ; Fowler chỉ ra rằng ở ranh giới ứng dụng thì ảnh hưởng nhỏ. Trong Spring, ApplicationContext.getBean(...) chính là service locator, và nó chấp nhận được ở một số chỗ (code khởi động, tích hợp với framework không hỗ trợ injection, một vài lớp tiện ích tĩnh), nhưng nếu service nghiệp vụ của bạn tự gọi getBean trong thân hàm, bạn đang rơi vào đúng kịch bản của demo trên.
Cách sửa anti-pattern service locator
- Constructor injection cho phụ thuộc bắt buộc, như ở bài về IoC container. Spring khuyến khích cách này vì lớp trở thành bất biến và dễ kiểm thử.
- Factory hoặc
Supplierđược tiêm vào khi cần tạo đối tượng ngắn hạn hoặc theo điều kiện, thay vì truyền cả container. - Composition root: chỉ một chỗ (main, cấu hình) biết container; phần còn lại không biết.
Ba anti-pattern liên quan với nhau
Ba anti-pattern này không độc lập. Một hệ thống anemic thường sinh ra service béo, tức là God object ở tầng service: logic bị rút khỏi đối tượng miền thì phải đi đâu đó. Service béo lại phụ thuộc nhiều thứ, và để tránh constructor mười tham số khó coi, người ta lấy phụ thuộc qua locator hoặc getBean, và phụ thuộc bị giấu đi. Ngược lại, sửa từ một đầu sẽ kéo theo các đầu còn lại: khi bạn đưa hành vi về đối tượng miền, service nhỏ đi; khi service nhỏ đi, constructor tự nhiên gọn và không còn cám dỗ dùng locator.
| Anti-pattern | Triệu chứng | Tác hại đo được | Cách sửa |
|---|---|---|---|
| God object | Một lớp nhiều trách nhiệm, nhiều trường không liên quan | Xung đột merge, test phải dựng cả lớp | Extract Class, strategy, điều phối mỏng |
| Anemic domain model | Entity chỉ getter/setter, logic ở service | Bất biến bị lách (demo: đơn PAID bị sửa, giá âm) | Hành vi vào đối tượng, bỏ setter, Value Object |
| Service locator | resolve() trong thân hàm, registry tĩnh | Lỗi lúc chạy, test phụ thuộc thứ tự | Constructor injection, composition root |
Cách xử lý một anti-pattern trong code thật
Gặp một God object 3.000 dòng, đừng viết lại từ đầu. Hãy viết test mô tả hành vi hiện tại, Extract Class một cụm trường và phương thức cohesive, chạy lại test, rồi lặp lại mỗi lần chạm vào file vì lý do khác. Với anemic model, chọn một quy tắc hay bị vi phạm, chuyển nó vào đối tượng, xoá setter tương ứng và để trình biên dịch chỉ ra mọi chỗ lách. Với service locator, bắt đầu từ lớp có nhiều lời gọi resolve nhất và đổi sang constructor. Đo kết quả bằng chỉ số đơn giản như số trường, số nhóm trách nhiệm, số lần file bị sửa trong tháng (từ git log).
Lỗi thường gặp khi chống anti-pattern
- Tách quá tay: chia thành hàng chục lớp một phương thức khiến code khó theo dõi hơn God object; hãy tách theo lý do thay đổi, không theo số dòng.
- Thay God object bằng "God service" mới: đổi tên
OrderManagerthànhOrderServicemà không tách trách nhiệm thì chẳng thay đổi gì. - Rich model một cách hình thức: đặt phương thức vào entity nhưng vẫn giữ setter công khai nên không bảo vệ được gì.
- Cấm tuyệt đối anti-pattern locator: bỏ qua trường hợp ranh giới ứng dụng mà Fowler chỉ ra; mục tiêu là phụ thuộc rõ ràng ở logic nghiệp vụ.
- Gán nhãn thay vì phân tích: gọi mọi thứ là anti-pattern mà không chỉ ra tác hại cụ thể thì người nghe khó đồng ý.
Checklist rà soát anti-pattern
- Lớp nào có hơn một lý do để thay đổi? Có cụm trường không dùng chung với cụm khác không?
- Entity có setter công khai hay trả về collection khả biến không? Quy tắc nghiệp vụ nằm ở đâu?
- Có lời gọi
getBean,resolvehoặc registry tĩnh trong logic nghiệp vụ không? - Một test có phải dựng nhiều thứ không liên quan để chạy không? Test có kết quả khác nhau theo thứ tự chạy không?
- Đã có test bảo vệ hành vi trước khi refactor chưa?
- Có phải CRUD thuần, nơi mô hình đơn giản là đủ không?
Tóm lại, gọi tên anti-pattern là công cụ giao tiếp để gọi tên một vấn đề và chỉ ra lối thoát, không phải vũ khí để chê code. Ba trường hợp trên đều có chung một gốc: thiếu ranh giới rõ ràng, dù là ranh giới trách nhiệm, ranh giới bất biến hay ranh giới phụ thuộc. Khi ranh giới được đặt đúng, code dễ đổi hơn. Tài liệu tham khảo: Fowler: AnemicDomainModel, Seemann: Service Locator is an Anti-Pattern và refactoring.guru: Large Class.
Bài tiếp theo
Biết gọi tên những thiết kế gây hại mới là một nửa việc; nửa còn lại là viết test để refactor an toàn mà không sợ làm hỏng hành vi cũ. Mời bạn đọc tiếp Testing patterns: Test Data Builder, Object Mother và test double.



