IoC & Dependency Injection: vì sao không tự new

4 câu hỏi~25 phútTừ khóa: IoC, DI, ApplicationContext, Bean, constructor injection, @Component, @Bean, @Qualifier, @Primary

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 đề

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

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

ConstructorSetterField (@Autowired trên field)
Field finalCóKhôngKhông
Thiếu dependencyLỗi lúc startCó thể null lúc chạyCó thể null lúc chạy
Unit test không cần SpringDễĐượcKhó (phải reflection)
Lộ ra quá nhiều dependencyThấy ngay (constructor dài)ẨnẨn
Dùng khiMặc địnhDependency tùy chọnCode cũ, test class

7. Khi nào nên dùng / không nên dùng

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 ở đâuTrên classTrên method trong class @Configuration
Ai tạo objectSpring gọi constructorBạn tự viết code tạo, Spring gọi method
Dùng choClass do mình viếtClass 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).

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ần new vớ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áo NoUniqueBeanDefinitionException. Đánh dấu một cái là @Primary làm mặc định, dùng @Qualifier ở chỗ cần cái khác, hoặc inject List/Map để lấy tất cả và chọn lúc runtime.

🇬🇧 English:

Spring throws NoUniqueBeanDefinitionException. Mark one as @Primary to be the default, use @Qualifier where you need a specific one, or inject a List or Map of all implementations and pick at runtime, like a strategy pattern.

Cách nhớ ngắn gọn


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.