0

Trọn bộ các annotation cơ bản cần phải biết trong Spring Boot (Phần 1)

"Hãy quay ngược thời gian một chút về thời kỳ 'đồ đá' của Java Enterprise. Nếu bạn đã từng phải vật lộn với những dự án di sản (legacy), từng mệt nhoài khi phải gom góp các thư viện tiện ích rồi tự tay đấu nối hàng ngàn dòng cấu hình XML cồng kềnh, bạn sẽ hiểu cảm giác nghẹt thở khi hệ thống phình to. Rồi Spring Boot xuất hiện, mang theo một cuộc cách mạng thực sự. Và thứ tạo nên 'phép màu' lật ngược thế cờ đó, không gì khác, chính là các Annotation.

Chỉ bằng vài ký tự nhỏ bé bắt đầu với dấu @, hàng trăm dòng code boilerplate (mã lặp) bay màu, các Design Pattern phức tạp được kích hoạt tự động, và luồng Dependency Injection tự do chảy trơn tru trong hệ thống. Nhưng sự ma thuật nào cũng có cái giá của nó. Nếu chỉ dùng theo thói quen copy-paste mà không hiểu rõ cơ chế ngầm, hệ thống của bạn sẽ sớm biến thành một 'hộp đen' đầy rẫy những lỗi khởi tạo không lường trước. Hôm nay, hãy cùng mình đi sâu vào 'giải phẫu' trọn bộ các Annotation cốt lõi nhất, để biến phép màu của Spring Boot thành công cụ nằm trong tầm kiểm soát tuyệt đối của bạn."

Phần I. Core & Cấu hình (Cốt lõi)

1. @SpringBootApplication

@SpringBootApplication là annotation quan trọng nhất và mang tính biểu tượng nhất của Spring Boot. Nó thường được đặt ở class chứa hàm main() để khởi động ứng dụng.

Về bản chất, @SpringBootApplication là một meta-annotation (một annotation được tạo thành từ việc gộp nhiều annotation khác lại với nhau). Việc hiểu sâu về nó đòi hỏi chúng ta phải "mổ xẻ" 3 annotation cốt lõi cấu thành nên nó, cũng như cơ chế tùy biến đi kèm.


Nếu xem mã nguồn của @SpringBootApplication, bạn sẽ thấy nó được cấu tạo chủ yếu từ 3 annotation của Spring Framework:

@SpringBootApplication // Annotation này bao gồm luôn 3 annotation bên trên
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

1.1 @Configuration

Khi bạn gắn @Configuration (hoặc @SpringBootConfiguration) lên một class, Spring sẽ biết rằng class này chứa các phương thức trả về các object cần được quản lý (các @Bean).


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DatabaseConfig {

    // Spring sẽ gọi method này, lấy object trả về và lưu vào IoC Container
    @Bean
    public DataSource dataSource() {
        return new DataSource("localhost", 3306, "my_db");
    }

    @Bean
    public UserRepository userRepository() {
        // Truyền bean dataSource vào userRepository
        return new UserRepository(dataSource()); 
    }
}

Điều kỳ diệu của @Configuration (CGLIB Proxying)

Điểm đặc biệt nhất của @Configuration so với các annotation thông thường là nó sử dụng CGLIB Proxy để bọc class của bạn lại. Điều này đảm bảo tính chất Singleton của các Bean.

Hãy nhìn lại ví dụ trên, ở method userRepository(), chúng ta gọi dataSource(). Nếu đây là Java thuần, mỗi lần gọi dataSource(), một object DataSource mới sẽ được tạo ra. Tuy nhiên, nhờ @Configuration, Spring sẽ can thiệp vào:

  • Nó kiểm tra xem Bean dataSource đã tồn tại trong Container chưa.
  • Nếu đã có, nó sẽ trả về instance cũ thay vì chạy lại đoạn code new DataSource(...).
  • Nhờ đó, cả ứng dụng của bạn sẽ chỉ dùng chung một object DataSource duy nhất.

Những lưu ý bắt buộc (Best Practices)

Để @Configuration hoạt động đúng, class của bạn phải tuân thủ các quy tắc sau:

  • Không được là final: Spring cần tạo class con (subclass) bằng CGLIB để làm Proxy, nên class không được đánh dấu là final.
  • Không được tạo các method @Bean là private hoặc final: Tương tự, Proxy cần có khả năng ghi đè (override) các method này.
  • Phải có constructor mặc định (no-args constructor): Mặc dù các phiên bản Spring mới đã linh hoạt hơn, nhưng việc giữ một constructor rỗng là an toàn nhất để CGLIB có thể khởi tạo.

1.2 @EnableAutoConfiguration

Trong Spring Boot, @EnableAutoConfiguration chính là linh hồn tạo nên sự khác biệt giữa Spring Boot và Spring Framework truyền thống. Nó chịu trách nhiệm cho "phép thuật" tự động cấu hình ứng dụng dựa trên các thư viện (dependencies) mà bạn thêm vào dự án.

Nói một cách đơn giản: Thay vì bạn phải tự tay viết hàng tá các class @Configuration để cài đặt database, web server, hay bảo mật, @EnableAutoConfiguration sẽ "đoán" những gì bạn cần và tự động cấu hình chúng.


Bản chất của "Phép thuật"

Khi ứng dụng khởi chạy, @EnableAutoConfiguration sẽ quét toàn bộ Classpath (các thư viện có trong file pom.xml hoặc build.gradle) để xem bạn đang dùng công nghệ gì.

  • Ví dụ 1: Nếu nó thấy thư viện spring-boot-starter-web và tomcat-embed-core trên classpath, nó sẽ tự động cấu hình một Web Server (Tomcat) và cài đặt DispatcherServlet để bạn có thể viết các REST API ngay lập tức.
  • Ví dụ 2: Nếu nó thấy spring-boot-starter-data-jpa và thư viện driver của MySQL, nó sẽ tự động tạo ra các Bean như DataSource, EntityManagerFactory, và TransactionManager để bạn kết nối cơ sở dữ liệu mà không cần viết code cấu hình.

Cách hoạt động bên dưới (Under the hood)

Thực chất, @EnableAutoConfiguration không phải là ma thuật, nó tuân theo một quy trình rất logic:

  1. Tìm kiếm các cấu hình mặc định: Spring Boot chứa sẵn hàng trăm class @Configuration được viết bởi các kỹ sư của Spring. Nó sẽ tìm danh sách các class này trong file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (ở các bản Spring Boot cũ hơn 2.7 là file spring.factories).

  2. Sàng lọc bằng @Conditional: Không phải class cấu hình nào cũng được nạp. Spring Boot sử dụng các annotation như @ConditionalOnClass (chỉ nạp khi có thư viện này), @ConditionalOnMissingBean (chỉ nạp nếu developer chưa tự định nghĩa Bean này).

  3. Tôn trọng cấu hình của bạn: Nguyên tắc của Spring Boot là "Ý của lập trình viên là cao nhất". Nếu bạn tự viết một @Bean cho DataSource, Spring Boot sẽ lùi lại và không nạp DataSource tự động của nó nữa.

Cách tắt một Auto-Configuration cụ thể (exclude)

Trong thực tế, ngày nay bạn sẽ rất hiếm khi thấy ai viết @EnableAutoConfiguration một cách độc lập. Lý do như các bạn đã biết vì nó đã được gói gọn bên trong annotation @SpringBootApplication mà chúng ta hay đặt ở class hàm main.

Đôi khi, sự thông minh của Spring Boot lại gây rắc rối. Giả sử bạn thêm thư viện JPA vào project nhưng chưa kịp cài đặt thông tin database trong file application.properties. Khi chạy ứng dụng, Spring Boot sẽ cố gắng tự động kết nối DB và báo lỗi crash app (lỗi Failed to configure a DataSource).

Để giải quyết, bạn có thể "tắt" tính năng tự động cấu hình cho một module cụ thể bằng thuộc tính exclude:

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;

// Tắt tự động cấu hình DataSource
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

1.3 @ComponentScan

Trong Spring Boot, @ComponentScan đóng vai trò như một chiếc "radar". Nhiệm vụ của nó là nói cho Spring biết phải đi tìm các class cấu hình và các thành phần (Bean) ở đâu để đưa vào quản lý trong Spring IoC Container.


Nếu @Configuration là nơi bạn tự tay định nghĩa Bean, @EnableAutoConfiguration là nơi Spring tự động nạp Bean từ thư viện, thì @ComponentScan là cách Spring tự động quét và thu thập code do chính bạn viết.

Chiếc radar này tìm kiếm những gì?

Khi được kích hoạt, @ComponentScan sẽ đi qua các thư mục (package) và tìm kiếm các class được gắn một trong các "stereotype annotation" sau để biến chúng thành Bean:

  • @Component: Đánh dấu một class Java thông thường là một Bean.
  • @Service: Đánh dấu tầng xử lý nghiệp vụ (Business Logic).
  • @Repository: Đánh dấu tầng giao tiếp với cơ sở dữ liệu (Data Access Object).
  • @Controller / @RestController: Đánh dấu tầng tiếp nhận các request (Web/API).
  • @Configuration: Các class chứa cấu hình do bạn viết.

