IoC & Dependency Injection: vì sao không tự new
Mình muốn học Spring Boot.
Rất phù hợp. Muốn hiểu Spring Boot thì phải bắt đầu từ Spring Core: IoC, Dependency Injection và Bean. Hiểu 3 khái niệm này thì những phần phía sau (AOP, @Transactional, @Async, auto-configuration) dễ hơn rất nhiều.
Cả chương này mình dùng một project xuyên suốt: shop-app, Spring Boot 3, Java 17.
shop-app/
└── src/main/java/com/example/shop/
├── ShopApplication.java
├── order/ OrderController, OrderService, OrderRepository
├── payment/ PaymentService, PaymentGateway
└── notify/ EmailService
1. Bài toán: OrderService cần PaymentService và EmailService
Tạo đơn hàng gồm: lưu order, thanh toán, gửi email. Cách tự nhiên nhất khi mới học Java:
public class OrderService {
private final OrderRepository orderRepository = new JpaOrderRepository();
private final PaymentService paymentService = new PaymentService(new VnpayGateway());
private final EmailService emailService = new EmailService("smtp.example.com", 587);
public Order createOrder(CreateOrderRequest req) {
Order order = orderRepository.save(Order.from(req));
paymentService.charge(order);
emailService.sendOrderConfirmation(order);
return order;
}
}
2. Vấn đề
OrderServicephải biết cách tạo mọi thứ nó dùng, kể cả SMTP host, gateway nào.- Đổi
VnpayGatewaysangMomoGatewaylà phải sửaOrderService. - Unit test không thay được
PaymentServicebằng mock: test nào cũng gọi thanh toán thật. - Mỗi chỗ
newmột instance riêng: 10 service dùngEmailServicelà 10 object, 10 connection.
Gốc rễ: object vừa dùng dependency vừa tạo dependency. Hai trách nhiệm dính vào nhau.
3. Giải pháp: IoC và DI
- IoC (Inversion of Control): đảo quyền điều khiển. Không phải class tự tạo dependency, mà một bên ngoài (Spring container) tạo và lắp ráp.
- DI (Dependency Injection): cách thực hiện IoC. Dependency được "tiêm" vào qua constructor, setter hoặc field.
- Container:
ApplicationContext. Nó đọc cấu hình, tạo object, inject, quản lý vòng đời. - Bean: object do container tạo và quản lý.
Tự new: IoC:
OrderService Spring Container
├─ new PaymentService ├─ tạo PaymentService
├─ new EmailService ├─ tạo EmailService
└─ dùng └─ new OrderService(payment, email)
↓
OrderService chỉ việc dùng
4. Code từng bước
Bước 1: Dependency. Chỉ cần starter web (đã kéo theo spring-context):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Bước 2: Đánh dấu bean bằng stereotype annotation
// Spring Data JPA tự tạo bean implementation cho interface này
public interface OrderRepository extends JpaRepository<Order, Long> { }
@Service
public class EmailService {
public void sendOrderConfirmation(Order order) {
System.out.println("Email for order " + order.getId());
}
}
@Service
public class PaymentService {
private final PaymentGateway gateway;
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
public void charge(Order order) {
gateway.pay(order.getId(), order.getTotal());
}
}
@Service, @Repository, @Controller đều là @Component có thêm ý nghĩa. @Repository còn được Spring dịch exception DB sang DataAccessException.
Bước 3: Constructor injection trong OrderService
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final EmailService emailService;
// Chỉ có 1 constructor thì không cần @Autowired
public OrderService(OrderRepository orderRepository,
PaymentService paymentService,
EmailService emailService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
this.emailService = emailService;
}
public Order createOrder(CreateOrderRequest req) {
Order order = orderRepository.save(Order.from(req));
paymentService.charge(order);
emailService.sendOrderConfirmation(order);
return order;
}
}
Không còn chữ new. OrderService chỉ khai báo "tôi cần gì".
Bước 4: Test. Unit test giờ truyền mock vào constructor, không cần Spring:
@Test
void createOrder_chargesPayment() {
var repo = mock(OrderRepository.class);
var payment = mock(PaymentService.class);
var email = mock(EmailService.class);
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
new OrderService(repo, payment, email).createOrder(sampleRequest());
verify(payment).charge(any());
}
Và gọi thật qua API:
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"productId":1,"quantity":2}'
5. Cách hoạt động
SpringApplication.run(ShopApplication.class)
|
v
1. Component scan package com.example.shop
→ thấy @Service, @Repository, @Controller, @Configuration
|
v
2. Tạo BeanDefinition cho mỗi class (công thức tạo bean)
|
v
3. Sắp xếp theo dependency graph:
PaymentGateway → PaymentService ─┐
EmailService ────────────────────┼→ OrderService → OrderController
OrderRepository ─────────────────┘
|
v
4. Gọi constructor, inject, chạy init callback
|
v
5. ApplicationContext sẵn sàng, mỗi bean singleton có 1 instance
6. So sánh các kiểu injection
| Constructor | Setter | Field (@Autowired trên field) | |
|---|---|---|---|
Field final | Có | Không | Không |
| Thiếu dependency | Lỗi lúc start | Có thể null lúc chạy | Có thể null lúc chạy |
| Unit test không cần Spring | Dễ | Được | Khó (phải reflection) |
| Lộ ra quá nhiều dependency | Thấy ngay (constructor dài) | Ẩn | Ẩn |
| Dùng khi | Mặc định | Dependency tùy chọn | Code cũ, test class |
7. Khi nào nên dùng / không nên dùng
- Nên làm bean: service, repository, client, config, thứ có state dùng chung hoặc cần inject.
- Không nên làm bean: entity, DTO, value object. Chúng được tạo theo từng request, cứ
newbình thường. - Constructor có hơn 5–6 tham số là dấu hiệu class đang làm quá nhiều việc, nên tách.
PaymentService cần một PaymentGateway, nhưng có hai implementation (VNPay, Momo). Spring chọn cái nào?Vậy @Component với @Bean khác nhau chỗ nào? Khi nào dùng cái nào?
Cả hai đều đăng ký bean vào container. Khác nhau ở ai viết code tạo object.
@Component (và @Service...) | @Bean | |
|---|---|---|
| Đặt ở đâu | Trên class | Trên method trong class @Configuration |
| Ai tạo object | Spring gọi constructor | Bạn tự viết code tạo, Spring gọi method |
| Dùng cho | Class do mình viết | Class thư viện (không sửa được source), hoặc cần logic khởi tạo |
@Configuration
public class ClientConfig {
// RestClient là class của Spring, không thể gắn @Component lên nó
@Bean
public RestClient vnpayRestClient(@Value("${payment.vnpay.base-url}") String baseUrl) {
return RestClient.builder().baseUrl(baseUrl).build();
}
}
Tên bean mặc định: với @Component là tên class viết thường chữ đầu (orderService); với @Bean là tên method (vnpayRestClient).
PaymentGateway.Có hai class cùng implement PaymentGateway thì Spring inject cái nào?
Không cái nào cả. Spring báo lỗi lúc start:
Parameter 0 of constructor in PaymentService required a single bean,
but 2 were found:
- momoGateway
- vnpayGateway
Có ba cách giải quyết:
@Component
@Primary // 1. mặc định dùng cái này
public class VnpayGateway implements PaymentGateway { ... }
@Component("momo")
public class MomoGateway implements PaymentGateway { ... }
@Service
public class RefundService {
private final PaymentGateway gateway;
// 2. chỉ định rõ bằng @Qualifier, thắng @Primary
public RefundService(@Qualifier("momo") PaymentGateway gateway) {
this.gateway = gateway;
}
}
@Service
public class PaymentRouter {
private final Map<String, PaymentGateway> gateways;
// 3. lấy tất cả: key = tên bean
public PaymentRouter(Map<String, PaymentGateway> gateways) {
this.gateways = gateways;
}
public PaymentGateway forMethod(String method) {
return gateways.get(method); // "momo", "vnpayGateway"
}
}
Cách 3 rất hay dùng thực tế: thêm gateway mới chỉ cần thêm một class, không sửa PaymentRouter (Open/Closed principle).
Cần 1 bean kiểu PaymentGateway
├─ có @Qualifier? → lấy đúng tên đó
├─ có đúng 1 @Primary? → lấy @Primary
├─ tên tham số khớp tên bean? → lấy bean đó
└─ không thì → NoUniqueBeanDefinitionException
Phỏng vấn hỏi IoC/DI thì trả lời sao?
Q1. IoC và Dependency Injection là gì?
🇻🇳 Tiếng Việt:
IoC là đảo quyền điều khiển: class không tự tạo dependency mà để container tạo và quản lý. DI là cách thực hiện IoC, dependency được truyền vào qua constructor, setter hoặc field. Trong Spring, ApplicationContext scan các class có @Component, tạo bean và inject theo kiểu dữ liệu. Lợi ích chính là loose coupling, dễ thay implementation và dễ unit test bằng mock.
🇬🇧 English:
Inversion of Control means a class does not create its own dependencies; a container creates and wires them. Dependency Injection is how IoC is implemented: dependencies are passed in through the constructor, a setter or a field. In Spring, the ApplicationContext scans @Component classes, creates beans and injects them by type. The main benefits are loose coupling, easy swapping of implementations and easy unit testing with mocks.
Q2. Vì sao nên dùng constructor injection?
🇻🇳 Tiếng Việt:
Field có thể làfinal, object luôn được tạo ở trạng thái đầy đủ, thiếu dependency thì app fail ngay lúc start. Test không cần Spring, chỉ cầnnewvới mock. Constructor dài còn là tín hiệu class đang vi phạm single responsibility.
🇬🇧 English:
Fields can be final, the object is always fully initialized, and a missing dependency fails at startup instead of causing a NullPointerException later. Tests don't need Spring; you just call the constructor with mocks. A long constructor is also a visible signal that the class has too many responsibilities.
Q3. Hai bean cùng kiểu thì xử lý thế nào?
🇻🇳 Tiếng Việt:
Spring báoNoUniqueBeanDefinitionException. Đánh dấu một cái là@Primarylàm mặc định, dùng@Qualifierở chỗ cần cái khác, hoặc injectList/Mapđể lấy tất cả và chọn lúc runtime.
🇬🇧 English:
Spring throws NoUniqueBeanDefinitionException. Mark one as@Primaryto be the default, use@Qualifierwhere you need a specific one, or inject aListorMapof all implementations and pick at runtime, like a strategy pattern.
Cách nhớ ngắn gọn
- IoC = ai tạo object? Container. DI = đưa vào bằng cách nào? Constructor.
- Bean = object do Spring quản lý. Entity/DTO không phải bean.
@Componentcho class của mình,@Beancho class thư viện.- Trùng kiểu:
@Qualifier>@Primary> tên tham số > lỗi.
Tự kiểm tra
Vì sao OrderService tự new PaymentService() lại khó test?
Không thay được bằng mock, test nào cũng chạy logic thanh toán thật. Với DI, test truyền mock qua constructor.
Constructor injection có cần @Autowired không?
Không, nếu class chỉ có một constructor. Có nhiều constructor thì đánh @Autowired lên cái muốn Spring dùng.
Muốn đăng ký RestClient (class của thư viện) làm bean thì dùng gì?
Method @Bean trong class @Configuration.
@Primary và @Qualifier cùng có mặt thì cái nào thắng?
@Qualifier ở điểm inject thắng.
DTO CreateOrderRequest có nên là bean không?
Không. Nó là dữ liệu theo từng request, cứ tạo bình thường.