Nền tảng Spring Boot cho thiết kế
Chương 1 kết thúc với một kết luận rõ ràng: EnrollmentManager là một God object gắn chặt với database, email và thư viện PDF, nên mỗi thay đổi đều đắt và khó test. Bước tự nhiên tiếp theo là tách nó ra. Nhưng tách như thế nào để thật sự giảm coupling, chứ không chỉ đổi một class lớn lấy vài class nhỏ vẫn gắn chặt vào nhau? Đây chính là chỗ Spring Boot bước vào. Chương này giới thiệu các cơ chế nền tảng của Spring — Inversion of Control, Dependency Injection, bean, và Layered Architecture — không phải như một bài hướng dẫn cấu hình, mà như một bộ công cụ để hiện thực những mục tiêu thiết kế đã đặt ra ở Chương 1.
Cần nói rõ ngay từ đầu một điều mà rất nhiều lập trình viên hiểu sai: Spring không tự làm cho code của bạn trở nên SOLID. Nó cấp cho bạn cơ chế để có thể thiết kế tốt, còn dùng cơ chế đó cho đúng nguyên lý lại là chuyện khác. Vì vậy Chương 2 dạy cơ chế (Spring wiring hoạt động ra sao), còn năm chương sau dạy nguyên lý (dùng cơ chế đó thế nào cho đúng). Ranh giới này sẽ được nhắc lại nhiều lần, vì lẫn lộn giữa cơ chế và nguyên lý là nguồn gốc của những codebase Spring "đúng cú pháp mà sai thiết kế".
Tình huống: đã tách class nhưng coupling vẫn còn
Giả sử ta nghe theo Chương 1 và tách EnrollmentManager thành ba class: một class lo nghiệp vụ ghi danh, một class lo truy cập database, một class lo gửi thông báo. Một lập trình viên mới ra trường thường sẽ nối chúng theo cách quen thuộc nhất: cho class nghiệp vụ tự tạo ra hai class kia bằng new ngay bên trong.
public class EnrollmentService {
// Tự tạo dependency bằng new — coupling chưa hề giảm
private final EnrollmentRepository repository = new EnrollmentRepository();
private final EmailNotificationService notifier = new EmailNotificationService();
public void enroll(Long studentId, Long courseId) {
// ... logic ghi danh, gọi repository.save(...) và notifier.send(...)
}
}
Nhìn thì gọn hơn EnrollmentManager, nhưng vấn đề cốt lõi vẫn còn nguyên. EnrollmentService biết chính xác nó dùng EnrollmentRepository nào và EmailNotificationService nào; nó tự quyết định việc tạo ra chúng. Muốn đổi sang gửi thông báo qua SMS, hay muốn thay database thật bằng một bản giả để viết test, bạn buộc phải mở EnrollmentService ra sửa. Đây vẫn là tight coupling, chỉ là được chia thành nhiều mảnh. Câu hỏi đặt ra: nếu không để mỗi class tự tạo dependency của mình, thì ai sẽ làm việc đó?
Ghi chú
Spring Boot làm gì cho thiết kế
Spring Boot là một lớp tiện ích dựng trên Spring Framework, sinh ra để giảm công cấu hình: nó cung cấp các starter dependency gom sẵn thư viện theo nhóm, cùng auto-configuration tự thiết lập những thành phần thông dụng dựa trên những gì có trên classpath. Nhờ đó bạn khởi động một ứng dụng web chỉ với vài dòng thay vì hàng trăm dòng cấu hình. Những tiện ích này quan trọng, nhưng chúng không phải trọng tâm của cuốn sách.
Thứ liên quan trực tiếp tới thiết kế là hạt nhân mà Spring Boot kế thừa từ Spring Framework: IoC container. Đây là bộ phận quyết định object nào được tạo, được cấu hình ra sao, và được nối với nhau như thế nào. Hiểu container này là hiểu vì sao một ứng dụng Spring được tổ chức theo cách nó vốn có — và đó là nền để áp dụng SOLID về sau.
Inversion of Control: đảo quyền điều khiển
Inversion of Control (IoC), tạm hiểu là đảo quyền điều khiển, mô tả việc chuyển quyền quyết định luồng tạo và nối object từ code ứng dụng sang một thành phần bên ngoài. Trong đoạn code ở trên, chính EnrollmentService giữ quyền điều khiển: nó tự quyết định tạo repository và notifier. Với IoC, quyền đó bị đảo ngược — một container đứng ngoài tạo sẵn các object và trao cho EnrollmentService những gì nó cần. Martin Fowler, trong bài viết năm 2004 về chủ đề này, chốt lại bản chất của IoC bằng một câu đáng nhớ: điều cốt lõi là tách phần cấu hình (quyết định dùng implementation nào, nối ra sao) khỏi phần sử dụng (logic nghiệp vụ thực thi).
IoC là một khái niệm rộng, và Dependency Injection chỉ là một dạng của nó. Bất cứ khi nào bạn trao quyền điều khiển luồng chạy cho một framework thay vì tự gọi, đó đã là IoC: một template method để lớp con điền vào chỗ trống, một callback được framework gọi lại, hay một event listener được kích hoạt khi có sự kiện. Nguyên tắc chung đằng sau thường được gọi là Hollywood Principle — "đừng gọi chúng tôi, chúng tôi sẽ gọi bạn". Chính vì IoC quá tổng quát nên Fowler đề nghị một cái tên cụ thể hơn cho dạng IoC dùng để lắp ráp dependency, đó là Dependency Injection.
Tách được hai việc này là một thắng lợi thiết kế lớn. Logic nghiệp vụ của EnrollmentService không còn bận tâm dependency đến từ đâu; nó chỉ việc dùng. Việc quyết định dùng bản thật hay bản giả, gửi email hay SMS, được dời ra một chỗ tập trung. Đây là điều kiện tiên quyết để đạt loose coupling mà Chương 1 đã đặt làm mục tiêu.
IoC Container và Application Context
Trong Spring, thành phần đảm nhận IoC được gọi là IoC container. Nhiệm vụ của nó là tạo ra các object, cấu hình chúng, lắp ráp các phụ thuộc giữa chúng, và quản lý vòng đời của chúng từ lúc khởi tạo tới lúc hủy. Giao diện đại diện cho container này là ApplicationContext. Khi một ứng dụng Spring Boot khởi động, nó dựng lên một ApplicationContext, quét tìm các class cần quản lý, khởi tạo chúng và nối chúng lại thành một đồ thị object hoàn chỉnh, sẵn sàng phục vụ request.
Spring thực ra có hai loại container. BeanFactory là bản cơ bản, tạo bean theo kiểu lazy — chỉ khi được yêu cầu. ApplicationContext là bản mở rộng và là loại dùng trong hầu hết ứng dụng thực tế: nó tạo sẵn các singleton bean ngay lúc khởi động (eager) và bổ sung nhiều tính năng doanh nghiệp như phát sự kiện, quốc tế hóa (i18n), và tích hợp AOP. Trong ứng dụng Spring Boot, việc quét package bắt đầu từ package chứa class gắn @SpringBootApplication rồi lan xuống các package con — đó là lý do đặt class khởi động ở package gốc là một quy ước quan trọng.
Một object do container tạo ra và quản lý được gọi là một Spring bean. Khác biệt giữa một bean và một object thường không nằm ở bản thân class, mà ở chỗ ai tạo và quản lý nó: object bạn tự new do bạn quản lý; bean do container quản lý. Chính vì container nắm quyền tạo bean nên nó mới có thể tiêm dependency vào đúng lúc khởi tạo — đó là cơ chế ta sẽ xem ngay sau đây.
Dependency Injection: một dạng cụ thể của IoC
Dependency Injection (DI) là cách phổ biến nhất để hiện thực IoC: thay vì object tự tạo dependency, container tiêm (inject) dependency vào từ bên ngoài. DI không phải một khái niệm đối lập với IoC mà là một trường hợp cụ thể của nó — IoC là ý tưởng tổng quát về đảo quyền điều khiển, còn DI là kỹ thuật đưa dependency vào để hiện thực ý tưởng đó. Spring hỗ trợ ba kiểu tiêm: qua constructor, qua setter, và qua field.
Ba kiểu này khác nhau ở thời điểm và cách dependency được đưa vào. Constructor injection truyền dependency qua tham số constructor, nên object luôn được tạo ra ở trạng thái đầy đủ và dependency có thể để final. Setter injection đưa dependency qua method setter sau khi object đã được tạo, phù hợp cho dependency tùy chọn hoặc có thể thay đổi về sau. Field injection gán thẳng giá trị vào field (thường qua @Autowired), ngắn gọn về cú pháp nhưng đánh đổi nhiều mặt sẽ được phân tích ở phần sau. Khuyến nghị hiện hành khá rõ: constructor injection cho dependency bắt buộc, setter injection cho dependency tùy chọn, và tránh field injection trong code mới.
Trong ba kiểu, constructor injection là kiểu được khuyến nghị, và cuốn sách này dùng nó làm mặc định. Lý do sẽ được phân tích kỹ ở phần sau; trước hết cần thấy nó trông ra sao trong một ứng dụng có cấu trúc.
Stereotype annotation và component scanning
Để container biết class nào cần quản lý thành bean, Spring dùng cơ chế component scanning: khi khởi động, nó quét các package và nhận diện những class được gắn annotation đánh dấu. Annotation nền tảng là @Component; ngoài ra Spring cung cấp ba biến thể chuyên biệt mang ý nghĩa ngữ nghĩa rõ hơn theo vai trò của class: @RestController (hoặc @Controller) cho lớp tiếp nhận request, @Service cho lớp chứa logic nghiệp vụ, và @Repository cho lớp truy cập dữ liệu. Về mặt kỹ thuật cả ba đều kế thừa @Component nên đều trở thành bean; khác biệt nằm ở ý nghĩa và ở một vài hành vi kèm theo.
Khác biệt hành vi đáng chú ý nhất thuộc về @Repository: Spring tự động dịch các exception truy cập dữ liệu ở tầng thấp (ví dụ SQLException) thành cây exception thống nhất của Spring là DataAccessException. Nhờ đó lớp nghiệp vụ không phải phụ thuộc vào loại exception đặc thù của một công nghệ database cụ thể — một cách giảm coupling do chính framework cung cấp. @Service và @RestController chủ yếu mang ý nghĩa phân loại (và là điểm neo cho các tính năng như transaction hay xử lý request), nhưng việc dùng đúng annotation theo vai trò giúp codebase tự mô tả kiến trúc của nó.
@Autowired là annotation đánh dấu điểm mà container cần tiêm dependency. Một chi tiết thực hành quan trọng: nếu một class chỉ có đúng một constructor, từ Spring 4.3 trở đi bạn không cần ghi @Autowired trên constructor đó nữa — container tự hiểu. Vì vậy trong sách này, các ví dụ constructor injection thường không có @Autowired, và đó là cách viết hiện đại được khuyến khích.
Khi có nhiều bean cùng kiểu: @Primary và @Qualifier
Điều gì xảy ra khi container tìm thấy nhiều bean cùng một kiểu nhưng một chỗ tiêm chỉ nhận được một? Mặc định Spring không tự ý chọn; nó ném NoUniqueBeanDefinitionException lúc khởi động — lại một lần nữa fail-fast. Đây là tình huống rất thường gặp, và cũng là nền cho các chương sau khi ta có nhiều implementation của cùng một interface (ví dụ nhiều PaymentGateway, hay nhiều chính sách khuyến mãi).
Có hai cách khử nhập nhằng. @Primary đánh dấu một bean làm lựa chọn mặc định của toàn hệ thống khi không chỉ định gì thêm. @Qualifier chỉ đích danh bean cần tiêm theo tên, dùng khi muốn ghi đè mặc định ở một chỗ cụ thể. Quy tắc thực dụng: dùng @Primary cho lựa chọn mặc định phổ biến nhất, và @Qualifier cho những nơi cần một lựa chọn khác.
public interface PaymentGateway {
double charge(Long studentId, Long courseId, String promoCode);
}
@Component
@Primary // lựa chọn mặc định của toàn hệ thống
public class StripePaymentGateway implements PaymentGateway { /* ... */ }
@Component("vnpay") // đặt tên để @Qualifier chỉ tới
public class VnPayPaymentGateway implements PaymentGateway { /* ... */ }
@Service
public class EnrollmentService {
private final PaymentGateway paymentGateway;
// Không @Qualifier: nhận bean @Primary (Stripe)
public EnrollmentService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
}
@Service
public class DomesticEnrollmentService {
private final PaymentGateway paymentGateway;
// Có @Qualifier: ghi đè mặc định, nhận đúng bean "vnpay"
public DomesticEnrollmentService(@Qualifier("vnpay") PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
}
Cơ chế này là nền tảng kỹ thuật cho Open/Closed và Dependency Inversion ở các chương sau: khi thêm một PaymentGateway mới, bạn chỉ cần thêm một bean, còn việc chọn bean nào được giải quyết qua @Primary hoặc @Qualifier mà không phải sửa nơi sử dụng.
Vòng đời và scope của bean
Mặc định, mỗi bean trong Spring là singleton: container tạo đúng một thể hiện và dùng lại cho toàn ứng dụng. Ngoài ra còn các scope khác như prototype (tạo mới mỗi lần lấy ra), và trong ứng dụng web là request hoặc session. Container cũng cung cấp các điểm móc vào vòng đời: một method gắn @PostConstruct chạy ngay sau khi dependency đã được tiêm xong, còn @PreDestroy chạy trước khi bean bị hủy.
Đầy đủ hơn, mỗi bean đi qua một trình tự vòng đời cố định do container điều phối: container khởi tạo object; tiêm dependency vào; gọi các callback nhận biết context và các BeanPostProcessor ở giai đoạn trước khởi tạo; chạy method khởi tạo như @PostConstruct; bean sẵn sàng phục vụ; và khi context đóng lại, chạy @PreDestroy rồi hủy bean. Một chi tiết đáng nhớ: bean scope prototype không được container gọi @PreDestroy — sau khi tạo xong, vòng đời của nó do bên sử dụng tự quản.
Hệ quả thiết kế của việc bean mặc định là singleton rất quan trọng: một bean singleton nên là stateless, tức không giữ state thay đổi được và dùng chung giữa các request. Nếu một @Service singleton lưu dữ liệu riêng của từng request vào field, các request sẽ ghi đè lẫn nhau — đây chính là common coupling qua trạng thái dùng chung mà Chương 1 đã cảnh báo. Giữ service stateless và truyền dữ liệu qua tham số là cách tránh.
Tạo bean cho class bạn không sở hữu: @Configuration và @Bean
Stereotype annotation chỉ gắn được lên class của chính bạn. Khi cần biến một class từ thư viện bên thứ ba — không sửa được để thêm annotation — thành bean, dùng một lớp cấu hình gắn @Configuration với các method gắn @Bean. Container gọi method đó và đăng ký giá trị trả về làm bean, sẵn sàng để inject vào nơi khác.
@Configuration
public class PaymentConfig {
// Biến một client bên thứ ba thành bean để inject vào EnrollmentService
@Bean
public PaymentGateway paymentGateway(@Value("${payment.api-key}") String apiKey) {
return new StripePaymentGateway(apiKey); // class ta không sửa được để gắn @Component
}
}
Cách này giữ cho việc lắp ráp — chọn implementation nào, cấu hình ra sao — nằm ở một chỗ tập trung, đúng tinh thần tách configuration khỏi use của Fowler. Đồng thời nơi dùng (EnrollmentService) chỉ khai báo cần một PaymentGateway, không cần biết đó là implementation nào.
IoC và DI giải quyết gì từ Chương 1
Ba lợi ích của DI ánh xạ trực tiếp vào ba vấn đề Chương 1 đã nêu. Thứ nhất là loose coupling: khi EnrollmentService nhận dependency từ ngoài thay vì tự tạo, nó không còn phụ thuộc vào implementation cụ thể, nên đổi implementation không buộc phải sửa nó. Thứ hai là bớt boilerplate và tập trung cấu hình: container lo việc tạo và nối object, lập trình viên không rải rác lệnh new khắp nơi. Thứ ba, và quan trọng nhất với chất lượng lâu dài, là testability.
Testability là chỗ DI thể hiện giá trị rõ nhất. Nhớ lại rằng EnrollmentManager ở Chương 1 gần như không thể unit test vì nó gọi thẳng database và email thật. Khi dependency được tiêm từ ngoài, bạn có thể tiêm một bản giả (mock) trong test: kiểm tra logic ghi danh mà không cần database thật, không cần gửi email thật. Đây không phải tiện ích phụ — khả năng test độc lập là một trong những thước đo đáng tin cậy nhất của thiết kế tốt, và Chương 8 sẽ dành trọn cho chủ đề này.
Ở góc độ bắc cầu học thuật và doanh nghiệp: trong bài tập ở trường, new trực tiếp một dependency là chuyện bình thường vì chương trình nhỏ và chỉ chạy một lần. Trong hệ thống production, để container quản lý phụ thuộc là mặc định, bởi hệ thống phải đổi implementation theo môi trường (thật khi chạy, giả khi test), phải quản lý vòng đời hàng trăm object, và phải giữ cho các thành phần đủ độc lập để nhiều người cùng phát triển.
Refactor lần đầu của EnrollmentManager
Bây giờ ta áp dụng những cơ chế trên để refactor EnrollmentManager. Đây là bước gỡ đầu tiên — tách concern và dùng DI — chứ chưa phải bản SOLID hoàn chỉnh. Cấu trúc mục tiêu là ba lớp: một controller tiếp nhận request, một service chứa logic ghi danh, và một repository lo truy cập dữ liệu; các collaborator được tiêm qua constructor.
// Lớp tiếp nhận request (presentation)
@RestController
@RequestMapping("/enrollments")
public class EnrollmentController {
private final EnrollmentService enrollmentService;
// Chỉ một constructor: không cần @Autowired, container tự tiêm
public EnrollmentController(EnrollmentService enrollmentService) {
this.enrollmentService = enrollmentService;
}
@PostMapping
public ResponseEntity<Void> enroll(@RequestBody EnrollmentRequest request) {
enrollmentService.enroll(request.studentId(), request.courseId(), request.promoCode());
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
// Lớp nghiệp vụ (business logic)
@Service
public class EnrollmentService {
private final EnrollmentRepository enrollmentRepository;
private final PaymentGateway paymentGateway; // interface — được inject
private final NotificationService notificationService;
public EnrollmentService(EnrollmentRepository enrollmentRepository,
PaymentGateway paymentGateway,
NotificationService notificationService) {
this.enrollmentRepository = enrollmentRepository;
this.paymentGateway = paymentGateway;
this.notificationService = notificationService;
}
public void enroll(Long studentId, Long courseId, String promoCode) {
if (enrollmentRepository.existsBy(studentId, courseId)) {
throw new IllegalStateException("Đã ghi danh khóa này rồi");
}
double fee = paymentGateway.charge(studentId, courseId, promoCode);
enrollmentRepository.save(new Enrollment(studentId, courseId, fee));
notificationService.notifyEnrolled(studentId, courseId);
}
}
// Lớp truy cập dữ liệu (data access)
@Repository
public class EnrollmentRepository {
private final JdbcTemplate jdbcTemplate; // do Spring Boot auto-configure, được inject
public EnrollmentRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public boolean existsBy(Long studentId, Long courseId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM enrollments WHERE student_id = ? AND course_id = ?",
Integer.class, studentId, courseId);
return count != null && count > 0;
}
public void save(Enrollment enrollment) {
jdbcTemplate.update(
"INSERT INTO enrollments(student_id, course_id, fee) VALUES (?, ?, ?)",
enrollment.studentId(), enrollment.courseId(), enrollment.fee());
}
}
So với EnrollmentManager ở Chương 1, khác biệt là rõ rệt. Mỗi lớp giờ có một concern: controller lo giao tiếp HTTP, service lo luồng nghiệp vụ, repository lo dữ liệu. Không lớp nào tự new dependency của mình; tất cả nhận qua constructor và do container lắp ráp. EnrollmentRepository dùng JdbcTemplate do Spring Boot tự cấu hình. Logic ghi danh trong service giờ có thể test bằng cách tiêm mock cho ba dependency, không cần database hay mail server thật.
Ghi chú
Một hệ quả trực tiếp của constructor injection: giờ có thể viết unit test cho EnrollmentService bằng cách tiêm mock cho ba dependency, kiểm tra logic ghi danh mà không cần database hay mail server thật. Đoạn test dưới đây là phần giới thiệu trước cho Chương 8 — chưa cần nắm chi tiết, chỉ cần thấy DI khiến việc test độc lập trở nên khả thi.
class EnrollmentServiceTest {
@Test
void khong_cho_ghi_danh_trung_khoa() {
EnrollmentRepository repo = mock(EnrollmentRepository.class);
PaymentGateway payment = mock(PaymentGateway.class);
NotificationService notifier = mock(NotificationService.class);
when(repo.existsBy(1L, 10L)).thenReturn(true); // giả lập: đã ghi danh trước đó
// Tạo service bằng new với mock — không cần khởi động Spring container
EnrollmentService service = new EnrollmentService(repo, payment, notifier);
assertThrows(IllegalStateException.class,
() -> service.enroll(1L, 10L, null));
verify(payment, never()).charge(anyLong(), anyLong(), any()); // không thu tiền khi trùng
}
}
Layered Architecture: biến Separation of Concerns thành cấu trúc
Ba lớp trong ví dụ trên không phải ngẫu nhiên; chúng là hiện thân của Layered Architecture, một cách tổ chức mã bằng cách cắt hệ thống thành các tầng, mỗi tầng đảm nhận một nhóm concern. Đây chính là Separation of Concerns của Chương 1 được cụ thể hóa thành cấu trúc thư mục và class. Trong một ứng dụng Spring điển hình, ba tầng thường gặp là presentation (controller), business (service), và data access (repository).
Tầng business là chỗ nhiều lập trình viên lúng túng nhất. Có một tình huống phỏng vấn được Khalil Stemmler kể lại trong solidbook: ứng viên bị hỏi "bạn thiết kế tầng business-logic thế nào?" và nhận ra mình chỉ nói được về MVC, cách chia thư mục, cách viết controller — không có gì thật sự liên quan đến business logic. Đây là một khoảng trống rất thật: trường lớp dạy thuật toán và cú pháp, nhưng hiếm khi dạy cách tổ chức logic nghiệp vụ. Tầng service trong kiến trúc phân lớp chính là nơi trả lời câu hỏi đó — nơi quy tắc nghiệp vụ sống, tách khỏi giao tiếp HTTP và khỏi chi tiết database.
Cách chia tầng này tương ứng với một cách nhìn tổng quát hơn mà nhiều tài liệu kiến trúc mô tả: tách phần hạ tầng (infrastructure — database, web, mail) khỏi phần ứng dụng (application — điều phối use case) và phần lõi nghiệp vụ (domain — quy tắc nghiệp vụ cốt lõi). Điểm mấu chốt là chiều phụ thuộc: tầng trên được phép phụ thuộc tầng dưới, và lý tưởng là phụ thuộc qua abstraction chứ không phải qua class cụ thể. Nguyên tắc về chiều phụ thuộc này — thường gọi là dependency rule — là hạt giống của Dependency Inversion (Chương 7) và của Hexagonal Architecture (Chương 9).
Layered Architecture đem lại ba lợi ích cụ thể. Nó khiến mỗi thay đổi khu trú vào đúng một tầng: đổi giao diện API chỉ chạm controller, đổi công nghệ database chỉ chạm repository. Nó cho phép test từng tầng độc lập. Và nó giúp người mới định vị nhanh: nhìn tên lớp là biết trách nhiệm. Đây đều là những biểu hiện cụ thể của mục tiêu low coupling, high cohesion đã nêu ở Chương 1.
Chia package: theo tầng hay theo tính năng
Một quyết định thực tế đi kèm layered architecture là tổ chức package thế nào. Có hai cách phổ biến. Package by layer gom file theo tầng kỹ thuật: một package controller, một package service, một package repository. Package by feature gom file theo domain capability: package enrollment chứa các thành phần của tính năng ghi danh; package course chứa các thành phần của khóa học. Spring Boot không bắt buộc một layout, nhưng tài liệu hiện hành minh họa package theo domain dưới root package và trỏ tới Spring Modulith khi cần thực thi structure ấy.
Kinh nghiệm thực tế từ cộng đồng khá thống nhất: package by layer đơn giản, hợp dự án nhỏ hoặc ngắn hạn; nhưng khi hệ thống lớn lên, các file của cùng một tính năng nằm rải rác khắp nhiều package, sửa một tính năng phải mở nhiều thư mục khác nhau. Package by feature cho cohesion cao hơn (mọi thứ của một tính năng ở cùng chỗ), coupling thấp hơn giữa các tính năng, và hỗ trợ nhiều người làm song song trên các tính năng khác nhau — nên thường được ưu tiên cho dự án vừa và lớn. Một quy tắc quan trọng khi chia theo feature: các package feature không nên inject thẳng bean của nhau; nếu một feature cần dữ liệu của feature khác, hãy gọi qua một interface công khai thay vì phụ thuộc trực tiếp — đây chính là low coupling ở cấp module.
Dù chọn cách nào, tinh thần vẫn là Separation of Concerns. Sách này dùng cách chia theo tầng cho ví dụ vì nó trực quan khi minh họa từng nguyên lý, nhưng ở dự án thật, package by feature thường là lựa chọn bền vững hơn khi hệ thống phát triển.
Vì sao constructor injection, không phải field injection
Đã đến lúc trả lời vì sao cuốn sách chọn constructor injection làm mặc định thay vì field injection (gắn @Autowired trực tiếp lên field). Field injection nhìn ngắn gọn hơn, nhưng đánh đổi bằng nhiều nhược điểm mà chính Spring Team khuyến nghị nên tránh.
Có bốn lý do chính. Một là tính bất biến: constructor injection cho phép khai báo field là final, đảm bảo dependency được gán đúng một lần lúc khởi tạo và không bị đổi ngầm về sau; field injection không thể dùng final. Hai là testability: một class dùng constructor injection có thể được tạo trong test bằng một lệnh new bình thường với các mock truyền vào, không cần khởi động Spring container; class dùng field injection thì khó tạo trực tiếp vì không có điểm truyền dependency vào. Ba là dependency tường minh: constructor liệt kê rõ mọi thứ class cần để hoạt động, nên một constructor quá dài trở thành tín hiệu class có thể đang chứa quá nhiều responsibility. Bốn là phát hiện circular dependency: vòng phụ thuộc qua constructor không thể được thỏa mãn bằng một object chưa khởi tạo xong, nên container fail-fast và buộc team xem lại boundary.
Lý do thứ ba và thứ tư đặc biệt đáng giá vì chúng biến những vấn đề thiết kế ẩn thành lỗi hiện rõ. Một class khó viết constructor vì quá nhiều dependency, hay một hệ thống không khởi động được vì circular dependency, đều là phản hồi sớm từ chính công cụ — và phản hồi sớm luôn rẻ hơn phát hiện muộn.
Ba kiểu hỏng của field injection ngoài thực tế
Phần trên nêu lý do ở mức nguyên tắc; đây là ba failure mode cụ thể của field injection trong code và test.
Thứ nhất là NullPointerException lúc runtime. Vì field injection gán dependency sau khi object đã được tạo, nếu một object được khởi tạo ngoài container — ví dụ ai đó lỡ new nó, hoặc trong một test không dùng Spring — các field dependency vẫn là null, và lỗi chỉ bộc lộ khi code chạm tới chúng: muộn và khó truy vết. Constructor injection khiến điều này bất khả thi, vì không thể tạo object mà thiếu dependency bắt buộc.
Thứ hai là giấu circular dependency. Với field injection, ứng dụng có thể khởi động dù tồn tại vòng phụ thuộc, để rồi lỗi bộc lộ muộn lúc runtime dưới dạng NullPointerException khó hiểu. Constructor injection làm vòng phụ thuộc lộ ra ngay lúc khởi động — và như phần sau sẽ thấy, lộ sớm là điều tốt.
Thứ ba là gắn class vào Spring khi test. Một class dùng field injection không thể tạo bằng new với mock trong unit test; bạn buộc phải khởi động Spring context hoặc dùng reflection để gán mock vào field private. Điều này làm test chậm và phức tạp, đi ngược lại chính giá trị testability mà DI đáng lẽ mang lại. Đó cũng là lý do nhiều IDE như IntelliJ IDEA hiển thị cảnh báo "Field injection is not recommended" ngay khi bạn gắn @Autowired lên một field.
Circular dependency: một tình huống thực tế
Circular dependency xảy ra khi bean A cần bean B, còn bean B lại cần bean A — trực tiếp hoặc qua một chuỗi trung gian. Trong hệ thống LMS, hãy hình dung EnrollmentService gọi NotificationService để báo ghi danh thành công, rồi NotificationService lại gọi ngược EnrollmentService để lấy chi tiết đăng ký khi soạn nội dung email. Hai bean giờ phụ thuộc vòng vào nhau.
Từ Spring Boot 2.6, circular dependency bị cấm mặc định: ứng dụng fail-fast lúc khởi động thay vì âm thầm tạo ra một bean chưa hoàn chỉnh. Đây là hành vi tốt, vì nó buộc bạn đối diện vấn đề thiết kế. Có một cờ cấu hình để cho phép lại vòng phụ thuộc, nhưng dùng nó là xử lý phần ngọn chứ không phải gốc rễ.
Circular dependency thường là dấu hiệu boundary responsibility bị đặt sai. Các cách xử lý nên xem xét trước tiên là tách policy dùng chung ra một bean thứ ba, thiết kế lại để dependency đi một chiều, hoặc truyền dữ liệu cần thiết qua lời gọi thay vì để collaborator gọi ngược. @Lazy, setter injection hay cờ cho phép circular reference có thể phá vòng về mặt runtime, nhưng chỉ hợp lý khi team đã hiểu lifecycle và trade-off; chúng không tự giảm coupling.
Fat Controller và Anemic Domain Model: hai thái cực ngoài thực tế
Layered architecture dễ bị làm sai theo hai hướng ngược nhau, cả hai đều rất phổ biến trong code thật. Hiểu chúng giúp bạn tránh.
Fat Controller là khi controller phình ra ôm cả business logic: validate nghiệp vụ, truy vấn database, xử lý điều kiện, bắt exception — tất cả dồn vào một method của controller. Một dấu hiệu thực dụng được cộng đồng hay dùng: nếu một method controller dài quá mười lăm dòng, hoặc chứa logic điều kiện và truy cập dữ liệu, nhiều khả năng nó đã là fat controller. Controller lẽ ra chỉ nhận request, ủy quyền cho service, và trả response; mọi logic nghiệp vụ thuộc về tầng service. Fat controller khó test vì phải giả lập tầng web chỉ để kiểm tra một quy tắc nghiệp vụ, và khiến logic không tái sử dụng được.
Ở thái cực ngược lại là Anemic Domain Model (domain chỉ có dữ liệu, thiếu hành vi) — một tranh luận thiết kế đáng biết. Khi dồn toàn bộ logic vào các @Service và để các class domain như Enrollment, Course chỉ còn getter/setter, ta có dữ liệu một nơi, hành vi một nơi. Martin Fowler gọi đây là một anti-pattern vì nó tách data khỏi behavior và làm giảm lợi ích của object model. Cần trung thực về đánh đổi: cấu trúc service-centric quen thuộc của Spring dễ dẫn tới anemic domain model, và với nhiều ứng dụng CRUD đơn giản điều đó có thể chấp nhận được; khi business rules và invariant phức tạp lên, đưa behavior trở lại domain model thường có lợi hơn. Phân tích đầy đủ về Domain-Driven Design nằm ngoài phạm vi cuốn sách này.
Hai thái cực này cho thấy một điều quan trọng: bản thân việc có đủ ba tầng chưa đảm bảo thiết kế tốt. Đặt logic đúng tầng và giữ mỗi tầng đúng vai trò mới là phần khó — và đó chính là việc mà các nguyên lý SOLID ở những chương sau hướng dẫn.
Những hiểu lầm phổ biến
Dùng field injection ở khắp nơi vì nó ngắn: đánh đổi này che giấu chi phí thật (khó test, khó phát hiện circular dependency, không dùng được
final). Ưu tiên constructor injection.Đặt logic nghiệp vụ trong controller: controller chỉ nên nhận request, ủy quyền cho service và trả response. Controller phình to là dấu hiệu tầng business bị bỏ trống.
Coi Spring là phép màu: nếu không hiểu container tạo và nối bean thế nào, bạn sẽ lúng túng khi gặp lỗi wiring hoặc circular dependency. Cơ chế không phức tạp, và hiểu nó giúp bạn kiểm soát thay vì phỏng đoán khi gỡ lỗi.
Tưởng "đã dùng DI của Spring là đã theo DIP": DI là cơ chế tiêm dependency; DIP là nguyên lý yêu cầu phụ thuộc vào abstraction. Bạn hoàn toàn có thể inject một class cụ thể và vẫn vi phạm DIP. Chương 7 sẽ tách bạch điều này.
Để hai service phụ thuộc vòng vào nhau: circular dependency thường là dấu hiệu boundary responsibility cần được xem lại. Đổi sang field injection hoặc bật
spring.main.allow-circular-referencescó thể làm ứng dụng khởi động nhưng không tự giải quyết coupling; chỉ dùng khi đã phân tích rõ trade-off của trường hợp cụ thể.
Chương này nối với phần còn lại của sách ra sao
Chương 1 đặt ra mục tiêu — low coupling, high cohesion, separation of concerns — như những tiêu chí trừu tượng. Chương 2 trao công cụ Spring để hiện thực chúng: IoC và DI để giảm coupling, layered architecture để hiện thực SoC, container để quản lý vòng đời. Nhưng công cụ chỉ mở ra khả năng. Từ Chương 3 đến Chương 7, mỗi nguyên lý SOLID chỉ ra cách dùng những công cụ này cho đúng: Single Responsibility quyết định cắt tầng và tách class ở đâu, Open/Closed và Dependency Inversion quyết định nên inject abstraction thay vì class cụ thể. DI ở chương này là cơ chế; DIP ở Chương 7 là nguyên lý dùng cơ chế đó. Layered architecture ở đây sẽ tiến hóa thành Hexagonal Architecture ở Chương 9.
Tóm tắt
Inversion of Control chuyển quyền tạo và nối object từ code của bạn sang một container; bản chất là tách phần cấu hình khỏi phần sử dụng.
IoC container (đại diện bởi
ApplicationContext) tạo, cấu hình, lắp ráp và quản lý vòng đời của các bean.Dependency Injection là một dạng cụ thể của IoC; ưu tiên constructor injection vì nó cho phép
final, dễ test, làm dependency tường minh, và phát hiện circular dependency sớm.Các stereotype annotation (
@Component,@Service,@Repository,@RestController) đều tạo bean;@Repositorycòn tự dịch exception dữ liệu sangDataAccessException.Layered Architecture là Separation of Concerns thành cấu trúc: presentation, business, data access, với chiều phụ thuộc hướng xuống.
Spring cấp cơ chế để thiết kế tốt, nhưng không tự làm code SOLID; dùng DI chưa đồng nghĩa với theo DIP. Cơ chế trước, nguyên lý sau.
Bài tập cuối chương
Phần bài tập cuối chương giúp bạn phân biệt một object graph dùng DI đúng cú pháp với một thiết kế có dependency direction hợp lý. Hãy hoàn thành cả câu hỏi, code-spotting và bài refactor trước khi chuyển sang các nguyên lý SOLID cụ thể.