(Lưu ý: Thực chất @Service, @Repository, @Controller đều là các lớp con được kế thừa từ @Component).

Cách hoạt động mặc định (Cực kỳ quan trọng)

Giống như @EnableAutoConfiguration, bạn hiếm khi phải tự viết @ComponentScan vì nó đã được tích hợp sẵn bên trong @SpringBootApplication ở class chạy hàm main.

Theo mặc định, @ComponentScan sẽ quét package chứa class khai báo nó và toàn bộ các sub-packages (package con) nằm bên trong.

Ví dụ về cấu trúc chuẩn:

com.myproject.app
 ├── MyApplication.java       <-- Class chứa @SpringBootApplication (@ComponentScan sẽ quét từ đây)
 ├── controller/
 │    └── UserController.java <-- Sẽ được quét (vì là package con)
 ├── service/
 │    └── UserService.java    <-- Sẽ được quét
 └── repository/
      └── UserRepository.java <-- Sẽ được quét

Lỗi "Kinh điển" của người mới học

Dựa vào quy tắc mặc định ở trên, một lỗi cực kỳ phổ biến mà các lập trình viên mới gặp phải là báo lỗi "Field required a bean of type '...' that could not be found" (Không tìm thấy Bean). Lý do thường là vị trí đặt class main bị sai.

Ví dụ về cấu trúc LỖI:

com.myproject.app
 ├── controller/
 │    └── UserController.java <-- LỖI: Sẽ KHÔNG được quét!
 ├── service/
 │    └── UserService.java    <-- LỖI: Sẽ KHÔNG được quét!
 └── main/
      └── MyApplication.java  <-- Class Main nằm "sâu" trong một package riêng. Radar chỉ quét thư mục 'main' và các thư mục con của 'main'.

Cách khắc phục: Luôn đặt class chứa @SpringBootApplication ở package ngoài cùng (root package) của dự án.

Cách cấu hình thủ công @ComponentScan (basePackages)

Nếu bạn bắt buộc phải để các component ở một package hoàn toàn khác, hoặc bạn đang làm việc trong một dự án đa module (multi-module) nơi các class nằm ở ngoài root package, bạn có thể chỉ định rõ ràng cho radar biết cần quét ở đâu bằng thuộc tính basePackages:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

// Radar sẽ quét package mặc định VÀ thêm một package bên ngoài dự án
@SpringBootApplication
@ComponentScan(basePackages = {"com.myproject.app", "com.external.library.components"})
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

2. @Bean

Trong Spring Boot, @Bean là một annotation được sử dụng ở cấp độ phương thức (method-level). Nó mang thông điệp báo cho Spring IoC Container biết rằng: "Hãy chạy phương thức này, lấy object (đối tượng) mà nó trả về và lưu vào Container để quản lý".


Để khai báo @Bean, phương thức của bạn bắt buộc phải nằm bên trong một class cấu hình (được đánh dấu bằng @Configuration hoặc @Component - khai báo bên dưới mỗi annotation này cũng sẽ có sự khác nhau nhất định về cách Spring quản lý Bean, chúng ta sẽ tìm hiểu ở phần sau nhé).

Cú pháp sử dụng cơ bản:

@Configuration
public class NetworkConfig {

    // Spring sẽ gọi hàm này, lấy đối tượng RestTemplate và đặt tên Bean là "restTemplate"
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

Theo mặc định, tên của phương thức sẽ chính là tên của Bean trong Container (như ví dụ trên, tên Bean là restTemplate).

2.1. Các thuộc tính hữu ích của @Bean

Bản thân annotation @Bean cung cấp một số thuộc tính để bạn kiểm soát chính xác cách Bean đó được tạo ra và quản lý:

a. name (hoặc value) - Đổi tên Bean

Nếu bạn không muốn sử dụng tên phương thức làm tên Bean, hoặc muốn gán nhiều tên (alias) cho cùng một Bean, bạn sử dụng thuộc tính name.

// Đặt một tên mới
@Bean(name = "myCustomClient")
public HttpClient httpClient() {
    return new HttpClient();
}

// Đặt nhiều tên (aliases) cho cùng một Bean
@Bean(name = {"databaseConfig", "dbConfig", "primaryDb"})
public DataSource dataSource() {
    return new DataSource();
}

b. initMethod - Can thiệp ngay sau khi tạo

Thuộc tính này cho phép bạn chỉ định tên của một phương thức bên trong object. Spring sẽ tự động gọi phương thức này ngay sau khi đối tượng được khởi tạo xong và mọi dependencies đã được truyền vào. Thường dùng để mở kết nối, nạp dữ liệu ban đầu,...

@Bean(initMethod = "startConnection")
public RedisClient redisClient() {
    return new RedisClient(); 
    // Sau khi new RedisClient() chạy xong, Spring sẽ gọi tiếp redisClient.startConnection()
}

c. destroyMethod - Can thiệp trước khi hủy

Ngược lại với initMethod, thuộc tính này chỉ định một phương thức sẽ được gọi ngay trước khi Bean bị hủy (thường là lúc ứng dụng Spring Boot tắt). Dùng để đóng kết nối database, giải phóng bộ nhớ, xóa file tạm,...

// Spring sẽ tự động gọi fileManager.closeAllFiles() trước khi app tắt
@Bean(destroyMethod = "closeAllFiles")
public FileManager fileManager() {
    return new FileManager();
}

(Lưu ý: Với các class có sẵn hàm close() hoặc shutdown(), Spring thường tự động nhận diện và biến chúng thành destroyMethod mặc định).

2.2 Dependency Injection giữa các @Bean

Điều gì xảy ra nếu object bạn đang tạo cần một đối tượng khác để hoạt động? Bạn chỉ cần khai báo đối tượng cần thiết dưới dạng tham số của phương thức @Bean. Spring sẽ tự động tìm Bean đó trong Container và truyền (inject) vào cho bạn.

@Configuration
public class PaymentConfig {

    // Tạo Bean thứ nhất
    @Bean
    public PaymentGateway paymentGateway() {
        return new StripePaymentGateway();
    }

    // Tạo Bean thứ hai cần sử dụng Bean thứ nhất
    @Bean
    public PaymentService paymentService(PaymentGateway gateway) {
        // Spring tự động truyền (inject) paymentGateway vào tham số gateway
        return new PaymentService(gateway); 
    }
}

Phần II: Dependency Injection (Các Stereotype)

1. @Component

Trong Spring Boot, @Component là một annotation cơ bản nhất dùng để đánh dấu một class Java là một Spring Bean. Khi bạn gắn @Component lên đầu một class, bạn đang gửi một thông điệp tới Spring: "Đây là một class quan trọng của ứng dụng, hãy tự động khởi tạo nó (tạo object) và đưa vào IoC Container để quản lý giúp tôi".


import org.springframework.stereotype.Component;

// Đánh dấu class này là một Bean
@Component
public class EmailValidator {
    
    public boolean isValid(String email) {
        return email != null && email.contains("@");
    }
}
  • Khi ứng dụng chạy, Spring sẽ tự động gọi new EmailValidator() và lưu đối tượng đó vào bộ nhớ (Container). Sau này, nếu bất kỳ nơi nào trong ứng dụng cần dùng đến EmailValidator, Spring sẽ tự động lấy đối tượng đã tạo ra và truyền vào (quá trình này gọi là Dependency Injection).

Nếu bạn nhớ lại chiếc "radar" @ComponentScan ở phần trước, thì @Component chính là cái "bảng tên" phát ra tín hiệu để radar đó nhận diện và thu thập.

Như bạn thấy, cả @Bean@Component đều được sử dụng để tạo Bean, nhưng mục đích sử dụng lại khác nhau. @Bean được sử dụng cho phương thức, @Component được sử cho class (điều này đã được thể hiện rõ ràng ở trong mã nguồn của cả hai, do vậy chúng không thể đổi vị trí cho nhau). Tóm lại "Quy tắc vàng" là:

  • Bạn muốn nói với Spring: "Tôi đã viết xong Class này rồi, anh hãy tự khởi tạo nó đi" \rightarrow Đặt @Component (hoặc @Service, @Repository, @Controller) lên đầu Class.
  • Bạn muốn nói với Spring: "Anh hãy gọi Phương thức này, object mà phương thức này return ra anh hãy giữ lấy làm Bean nhé" \rightarrow Đặt @Bean lên đầu Phương thức.

Khai báo @Bean trong @Component hay @Configuration

Mặc dù bạn có thể khai báo phương thức @Bean bên trong một @Component (gọi là Lite Mode). nhưng tốt nhất để tránh lỗi ngầm định là luôn đặt @Bean bên trong class @Configuration (gọi là Full Mode)

Còn nếu các Bean của bạn có phụ thuộc gọi chéo lẫn nhau trong cùng một class cấu hình, bắt buộc phải dùng @Configuration. Lí do cho những điều này là bởi vì:

  • @Configuration (Full Mode): Spring sử dụng CGLIB Proxy. Các lời gọi method @Bean bên trong class sẽ luôn đảm bảo trả về Singleton Bean (chỉ một instance duy nhất được tạo ra - cơ chế mà mình đã nhắc ở trên).

  • @Component (Lite Mode): Không có Proxy. Nếu bạn gọi method @Bean từ một method khác trong cùng class, nó sẽ hoạt động như Java thuần (tạo ra object mới), làm phá vỡ tính Singleton và gây rò rỉ bộ nhớ hoặc lỗi logic.

Các "bản sao" của @Component (Stereotype Annotations)

Trong thực tế, bạn sẽ thấy người ta ít khi dùng trực tiếp @Component. Thay vào đó, Spring cung cấp 3 annotation khác là "con" của @Component (gọi chung là stereotype annotations) để phân chia rõ ràng các tầng kiến trúc trong ứng dụng:

1.1 @Controller / @RestController

Trong Spring Boot, @Controller@RestController đều là các annotation thuộc họ @Component. Chức năng chung của chúng là đánh dấu một class trở thành "Người tiếp tân" - chuyên đứng ở cửa ngõ ứng dụng để tiếp nhận và xử lý các HTTP Request (như GET, POST, PUT, DELETE) từ người dùng hoặc client gửi tới.

@Controller (Kiến trúc MVC truyền thống)

@Controller được thiết kế cho mô hình ứng dụng web truyền thống (Server-Side Rendering), nơi server chịu trách nhiệm tạo ra giao diện HTML và gửi về cho trình duyệt.

Khi một phương thức trong @Controller trả về một chuỗi (String), Spring sẽ không gửi chuỗi đó cho người dùng. Thay vào đó, nó nhờ một thành phần gọi là ViewResolver đi tìm một file giao diện (HTML, Thymeleaf, JSP) có tên tương ứng để hiển thị.

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class WebController {

    @GetMapping("/home")
    public String showHomePage() {
        // Spring sẽ đi tìm file có tên "index.html" (hoặc index.jsp/index.html tùy cấu hình)
        // trong thư mục resources/templates hoặc resources/static để trả về cho trình duyệt.
        // nếu không thấy sẽ trả về trang 404
        return "index"; 
    }
}

@RestController (Kiến trúc RESTful API)

Trong các ứng dụng hiện đại, Backend (Spring Boot) thường tách biệt hoàn toàn với Frontend (React, Vue, Angular, hoặc Mobile App). Lúc này, Backend không trả về giao diện HTML nữa mà chỉ trả về dữ liệu thô (thường là định dạng JSON). Đây chính là đất diễn của @RestController.

Bản chất của @RestController là sự kết hợp của 2 annotation:

  • @Controller: Đánh dấu đây là class xử lý request.
  • @ResponseBody: Ra lệnh cho Spring rằng: "Bất cứ thứ gì hàm này return, hãy tự động chuyển đổi nó thành JSON (hoặc XML) và gửi thẳng vào phần Body của HTTP Response, KHÔNG đi tìm file HTML nào cả".

(Chính vì thế, với @Controller, nếu bạn muốn trả về dữ liệu JSON thì bắt buộc phải khai báo kèm thêm @ResponseBody [trên class hoặc trên từng hàm]. Còn nếu dùng @RestController thì @ResponseBody đã được tích hợp sẵn bên trong nó rồi, bạn chỉ việc return dữ liệu là xong)

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ApiController {

    @GetMapping("/api/user")
    public User getUserData() {
        User user = new User("John Doe", 25);
        
        // Spring (nhờ thư viện Jackson) sẽ tự động biến object 'user' thành:
        // {"name": "John Doe", "age": 25} và gửi về cho client.
        return user; 
    }
}

Bảng so sánh

Tiêu chí @Controller @RestController
Mục đích chính Trả về các trang giao diện (Web Pages). Trả về dữ liệu (RESTful APIs).
Giá trị trả về mặc định Tên của View (giao diện HTML, Thymeleaf, JSP). Dữ liệu dạng JSON hoặc XML.
Cấu tạo Chỉ là @Controller Là sự kết hợp của @Controller + @ResponseBody
Muốn trả về JSON thì sao? Phải gắn thêm annotation @ResponseBody lên từng phương thức. Được mặc định sẵn cho tất cả các phương thức trong class.
Ứng dụng thực tế Các web app kiểu cũ, web nội bộ (admin dashboard) render bằng Thymeleaf. Làm Backend API cho React, Vue, App Mobile (iOS, Android).

1.2 @Service

Trong Spring Boot, @Service là một annotation thuộc họ @Component. Nhiệm vụ duy nhất của nó là đánh dấu một class đóng vai trò là Tầng xử lý nghiệp vụ (Business Logic Layer) trong kiến trúc của ứng dụng.

Nếu @Controller là "Người tiếp tân" đón khách, thì @Service chính là "Nhà bếp" hoặc "Bộ phận xử lý" – nơi thực hiện mọi tính toán phức tạp, kiểm tra đúng sai và đưa ra quyết định.


Tại sao không dùng @Component cho nhanh?

Về mặt kỹ thuật, Spring đối xử với class gắn @Service giống hệt 100% so với class gắn @Component (nghĩa là nó cũng khởi tạo Bean và đưa vào Container).

  • Tính ngữ nghĩa (Semantics / Readability): Khi một lập trình viên khác (hoặc chính bạn sau 3 tháng) nhìn vào class có chữ @Service, họ sẽ hiểu ngay đây là não bộ của dự án, nơi chứa các quy tắc kinh doanh.

  • Tương lai mở rộng: Mặc dù hiện tại @Service chưa có tính năng ẩn nào (như @Repository), nhưng Spring Framework hoàn toàn có thể bổ sung các tính năng đặc thù dành riêng cho tầng service trong tương lai dựa trên annotation này.

Quy tắc "Vàng" khi code: Thin Controller - Fat Service

Một lỗi rất phổ biến của người mới học là viết toàn bộ code tính toán, if/else trực tiếp bên trong @RestController. Điều này là cực kỳ cấm kỵ trong thiết kế phần mềm.

Quy tắc chuẩn:

  • Controller phải "ốm" (Thin): Chỉ làm nhiệm vụ check tham số đầu vào, gọi Service, và trả ra HTTP Status (200 OK, 400 Bad Request).

  • Service phải "mập" (Fat): Gánh vác toàn bộ não bộ của ứng dụng. Mọi logic phức tạp, kết nối các hệ thống khác, gửi email,... đều phải nằm ở đây. Điều này giúp bạn dễ dàng viết Unit Test cho logic mà không cần phải giả lập môi trường Web/HTTP.

1.3 @Repository

Trong Spring Boot, @Repository là mảnh ghép cuối cùng trong bộ ba kiến trúc phân tầng cơ bản (cùng với @Controller@Service). Nó được dùng để đánh dấu một class là Tầng truy cập dữ liệu (Data Access Object - DAO).

Nếu @Controller là "Người tiếp tân" đón khách, @Service là "Nhà bếp" xử lý logic, thì @Repository chính là "Thủ kho" - người duy nhất có chìa khóa để đi vào kho chứa (Cơ sở dữ liệu - Database) để lấy ra, cất vào, hoặc xóa dữ liệu.


Nhiệm vụ duy nhất của class được gắn @Repository là giao tiếp trực tiếp với Database. Tại đây, bạn sẽ viết các câu lệnh SQL hoặc sử dụng các công cụ như Hibernate/JPA để thực hiện các thao tác CRUD (Create, Read, Update, Delete).

import org.springframework.stereotype.Repository;
import org.springframework.jdbc.core.JdbcTemplate;

@Repository
public class UserRepository {

    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    // Thủ kho thực hiện lệnh: Lấy thông tin user từ DB
    public User findById(Long id) {
        String sql = "SELECT * FROM users WHERE id = ?";
        return jdbcTemplate.queryForObject(sql, new Object[]{id}, new UserRowMapper());
    }
}

"Siêu năng lực" ẩn giấu: Exception Translation

Giống như @Service, @Repository về bản chất cũng là một @Component và sẽ được Spring quét để tạo Bean.

Tuy nhiên, KHÔNG BAO GIỜ được dùng @Component để thay thế cho @Repository. Khác với @Service (chỉ mang ý nghĩa phân loại), @Repository được Spring trang bị một tính năng ngầm cực kỳ mạnh mẽ gọi là Exception Translation (Dịch lỗi).

Vấn đề:

Mỗi loại Database (MySQL, PostgreSQL, Oracle) ném ra các lỗi Java (Exceptions) khác nhau khi có sự cố. Ví dụ, khi bạn cố lưu một User có email bị trùng, MySQL có thể ném ra SQLException, Hibernate ném ra ConstraintViolationException. Nếu tầng @Service phải dùng try-catch để bắt hàng tá loại lỗi cụ thể của từng DB này, code sẽ rất rối và bị phụ thuộc chặt chẽ vào loại DB đang dùng.

Cách @Repository giải quyết:

Khi bạn đánh dấu class bằng @Repository, Spring sẽ tự động bắt tất cả các lỗi đặc thù của Database/Hibernate đó và "dịch" (translate) chúng thành một hệ thống lỗi chung chuẩn hóa của riêng Spring gọi là DataAccessException.

Nhờ đó, tầng @Service của bạn chỉ cần biết bắt một loại lỗi chung (DataAccessException), dù ngày mai bạn có đổi Database từ MySQL sang Oracle thì code ở tầng Service cũng không cần phải sửa lại.

Thực tế hiện nay: Spring Data JPA

Nếu bạn đang học Spring Boot phiên bản mới, có thể bạn sẽ thấy lạ vì... bạn hầu như không bao giờ phải viết class và gắn chữ @Repository lên đầu nữa!

Ngày nay, với sự xuất hiện của Spring Data JPA, bạn chỉ cần tạo một interface và kế thừa (extend) JpaRepository. Bạn thậm chí không cần viết code triển khai, cũng không cần gắn @Repository (mặc dù gắn vào cũng không sao).

import org.springframework.data.jpa.repository.JpaRepository;

// Không cần gắn @Repository, Spring Data tự hiểu và tự động tạo code ngầm
public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // Chỉ cần khai báo tên hàm, Spring Boot tự động sinh ra câu lệnh SQL "SELECT * FROM product WHERE name = ?"
    List<Product> findByName(String name);
}

Dù bạn không nhìn thấy, nhưng ở bên dưới nền (under the hood), Spring Boot vẫn tự động tạo ra một class triển khai cái interface này, tự động gắn @Repository vào class đó, và áp dụng cơ chế "Dịch lỗi" (Exception Translation) như đã nói ở trên.

2. @Autowired

Trong Spring Boot, @Autowired là trái tim của cơ chế Dependency Injection (Tiêm phụ thuộc).

Nếu @Component, @Service, @Bean làm nhiệm vụ tạo ra đối tượng và ném vào kho chứa (IoC Container), thì @Autowired chính là câu lệnh để lấy đối tượng đó từ kho ra để sử dụng.

Bản chất của @Autowired là tìm kiếm Bean theo kiểu dữ liệu (by Type). Khi thấy annotation này, Spring sẽ lục lọi trong Container xem có Bean nào khớp với kiểu dữ liệu đang yêu cầu hay không để "lắp" vào.

3. @Primary

Trong Spring Boot, @Primary là tấm thẻ "Ưu tiên số 1" dành cho các Bean.Nó được sinh ra để giải quyết một bài toán rất cụ thể trong quá trình Dependency Injection: "Khi có nhiều Bean cùng loại, tôi nên chọn ai?".


Dưới đây là chi tiết về cách hoạt động, cách sử dụng và các trường hợp thực tế nên áp dụng @Primary.

Bài toán: Sự bối rối của Spring (NoUniqueBeanDefinitionException)

Giả sử ứng dụng của bạn có một Interface là MessageService để gửi thông báo cho khách hàng, và bạn có 2 class triển khai (implement) Interface này:

public interface MessageService {
    void sendMessage(String msg);
}

@Service
public class EmailService implements MessageService { 
    // Gửi qua Email 
}

@Service
public class SmsService implements MessageService { 
    // Gửi qua SMS 
}

Bây giờ, tại một Controller, bạn yêu cầu Spring tiêm (@Autowired) một MessageService vào:

@RestController
public class NotificationController {
    
    // SPRING BÁO LỖI: NoUniqueBeanDefinitionException
    @Autowired
    private MessageService messageService; 
}

Lý do lỗi: Spring tìm thấy cả EmailService và SmsService đều là MessageService. Nó không thể tự quyết định nên lấy cái nào để tiêm vào, nên nó sẽ đánh sập ứng dụng để bắt bạn phải làm rõ.

Cách giải quyết bằng @Primary

Bạn sử dụng @Primary để đánh dấu một Bean làm mặc định. Khi Spring bị bối rối, nó sẽ ưu tiên lấy Bean có gắn @Primary.

@Service
@Primary // Đánh dấu Email là phương thức gửi mặc định
public class EmailService implements MessageService { ... }

@Service
public class SmsService implements MessageService { ... }

Lúc này, ở NotificationController, khi bạn gọi @Autowired MessageService, Spring sẽ không hỏi lại nữa mà tự động tiêm EmailService vào.

Dùng @Primary với phương thức @Bean

Không chỉ dùng được trên các class (@Component, @Service), @Primary còn dùng được trên các phương thức @Bean bên trong class @Configuration.

Điều này cực kỳ hữu ích khi bạn phải cấu hình nhiều kết nối Cơ sở dữ liệu (Database) trong cùng một dự án.

@Configuration
public class DatabaseConfig {

    // Cấu hình Database chính (Master - Ghi dữ liệu)
    @Bean
    @Primary // Bắt buộc phải có nếu bạn khai báo 2 DataSource
    public DataSource masterDataSource() {
        return new HikariDataSource(...);
    }

    // Cấu hình Database phụ (Slave - Chỉ đọc)
    @Bean
    public DataSource replicaDataSource() {
        return new HikariDataSource(...);
    }
}

Nhờ có @Primary, tất cả các phần trong Spring Data JPA (trừ khi được chỉ định rõ ràng) sẽ tự động sử dụng masterDataSource để làm việc.

Khi nào thì dùng @Primary trong thực tế?

  1. Chiến lược mặc định (Default Strategy): Khi bạn có một Interface có nhiều cách triển khai, nhưng 90% thời gian hệ thống sẽ dùng 1 cách cụ thể. Hãy gắn @Primary cho cái 90% đó để code ở nơi khác đỡ phải viết @Qualifier nhiều lần.

  2. Ghi đè cấu hình mặc định của Spring Boot: Spring Boot tự động tạo ra rất nhiều Bean (ví dụ: ObjectMapper để parse JSON). Nếu bạn muốn tạo một ObjectMapper của riêng mình với định dạng ngày tháng khác, bạn chỉ cần tạo @Bean và gắn @Primary, Spring sẽ vứt bỏ Bean mặc định của nó và dùng Bean của bạn.

4. @Qualifier

Trong Spring Boot, nếu @Primary là tấm thẻ "Ưu tiên mặc định", thì @Qualifier chính là "Ngón tay chỉ đích danh".

Annotation này được sử dụng trong quá trình Dependency Injection để nói với Spring một cách chính xác tuyệt đối rằng: "Giữa một rừng Bean cùng kiểu dữ liệu, tôi muốn lấy chính xác cái Bean có cái tên này!".

Vấn đề: Khi @Primary không giải quyết được tất cả

Hãy tưởng tượng bạn đang xây dựng một hệ thống thanh toán có hỗ trợ nhiều cổng khác nhau. Bạn có một Interface chung và 2 class triển khai:

public interface PaymentService {
    void pay();
}

@Service // Tên Bean ngầm định của class này là "momoPayment"
@Primary // Đặt Momo làm mặc định cho toàn hệ thống
public class MomoPayment implements PaymentService { ... }

@Service // Tên Bean ngầm định của class này là "vnpayPayment"
public class VnpayPayment implements PaymentService { ... }

Tại một module đặc biệt (ví dụ: VipCheckoutService), bạn bắt buộc phải dùng VNPay để thanh toán. Nếu bạn chỉ dùng @Autowired, Spring sẽ tự động lấy MomoPayment truyền vào (vì nó có @Primary).

Đây chính là lúc bạn phải dùng đến @Qualifier để "ghi đè" (override) luật mặc định đó.

Cách sử dụng @Qualifier

Bạn sẽ đặt @Qualifier đi kèm với nơi bạn yêu cầu tiêm Bean (thường là ở Constructor). Tham số truyền vào @Qualifier chính là tên của Bean mà bạn muốn lấy.

Lưu ý: Nếu bạn không tự đặt tên cho Bean (bằng thuộc tính name hoặc value), Spring sẽ tự động lấy tên class và viết thường chữ cái đầu tiên để làm tên Bean.

Sử dụng với Constructor Injection (Khuyên dùng):

@Service
public class VipCheckoutService {

    private final PaymentService paymentService;

    // Ép Spring phải lấy Bean có tên là "vnpayPayment", bỏ qua mọi quy tắc @Primary
    @Autowired
    public VipCheckoutService(@Qualifier("vnpayPayment") PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public void process() {
        paymentService.pay(); // Chắc chắn sẽ chạy code của VNPay
    }
}

Sử dụng với Field Injection

@Service
public class StandardCheckoutService {

    @Autowired
    @Qualifier("vnpayPayment") // Tiêm trực tiếp vào biến
    private PaymentService paymentService;
}

Dùng @Qualifier kết hợp với @Bean

@Qualifier cực kỳ hữu dụng khi bạn làm việc với các class @Configuration có cấu hình nhiều Bean cùng loại (ví dụ: kết nối 2 database, cấu hình 2 RestTemplate khác nhau).

Bước 1: Khai báo các Bean và đặt tên rõ ràng

@Configuration
public class NetworkConfig {

    @Bean(name = "internalRestTemplate")
    public RestTemplate internalClient() {
        return new RestTemplate(); // Cấu hình timeout ngắn cho mạng nội bộ
    }

    @Bean(name = "externalRestTemplate")
    public RestTemplate externalClient() {
        return new RestTemplate(); // Cấu hình proxy, timeout dài cho mạng bên ngoài
    }
}

Bước 2: Chỉ đích danh khi sử dụng

@Service
public class ThirdPartyApiService {

    private final RestTemplate restTemplate;

    // Yêu cầu lấy đúng Bean giao tiếp ra bên ngoài
    public ThirdPartyApiService(@Qualifier("externalRestTemplate") RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }
}

Tóm tắt Quyền lực của @Qualifier

  1. Độ ưu tiên cao nhất: Khi một Bean được chỉ định bởi @Qualifier, nó sẽ bỏ qua mọi thiết lập của @Primary.
  2. Khớp theo tên (By Name): Trong khi @Autowired tìm Bean theo kiểu dữ liệu (By Type), thì @Qualifier thu hẹp phạm vi lại, ép Spring phải tìm chính xác theo tên (By Name).

5. @Lazy

Trong Spring Boot, theo mặc định, tất cả các Spring Bean (Singleton) đều được khởi tạo ngay lập tức (Eager Initialization) khi ứng dụng vừa khởi động.

Điều này giống như việc một nhà hàng nấu sẵn tất cả các món trong menu ngay từ lúc mở cửa. Tuy nhiên, đôi khi có những món rất ít khách gọi, việc nấu sẵn sẽ gây lãng phí bộ nhớ và làm chậm thời gian mở cửa.

@Lazy được sinh ra để giải quyết vấn đề này. Khi sử dụng @Lazy, Spring sẽ trì hoãn (delay) việc tạo Bean cho đến khi có một đoạn code nào đó thực sự gọi đến nó lần đầu tiên.

Dưới đây mình sẽ đề cập 2 công dụng lớn nhất và cách sử dụng @Lazy trong thực tế.

Ứng dụng số 1: Tối ưu thời gian khởi động (Startup Time)

Nếu ứng dụng của bạn có những Bean chứa logic khởi tạo cực kỳ nặng (như đọc hàng ngàn dòng file Excel, kết nối đến các hệ thống bên ngoài chậm chạp) nhưng lại ít khi được sử dụng, bạn có thể gắn @Lazy để bỏ qua nó lúc khởi động.

Cách 1: Gắn trên class (Tạo bằng @Component / @Service)

import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

@Service
@Lazy // Spring sẽ bỏ qua class này lúc khởi động
public class HeavyReportService {
    
    public HeavyReportService() {
        System.out.println("Đang khởi tạo HeavyReportService... Mất 10 giây!");
    }
    
    public void generateReport() {
        // ...
    }
}

Cách 2: Gắn trên phương thức (Tạo bằng @Bean)

@Configuration
public class AppConfig {

    @Bean
    @Lazy
    public PdfGenerator pdfGenerator() {
        return new PdfGenerator();
    }
}

💡 Lưu ý: Nếu một Bean bình thường (không Lazy) gọi (@Autowired) một Bean @Lazy, thì Bean Lazy đó vẫn sẽ bị khởi tạo ngay từ đầu. Để @Lazy có tác dụng thực sự trong trường hợp này, bạn phải gắn thêm @Lazy vào ngay vị trí tiêm (chỗ gọi @Autowired hoặc tham số Constructor)

Giải thích rõ hơn tại sao lại như vậy:

Giả sử bạn có HeavyReportService đã được đánh dấu là @Lazy trên đầu class. Mục đích của bạn là muốn nó "ngủ yên" lúc khởi động.

Nhưng ở một class khác là DashboardController (một class bình thường, khởi động ngay lập tức), bạn lại gọi nó vào:

@RestController
public class DashboardController {
    
    @Autowired
    private HeavyReportService reportService; 
    // VẤN ĐỀ Ở ĐÂY: Vì Controller này khởi động ngay lập tức, 
    // nó bắt buộc phải "lôi" cái ReportService dậy để nạp vào biến này. 
    // Thế là công sức gắn @Lazy ở class ReportService đổ sông đổ biển.
}

Cách sửa chuẩn xác: Bạn phải gắn @Lazy ở cả chỗ gọi nó.

@RestController
public class DashboardController {
    
    @Autowired
    @Lazy // Dặn Spring: "Đừng tạo object thật vội, cứ quăng cho tôi 1 cái vỏ (Proxy) vào đây là được"
    private HeavyReportService reportService; 
}

Khi bạn thêm @Lazy vào vị trí này, Spring Boot sẽ chỉ nạp một "bản nháp" (Proxy) vào biến reportService. Cái HeavyReportService thật sự vẫn sẽ được trì hoãn khởi tạo cho đến khi bạn chạy một hàm thực tế như reportService.generate()

Ứng dụng số 2: "Cứu cánh" cho lỗi Vòng lặp phụ thuộc (Circular Dependency)

Đây là trường hợp bạn sẽ sử dụng @Lazy nhiều nhất trong các dự án thực tế.

Vấn đề: OrderService cần gọi PaymentService để tính tiền. Ngược lại, PaymentService lại cần gọi OrderService để cập nhật trạng thái đơn hàng.

  1. Spring cố tạo OrderService, thấy thiếu PaymentService.
  2. Spring chạy đi tạo PaymentService, lại thấy thiếu OrderService.
  3. Spring bị kẹt trong vòng lặp vô tận và đánh sập ứng dụng (Lỗi BeanCurrentlyInCreationException hoặc cảnh báo The dependencies of some of the beans in the application context form a cycle).

Cách giải quyết bằng @Lazy:

  • Bạn chỉ cần gắn @Lazy vào vị trí Tiêm (Injection) ở một trong hai class. Khi thấy @Lazy ở tham số truyền vào, Spring sẽ không đi tìm class thật nữa. Thay vào đó, nó tạo ra một Proxy (đối tượng thế thân ảo) để truyền vào cho OrderService dùng tạm, qua đó phá vỡ vòng lặp. Đến khi nào OrderService thực sự gọi một hàm của PaymentService, object thật mới được tạo ra.
@Service
public class OrderService {
    private final PaymentService paymentService;

    // Gắn @Lazy ở phía Constructor để phá vòng lặp
    public OrderService(@Lazy PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

@Service
public class PaymentService {
    private final OrderService orderService;

    public PaymentService(OrderService orderService) {
        this.orderService = orderService;
    }
}

Cấu hình Lazy toàn hệ thống (Global Lazy)

Nếu dự án của bạn quá lớn, mất đến 3-5 phút để chạy xong ứng dụng ở máy local, bạn có thể bật chế độ @Lazy cho toàn bộ hệ thống. Điều này giúp ứng dụng khởi động chỉ trong vài giây (cực kỳ hữu ích cho môi trường Dev).

# Yêu cầu Spring áp dụng @Lazy cho tất cả các Bean
spring.main.lazy-initialization=true

Khi nào KHÔNG nên dùng @Lazy?

Dù có vẻ rất tuyệt vời để tăng tốc độ bật ứng dụng, nhưng các chuyên gia khuyến cáo không nên lạm dụng @Lazy trên môi trường Production.

Tiêu chí Khởi tạo Eager (Mặc định) Khởi tạo Lazy (@Lazy)
Phát hiện lỗi (Fail-fast) Tốt. Nếu bạn cấu hình sai Bean, thiếu kết nối Database, ứng dụng sẽ sập ngay lúc khởi động để bạn biết mà sửa. Kém. Ứng dụng khởi động thành công, nhưng đến khi khách hàng bấm vào tính năng đó thì app mới sập.
Hiệu năng khi sử dụng Yêu cầu đầu tiên của người dùng sẽ được phản hồi cực nhanh vì mọi thứ đã sẵn sàng. Yêu cầu đầu tiên của người dùng sẽ bị chậm (độ trễ cao) vì hệ thống lúc đó mới lạch cạch đi tạo đối tượng.

Phần III. Web & REST API (Spring MVC)

1. @RequestMapping

Trong Spring Boot, @RequestMapping đóng vai trò là một "người điều hướng" (Router). Nhiệm vụ của nó là ánh xạ (map) một địa chỉ URL từ HTTP Request của người dùng (ví dụ: https://domain.com/api/users) tới một phương thức (method) Java cụ thể trong Controller để xử lý yêu cầu đó.

Cách sử dụng cơ bản (Kết hợp Class-level và Method-level)

Cách dùng phổ biến nhất của @RequestMapping là đặt nó ở cả trên đầu Class (để tạo tiền tố URL chung - Base URL) và trên Method (để tạo đường dẫn chi tiết).

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users") // Base URL cho toàn bộ class này
public class UserController {

    // Đường dẫn thực tế sẽ là: GET/POST/PUT... /api/users/profile
    @RequestMapping("/profile") 
    public String getUserProfile() {
        return "Thông tin User";
    }

    // Đường dẫn thực tế sẽ là: GET/POST/PUT... /api/users/settings
    @RequestMapping("/settings") 
    public String getUserSettings() {
        return "Cài đặt User";
    }
}

Chỉ định phương thức HTTP (HTTP Methods)

Nếu bạn chỉ viết @RequestMapping("/profile") như trên, phương thức đó sẽ chấp nhận tất cả các loại HTTP Method (GET, POST, PUT, DELETE, PATCH). Điều này rất nguy hiểm và không đúng chuẩn RESTful API.

Để giới hạn chỉ cho phép một loại Request cụ thể, bạn phải sử dụng thuộc tính method:

import org.springframework.web.bind.annotation.RequestMethod;

// Chỉ nhận HTTP GET request
@RequestMapping(value = "/profile", method = RequestMethod.GET)
public String getProfile() {
    return "Lấy thông tin";
}

// Chỉ nhận HTTP POST request
@RequestMapping(value = "/profile", method = RequestMethod.POST)
public String createProfile() {
    return "Tạo mới thông tin";
}

2. @GetMapping, @PostMapping, @PutMapping, @DeleteMapping

Bộ tứ @GetMapping, @PostMapping, @PutMapping, và @DeleteMapping là những annotation sinh ra để giải quyết sự cồng kềnh của @RequestMapping.

Được giới thiệu từ phiên bản Spring 4.3, chúng là các biến thể rút gọn (shortcut) được gắn cứng (hardcode) sẵn một loại HTTP Method cụ thể. Việc sử dụng chúng không chỉ giúp code ngắn gọn, sạch sẽ hơn mà còn ép ứng dụng của bạn tuân thủ chặt chẽ theo tiêu chuẩn thiết kế RESTful API (tương ứng với các thao tác CRUD: Create - Read - Update - Delete).

Bảng đối chiếu tiêu chuẩn RESTful API

Annotation Tương đương với Thao tác CRUD Tính lũy đẳng (Idempotent)* Mục đích sử dụng chính
@PostMapping @RequestMapping(method = POST) C (Create) KHÔNG Tạo ra một tài nguyên (bản ghi) mới. Dữ liệu thường được để trong Body của request.
@GetMapping @RequestMapping(method = GET) R (Read) Lấy dữ liệu từ server. Dữ liệu (nếu có gửi lên) phải nằm trên URL (Path hoặc Query param).
@PutMapping @RequestMapping(method = PUT) U (Update) Cập nhật (hoặc ghi đè) toàn bộ thông tin của một tài nguyên đã tồn tại.
@DeleteMapping @RequestMapping(method = DELETE) D (Delete) Xóa một tài nguyên khỏi hệ thống.

(Tính lũy đẳng - Idempotent: Nếu gọi API đó 1 lần hay 100 lần liên tiếp với cùng một dữ liệu, trạng thái của hệ thống kết quả vẫn như nhau. POST không có tính lũy đẳng vì gọi 100 lần sẽ tạo ra 100 bản ghi mới, trong khi DELETE 1 lần hay 100 lần thì bản ghi đó vẫn chỉ ở trạng thái "đã bị xóa")

Ví dụ: Xây dựng một CRUD Controller hoàn chỉnh

Trong một dự án thực tế, người ta thường dùng @RequestMapping ở trên đầu class để định nghĩa Base URL (tiền tố gốc), sau đó dùng bộ tứ annotation này ở từng phương thức để xử lý chi tiết.

import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/v1/users") // Base URL chung cho toàn bộ class
public class UserController {

    // 1. READ - Lấy danh sách tất cả người dùng
    // URL thực tế: GET /api/v1/users
    @GetMapping
    public List<User> getAllUsers() {
        return userService.findAll();
    }

    // 1. READ - Lấy thông tin 1 người dùng theo ID
    // URL thực tế: GET /api/v1/users/123
    @GetMapping("/{id}")
    public User getUserById(@PathVariable("id") Long id) {
        return userService.findById(id);
    }

    // 2. CREATE - Tạo người dùng mới
    // URL thực tế: POST /api/v1/users
    // Dữ liệu JSON gửi lên sẽ được ép kiểu vào biến User qua @RequestBody
    @PostMapping
    public User createUser(@RequestBody User newUser) {
        return userService.save(newUser);
    }

    // 3. UPDATE - Cập nhật toàn bộ thông tin người dùng có id = 123
    // URL thực tế: PUT /api/v1/users/123
    @PutMapping("/{id}")
    public User updateUser(@PathVariable("id") Long id, @RequestBody User updatedUser) {
        return userService.update(id, updatedUser);
    }

    // 4. DELETE - Xóa người dùng theo id
    // URL thực tế: DELETE /api/v1/users/123
    @DeleteMapping("/{id}")
    public void deleteUser(@PathVariable("id") Long id) {
        userService.deleteById(id);
    }
}

Một số lưu ý về cấu trúc thiết kế

  • Đường dẫn (Path) nên dùng danh từ số nhiều: Tiêu chuẩn RESTful khuyên dùng danh từ số nhiều (như /users, /products, /orders) thay vì động từ (không nên đặt path là /api/v1/getUser hay /api/v1/create-user). Hành động (get, create) đã được thể hiện qua chính HTTP Method (@GetMapping, @PostMapping).

  • Ngoài ra còn có @PatchMapping: PUT được dùng khi bạn muốn thay thế/ghi đè toàn bộ object. Nếu bạn chỉ muốn cập nhật một phần nhỏ (ví dụ: chỉ đổi mỗi mật khẩu hoặc trạng thái kích hoạt mà không chạm vào các trường khác), chuẩn RESTful định nghĩa nên dùng HTTP PATCH, tương ứng với @PatchMapping trong Spring Boot.

3. @PathVariable

Trong Spring Boot, @PathVariable là một annotation dùng để trích xuất giá trị trực tiếp từ đường dẫn URL (URI template) và gán nó vào một biến (parameter) trong phương thức xử lý của Controller. Đây là tính năng cốt lõi để xây dựng các RESTful API chuẩn, giúp định danh chính xác một tài nguyên (resource)

Để sử dụng @PathVariable, bạn cần định nghĩa một "điểm neo" (placeholder) trên URL bằng cặp ngoặc nhọn { }. Tên bên trong ngoặc nhọn phải khớp với tên biến trong tham số của phương thức.

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // URL thực tế từ Client: GET /api/products/123
    // Điểm neo là {id}
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        // Spring sẽ tự động cắt số "123" trên URL và gán vào biến 'id'
        return productService.findById(id);
    }
}

Xử lý khi tên biến không khớp (Custom Name)

Mặc định, Spring yêu cầu tên biến Java (ví dụ: Long id) phải giống hệt với tên điểm neo trên URL ({id}). Nếu bạn muốn (hoặc bắt buộc phải) đặt tên khác nhau, bạn phải truyền tên của điểm neo vào bên trong annotation.

// Điểm neo trên URL tên là {productId}
@GetMapping("/detail/{productId}")
public String getProductDetail(@PathVariable("productId") Long id) {
    // Biến Java tên là 'id', nhưng nhờ khai báo rõ "productId", Spring vẫn map chính xác.
    return "Chi tiết sản phẩm " + id;
}

Lấy nhiều @PathVariable trên cùng một URL

Trong các hệ thống thực tế, tài nguyên thường có tính phân cấp (sub-resources). Ví dụ: Lấy "đơn hàng số 5" của "khách hàng số 10". Bạn có thể dùng bao nhiêu @PathVariable tùy thích trên cùng một đường dẫn.

@RestController
@RequestMapping("/api/users")
public class UserOrderController {

    // URL thực tế: GET /api/users/10/orders/5
    @GetMapping("/{userId}/orders/{orderId}")
    public Order getSpecificOrder(
            @PathVariable Long userId, 
            @PathVariable Long orderId) {
        
        return orderService.findSpecificOrder(userId, orderId);
    }
}

Bắt buộc hay Không bắt buộc? (Required)

Mặc định, biến được gắn @PathVariable là bắt buộc (required = true). Nếu Client gọi URL mà khuyết mất phần này (ví dụ gọi /api/users/ thay vì/api/users/123), Spring sẽ báo lỗi 404 Not Found (hoặc 405 Method Not Allowe).

Lý do là vì trong kiến trúc REST, URL là đường dẫn vật lý xác định tài nguyên. Thiếu ID nghĩa là đường dẫn bị sai, chứ không phải là thiếu tham số. Do đó, rất hiếm khi người ta dùng required = false với @PathVariable (nếu có dùng, thường là do thiết kế API chưa chuẩn).

4. @RequestParam

Trong Spring Boot, @RequestParam là một annotation dùng để trích xuất các tham số truy vấn (Query Parameters) từ URL.

Nếu @PathVariable dùng để lấy dữ liệu nằm bên trong cấu trúc đường dẫn (như /users/123), thì @RequestParam làm nhiệm vụ lấy những dữ liệu nằm sau dấu chấm hỏi ? (ví dụ: /users?role=admin&age=25).

Đây là công cụ chủ lực để xử lý các tác vụ như: Lọc (Filter), Tìm kiếm (Search), Phân trang (Pagination) hoặc Sắp xếp (Sorting).

Theo mặc định, tên của tham số trên URL phải khớp chính xác với tên biến trong phương thức Java của bạn.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    // URL thực tế từ Client: GET /api/users?role=admin
    @GetMapping("/api/users")
    public String getUsersByRole(@RequestParam String role) {
        // Biến 'role' sẽ nhận giá trị "admin"
        return "Tìm kiếm user với role: " + role;
    }
}

Các thuộc tính cực kỳ quan trọng (Attributes)

Trong thực tế, người dùng có thể truyền thiếu tham số hoặc bạn muốn đặt giá trị mặc định cho phân trang. @RequestParam cung cấp 3 thuộc tính để bạn kiểm soát việc này:

  • name hoặc value: Dùng khi tham số trên URL có tên khác với tên biến Java (hoặc chứa các ký tự đặc biệt mà Java không cho phép đặt tên biến, ví dụ: user-name).

  • required: Mặc định là true. Nếu Client gọi URL mà không truyền tham số này, Spring sẽ ném lỗi 400 Bad Request. Đặt thành false nếu tham số đó là tùy chọn (optional).

  • defaultValue: Nếu Client không truyền tham số, biến sẽ nhận giá trị mặc định này thay vì bị null. (Lưu ý: defaultValue luôn là String, Spring sẽ tự ép kiểu sang số nếu biến của bạn là int, Long).

Ví dụ kết hợp cấu hình Phân trang (Pagination):

// URL gọi thực tế: GET /api/products?category=laptop
// Không truyền 'page' và 'size', hệ thống sẽ tự lấy giá trị mặc định.

@GetMapping("/api/products")
public List<Product> getProducts(
        @RequestParam(name = "category") String category,
        @RequestParam(name = "page", required = false, defaultValue = "1") int page,
        @RequestParam(name = "size", required = false, defaultValue = "10") int size) {
    
    // Hệ thống sẽ trả về danh sách category=laptop, trang 1, kích thước 10
    return productService.filter(category, page, size);
}

Nhận nhiều giá trị hoặc tham số động

a. Nhận một mảng (List/Array) dữ liệu:

Đôi khi client muốn lọc theo nhiều trạng thái cùng lúc: ?status=ACTIVE&status=PENDING hoặc ?status=ACTIVE,PENDING. Bạn chỉ cần khai báo biến là List.

@GetMapping("/api/orders")
public String getOrdersByStatus(@RequestParam List<String> status) {
    // Biến status sẽ là một list chứa ["ACTIVE", "PENDING"]
    return "Lọc đơn hàng theo các trạng thái: " + status;
}

b. Nhận tất cả tham số dưới dạng Map (Dynamic Params):

Nếu bạn không biết trước Client sẽ gửi lên những tiêu chí tìm kiếm nào (người dùng filter tự do), bạn có thể gom tất cả Query Params vào một Map<String, String>.

// URL: GET /api/search?color=red&priceMin=100&brand=apple

@GetMapping("/api/search")
public String searchDynamic(@RequestParam Map<String, String> allParams) {
    // allParams sẽ chứa { "color":"red", "priceMin":"100", "brand":"apple" }
    return "Tìm kiếm với các tiêu chí: " + allParams;
}

Khi nào @RequestParam thay vì @PathVariable? (Quy tắc thiết kế)

Một nhầm lẫn cực kỳ phổ biến của người mới học là không biết khi nào lấy dữ liệu bằng dấu gạch chéo (/123) và khi nào lấy bằng dấu hỏi chấm (?id=123).

  • Dùng @PathVariable (Ví dụ: /users/123): Khi giá trị đó dùng để Định danh (Identify) một tài nguyên duy nhất, cụ thể. Nó là một phần cấu trúc bắt buộc của hệ thống.

  • Dùng @RequestParam (Ví dụ: /users?role=admin&age=20): Khi giá trị đó dùng để Lọc (Filter), Tìm kiếm (Search), Phân trang (Pagination), hoặc các tham số tùy chọn bổ sung (Optional).

Ví dụ một API chuẩn RESTful kết hợp cả hai: GET /api/users/123/orders?status=DELIVERED

(Dịch nghĩa: Lấy danh sách đơn hàng của khách hàng ID = 123 (Định danh -> Dùng PathVariable), nhưng chỉ lọc ra những đơn hàng có trạng thái là DELIVERED (Bộ lọc -> Dùng RequestParam)).

5. @RequestBody

Trong Spring Boot, @RequestBody là annotation dùng để trích xuất dữ liệu từ phần thân (Body) của HTTP Request và chuyển đổi nó thành một đối tượng Java (Java Object).

Nếu @ResponseBody làm nhiệm vụ dịch từ Java sang JSON để trả về cho Client, thì @RequestBody làm nhiệm vụ ngược lại: Dịch dữ liệu JSON (hoặc XML) từ Client gửi lên thành đối tượng Java để hệ thống có thể xử lý.

Khi Client (trình duyệt, Postman, Mobile App) gửi một yêu cầu POST hoặc PUT, họ thường đính kèm một gói dữ liệu JSON.

Khi Request này đi vào Controller, Spring sẽ:

  1. Đọc header Content-Type của request (thường là application/json).

  2. Giao chuỗi JSON đó cho một trình chuyển đổi (HttpMessageConverter, mặc định là thư viện Jackson).

  3. Jackson sẽ quét các thuộc tính trong file JSON, khớp (map) chúng với các thuộc tính (fields) của class Java, tự động khởi tạo đối tượng và truyền vào hàm của bạn.

Ví dụ sử dụng cơ bản

Giả sử Client gửi lên một đoạn JSON để đăng ký tài khoản:

{
  "username": "john_doe",
  "email": "john@example.com",
  "age": 25
}

Bạn tạo một class (thường gọi là DTO - Data Transfer Object) để hứng dữ liệu này:

public class UserRegistrationDto {
    private String username;
    private String email;
    private int age;
    // Cần có các hàm Getter/Setter (hoặc dùng @Data của Lombok)
}

Trong Controller, bạn dùng @RequestBody để nhận:

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

    @PostMapping("/api/register")
    public String registerUser(@RequestBody UserRegistrationDto requestData) {
        // Lúc này requestData đã chứa đầy đủ {username: "john_doe", email: "john...", age: 25}
        System.out.println("Đăng ký cho user: " + requestData.getUsername());
        
        return "Đăng ký thành công!";
    }
}

Những lưu ý cực kỳ quan trọng trong thực tế

a. Chỉ dùng cho POST, PUT, PATCH

Theo chuẩn HTTP, các phương thức GETDELETE không nên chứa Body. Do đó, bạn không nên sử dụng @RequestBody đi kèm với @GetMapping hoặc @DeleteMapping. Nếu muốn truyền dữ liệu cho GET/DELETE, hãy dùng @PathVariable hoặc @RequestParam.

b. Lỗi "Khuyết dữ liệu"

Mặc định, @RequestBody là bắt buộc (required = true). Nếu Client gọi API POST mà phần Body bị trống rỗng, Spring sẽ báo lỗi 400 Bad Request. Nếu bạn muốn cho phép Body trống, có thể dùng @RequestBody(required = false).

c. Combo thực chiến: @RequestBody + @Valid

Trong các dự án thực tế, người ta hiếm khi dùng @RequestBody đứng một mình. Bạn không thể tin tưởng 100% dữ liệu từ Client gửi lên (ví dụ: email bị sai định dạng, tuổi bị âm).

Do đó, @RequestBody luôn được ghép cặp với @Valid (hoặc @Validated) để Spring tự động kiểm tra tính hợp lệ của dữ liệu trước khi cho phép chạy vào logic bên trong.

import jakarta.validation.Valid;

@PostMapping("/api/register")
public String registerUser(@Valid @RequestBody UserRegistrationDto requestData) {
    // Nếu JSON gửi lên có email sai định dạng (dựa trên cấu hình trong DTO),
    // Spring sẽ chặn đứng lại và báo lỗi ngay lập tức, hàm này không bị thực thi.
    return authService.register(requestData);
}

6. @Valid

Trong Spring Boot, @Valid (thuộc package jakarta.validation.Valid) đóng vai trò là "Người gác cổng". Nhiệm vụ của nó là tự động kiểm tra tính hợp lệ của dữ liệu đầu vào (validation) trước khi cho phép luồng xử lý đi sâu vào bên trong Controller và Tầng Service.

Thay vì bạn phải viết hàng chục dòng lệnh if/else để kiểm tra thủ công (ví dụ: if (username == null) return error;), @Valid kết hợp với các annotation ràng buộc (Constraints) sẽ tự động hóa toàn bộ quy trình này.

Kể từ Spring Boot 2.3 trở đi, module Validation đã bị tách ra khỏi gói web mặc định. Để sử dụng @Valid, bạn bắt buộc phải thêm thư viện sau vào file pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Cách hoạt động (Quy trình 2 bước)

Bước 1: Định nghĩa luật (Rules) trên DTO (Data Transfer Object)

Bạn sử dụng các annotation của thư viện Jakarta Validation (như @NotBlank, @Email, @Min, @Size,...) để đặt ra quy tắc cho từng thuộc tính.

import jakarta.validation.constraints.*;

public class UserDto {

    @NotBlank(message = "Tên đăng nhập không được để trống")
    @Size(min = 5, max = 20, message = "Tên đăng nhập phải từ 5 đến 20 ký tự")
    private String username;

    @NotBlank(message = "Email không được để trống")
    @Email(message = "Email không đúng định dạng")
    private String email;

    @Min(value = 18, message = "Tuổi phải từ 18 trở lên")
    private int age;
    
    // Getters và Setters (Hoặc dùng @Data của Lombok)
}

Bước 2: Kích hoạt kiểm tra bằng @Valid tại Controller

Khi nhận request từ Client, bạn gắn @Valid ngay trước tham số có @RequestBody (hoặc @ModelAttribute).

import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping("/register")
    public String registerUser(@Valid @RequestBody UserDto userDto) {
        // Nếu Spring thấy DTO vi phạm bất kỳ rule nào ở Bước 1,
        // nó sẽ chặn Request lại ngay lập tức và ném ra lỗi 400 Bad Request.
        // Dòng code này (và các logic Service) SẼ KHÔNG ĐƯỢC CHẠY.
        
        return "Đăng ký thành công cho: " + userDto.getUsername();
    }
}

Điều gì xảy ra khi Validation thất bại?

Theo mặc định, nếu dữ liệu không hợp lệ, Spring Boot sẽ ném ra một Exception có tên là MethodArgumentNotValidException và trả về cho Client một chuỗi lỗi khá cồng kềnh, khó đọc.

Trong các dự án thực tế, để API trả về cấu trúc lỗi JSON đẹp và thân thiện (ví dụ: báo chính xác trường nào lỗi, nội dung lỗi là gì), lập trình viên sẽ bắt Exception này lại bằng cách sử dụng @RestControllerAdvice@ExceptionHandler.

import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Bắt lỗi Validation và trả về JSON chuẩn
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        
        // Lấy tất cả các trường bị lỗi và tin nhắn lỗi (message đã định nghĩa ở DTO)
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        
        return errors; // Client sẽ nhận được: {"username": "Tên đăng nhập không được để trống", "age": "Tuổi phải từ 18 trở lên"}
    }
}

7. @Validated

Trong Spring Boot, @Validated là một annotation do chính Spring Framework cung cấp (org.springframework.validation.annotation.Validated). Nó được sinh ra để khắc phục hai giới hạn lớn nhất của @Valid (thuộc chuẩn Java EE): Tính năng chia nhóm (Validation Groups) và Kiểm tra tham số rời rạc (Method Validation).

Dưới đây là chi tiết về 2 "siêu năng lực" của @Validated@Valid không thể làm được.

1️⃣Siêu năng lực 1: Validation Groups (Chia nhóm kiểm tra)

Vấn đề: Giả sử bạn có một đối tượng UserDto dùng chung cho cả 2 API: Tạo mới (Create) và Cập nhật (Update).

  • Khi Tạo mới: ID bắt buộc phải để trống (vì DB sẽ tự tăng).
  • Khi Cập nhật: ID bắt buộc phải có (để biết đang sửa ai). Nếu chỉ dùng @Valid, bạn không thể thiết lập 2 luật trái ngược nhau trên cùng một trường id. Bạn sẽ phải tạo ra 2 class DTO khác nhau (CreateUserDto và UpdateUserDto), gây trùng lặp code.

Cách @Validated giải quyết: Bạn có thể tạo ra các "nhóm" (chỉ là các Interface rỗng) và gắn luật cho từng nhóm.

Bước 1: Tạo các interface đánh dấu (Marker Interfaces)

public interface OnCreate {}
public interface OnUpdate {}

Bước 2: Gắn nhóm vào DTO

public class UserDto {

    // Nhóm Create yêu cầu null, nhóm Update yêu cầu NotNull
    @Null(groups = OnCreate.class, message = "Không được truyền ID khi tạo mới")
    @NotNull(groups = OnUpdate.class, message = "Bắt buộc phải có ID khi cập nhật")
    private Long id;

    // Không ghi nhóm nào thì mặc định áp dụng cho tất cả, hoặc ghi rõ cả 2
    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    private String username;
}

Bước 3: Sử dụng @Validated ở Controller để kích hoạt đúng nhóm

@RestController
@RequestMapping("/api/users")
public class UserController {

    // Chỉ chạy các validation thuộc nhóm OnCreate
    @PostMapping
    public String createUser(@Validated(OnCreate.class) @RequestBody UserDto dto) {
        return "Tạo mới thành công";
    }

    // Chỉ chạy các validation thuộc nhóm OnUpdate
    @PutMapping
    public String updateUser(@Validated(OnUpdate.class) @RequestBody UserDto dto) {
        return "Cập nhật thành công";
    }
}

2️⃣Siêu năng lực 2: Method Validation (Kiểm tra tham số rời rạc)

Vấn đề: @Valid thường chỉ hoạt động tốt khi bạn kiểm tra một Object phức tạp (như DTO) truyền qua @RequestBody. Nhưng nếu bạn muốn kiểm tra trực tiếp các tham số nguyên thủy truyền qua @RequestParam hoặc @PathVariable (ví dụ: tuổi phải >= 18) thì @Valid sẽ "bất lực".

Cách @Validated giải quyết: Bạn phải đặt annotation @Validated lên đầu Class (cấp độ class). Lúc này, Spring sẽ tạo ra một Proxy để tự động chặn và kiểm tra mọi tham số rời rạc của tất cả các hàm bên trong class đó.

import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.Min;

@RestController
@RequestMapping("/api/users")
@Validated // BẮT BUỘC PHẢI CÓ Ở ĐÂY ĐỂ KIỂM TRA THAM SỐ RỜI RẠC
public class UserController {

    // API: GET /api/users/check?age=15
    @GetMapping("/check")
    public String checkAge(
            // Đặt trực tiếp constraint lên RequestParam
            @RequestParam @Min(value = 18, message = "Bạn phải từ 18 tuổi trở lên") int age) {
        
        return "Hợp lệ, tuổi của bạn là: " + age;
    }
}

Lưu ý: Khi kiểm tra lỗi ở cấp độ tham số rời rạc này, Spring sẽ ném ra lỗi ConstraintViolationException thay vì MethodArgumentNotValidException như khi dùng với DTO.

Tóm tắt: Khi nào dùng @Valid và Khi nào dùng @Validated?

Tình huống Nên dùng annotation nào? Vị trí đặt
1. Validate một Object (DTO) cơ bản @Valid (Chuẩn Java EE, dễ dùng). Trước tham số (ví dụ: @Valid @RequestBody UserDto).
Trước tham số (ví dụ: @Valid @RequestBody UserDto). @Validated Trước tham số và truyền tên nhóm: @Validated(OnCreate.class)
3. Validate trực tiếp @RequestParam hoặc @PathVariable @Validated Đặt ở đầu Class (Controller/Service).

Phần IV: Project Lombok (Thường dùng kèm)

LINK BÀI VIẾT CỦA MÌNH: 16 Lombok Annotations trong 4 phút

Tạm kết

"Đến đây, bài viết cũng đã khá dài và chúng ta vừa cùng nhau đi qua những 'viên gạch' nền móng quan trọng nhất của Spring Boot: từ nghệ thuật điều hướng Dependency Injection, kiểm soát dòng chảy dữ liệu của RESTful API, cho đến các annotation cực tiện ích đến từ nhà Lombok. Nắm vững bộ công cụ này, bạn đã hoàn toàn tự tin để xây dựng một kiến trúc backend chuẩn mực và an toàn.

Tuy nhiên, hành trình tối ưu hóa hệ thống vẫn chưa dừng lại. Khi ứng dụng phình to, làm thế nào để cấu trúc code linh hoạt giữa các môi trường, quản lý bảo mật khắt khe, hay xử lý mượt mà các bài toán đa luồng (multi-threading) phức tạp? Tất cả những annotation nâng cao về cấu hình (Configuration), bộ lọc (Filter) và kiến trúc hướng sự kiện (Event-driven) sẽ được giải mã chi tiết trong Phần 2.

Tạm thời, hãy để não bộ nghỉ ngơi và 'tiêu hóa' lượng kiến thức đồ sộ này nhé. Nếu có bất kỳ thắc mắc nào trong lúc áp dụng, đừng ngại để lại bình luận phía dưới. Hẹn gặp lại mọi người ở Phần 2!"


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí