Phần lớn người bỏ cuộc với Spring Boot không phải vì Spring. Họ bỏ cuộc vì phần Java nằm dưới nó — một List<Notifier> tự nhiên có sẵn nội dung, một JpaRepository<User, Long> có hai type parameter và không có method body, một @GetMapping trông như thể viết lại cả chương trình. Không cái nào trong đó là Spring. Tất cả đều là Java bình thường mà Spring dựa vào rất nhiều.
Bài này là danh sách kiến thức Java cần có cho cả series, và mỗi mục đều được dạy qua đúng chỗ Spring dùng tới nó. Mọi ví dụ bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64), dựa trên đúng các jar mà Spring Boot 4.1.1 resolve ra, nên output và lỗi compile là thứ toolchain thật sự in ra.
![]()
Hãy đọc nó như một checklist. Phần nào bạn thấy hiển nhiên thì bỏ qua; phần nào chưa rõ thì chính là chỗ Spring sẽ làm bạn rối về sau.
Series này xây dựng cái gì
Series dựng một REST API chạy trên relational database, làm từng lớp một: HTTP endpoint, tầng service, persistence bằng Spring Data JPA, validation, xử lý lỗi và test. Bài viết dành cho người đã viết và compile được Java nhưng chưa từng dùng framework nào.
| Lựa chọn | Vì sao nó quan trọng ở đây | |
|---|---|---|
| Framework | Spring Boot 4.1.1 | kéo theo Spring Framework 7.0.9 |
| Ngôn ngữ | Java 21 (LTS) | có sẵn record, var, pattern matching, Stream.toList() |
| Build tool | Gradle, qua wrapper ./gradlew | không cần cài Gradle; Maven sẽ được so sánh ở bài sau trong series |
| JDK tối thiểu | 17 | Boot 4.x không chạy được trên bản cũ hơn |
Dòng cuối không phải nghe đồn. Các class file trong spring-boot-4.1.1.jar được compile ra class file major version 61, tức Java 17:
javap -v -cp spring-boot-4.1.1.jar org.springframework.boot.SpringApplication | grep 'major version' major version: 61Series dùng Java 21 vì đây là bản LTS hiện tại, và vì record với Stream.toList() bớt được rất nhiều dòng code thừa trong ví dụ.
Spring Framework và Spring Boot thực chất là gì, auto-configuration và starter làm gì, embedded server ở đâu ra — đó là nội dung bài tiếp theo. Bài này dừng ở phần Java.
Đây là bản đồ. Mỗi dòng là một phần bên dưới, cột phải là API của Spring sẽ vô nghĩa nếu thiếu nó.
| Tính năng Java | Bạn gặp nó ở đâu trong Spring |
|---|---|
| interface + polymorphism | một field của service khai báo theo interface, implementation được chọn ở nơi khác |
List, Set, Map | List<SomeInterface> chứa mọi implementation; Map<String, T> đánh theo tên |
| generics | JpaRepository<User, Long>, ResponseEntity<T>, Optional<T> |
| lambda + functional interface | orElseThrow(() -> ...), config viết dạng cfg -> cfg.something() |
Stream | biến một list entity thành một list DTO |
Optional | giá trị trả về của findById |
record | DTO cho request và response, bind từ JSON |
| annotation + reflection | @Component, @GetMapping, @Entity — toàn bộ |
Interface và polymorphism: hình dạng mà Spring mong đợi
Interface là một danh sách signature của method, không có hành vi. Class nào implement nó thì cam kết cung cấp phần hành vi đó. Điều duy nhất quan trọng với Spring là cái mà caller được lợi: caller có thể viết theo interface và không bao giờ phải nhắc tên một class cụ thể.
interface Notifier {
String channel();
void send(String to, String message);
}
class EmailNotifier implements Notifier {
public String channel() { return "email"; }
public void send(String to, String message) {
System.out.println("[email] to=" + to + " body=" + message);
}
}
class SmsNotifier implements Notifier {
public String channel() { return "sms"; }
public void send(String to, String message) {
System.out.println("[sms] to=" + to + " body=" + message);
}
}Caller khai báo interface và nhận nó qua constructor. Bên trong nó không có new EmailNotifier() ở bất cứ đâu:
class OrderService {
private final Notifier notifier;
OrderService(Notifier notifier) {
this.notifier = notifier;
}
void placeOrder(String customer, String sku) {
System.out.println("order placed: " + sku + " (notifier=" + notifier.channel() + ")");
notifier.send(customer, "Order " + sku + " confirmed");
}
}Polymorphism là nửa còn lại: cùng một chỗ gọi nhưng ra hành vi khác nhau tuỳ theo thứ được truyền vào.
new OrderService(new EmailNotifier()).placeOrder("an@example.com", "SKU-1");
new OrderService(new SmsNotifier()).placeOrder("+84900000001", "SKU-2");order placed: SKU-1 (notifier=email)
[email] to=an@example.com body=Order SKU-1 confirmed
order placed: SKU-2 (notifier=sms)
[sms] to=+84900000001 body=Order SKU-2 confirmed
Hãy nhìn kỹ hai nửa đó nằm ở đâu. Bên trong OrderService, type duy nhất được nhắc tên là Notifier. Bên ngoài nó, ai đó viết new EmailNotifier(). Khoảng trống ấy — caller khai báo một type, người khác cung cấp instance — là một lỗ hổng trong chương trình, và lấp nó một cách tự động chính là việc của dependency injection. Series sẽ nói kỹ về chuyện đó sau; điều cần nhớ lúc này là DI không phải một tính năng mới của ngôn ngữ, nó chỉ là câu trả lời máy móc cho một khoảng trống mà cách viết code này vốn đã có.
Ba quy tắc liên quan nên nắm rõ trước khi tới đó:
| Cấu trúc | Giữ được state | Có hành vi | Một class có thể có |
|---|---|---|---|
interface | không (chỉ hằng số) | method default và static | nhiều |
abstract class | có | có, kể cả constructor | đúng một superclass |
record | có, chỉ immutable | có, nhưng không thêm field | bản thân nó đã ngầm là final |
Chọn interface khi bạn dự đoán sẽ có nhiều hơn một implementation, hoặc khi muốn thay bằng một bản giả trong test. Chỉ dùng abstract class khi nhiều implementation thật sự dùng chung state.
Collections: List, Set và Map
Ba interface này phủ gần hết những việc một ứng dụng Spring làm với nhóm object.
| Interface | Đảm bảo | Implementation thường dùng | Dùng ở đâu trong app Spring |
|---|---|---|---|
List<E> | có thứ tự, cho phép trùng, truy cập theo index | ArrayList | các dòng repository trả về |
Set<E> | không trùng, không đảm bảo thứ tự | HashSet, LinkedHashSet | role, tag, id duy nhất |
Map<K,V> | key ánh xạ sang value, mỗi key một entry | HashMap, LinkedHashMap | bảng tra cứu, request parameter |
List<String> roles = new ArrayList<>(List.of("ADMIN", "USER", "USER"));
Set<String> distinct = new HashSet<>(roles);
Map<String, Integer> limits = new HashMap<>();
limits.put("ADMIN", 1000);
limits.put("USER", 50);
System.out.println("list = " + roles + " size=" + roles.size());
System.out.println("set = " + distinct + " size=" + distinct.size());
System.out.println("map = " + limits);
System.out.println("get USER = " + limits.get("USER"));
System.out.println("get NONE = " + limits.get("NONE"));
System.out.println("getOrDef = " + limits.getOrDefault("NONE", 0));list = [ADMIN, USER, USER] size=3
set = [ADMIN, USER] size=2
map = {ADMIN=1000, USER=50}
get USER = 50
get NONE = null
getOrDef = 0Hai hành vi trong output đó gây ra bug thật. Map.get trả về null khi không có key chứ không throw, và đó là lý do getOrDefault tồn tại. Còn Set đã âm thầm nuốt mất bản trùng USER — ba phần tử vào, hai phần tử ra.
Loại collection sẽ làm bạn bất ngờ trong Spring là một List theo type của interface. Khi nhiều class cùng implement Notifier, Spring có thể trao cho caller tất cả chúng trong một List<Notifier>, và một Map<String, Notifier> đánh theo tên. Phần Java của chuyện đó chẳng có gì đặc biệt — chỉ là một list các instance, tất cả được nhìn qua interface:
List<Notifier> injected = List.of(new EmailNotifier(), new SmsNotifier());
Map<String, Notifier> byChannel = injected.stream()
.collect(Collectors.toMap(Notifier::channel, Function.identity()));
System.out.println("byChannel keys = " + byChannel.keySet());
byChannel.get("sms").send("+84900000001", "ping");byChannel keys = [sms, email]
[sms] +84900000001 pingĐể ý thứ tự key: HashMap không giữ thứ tự thêm vào, và entry sms ra trước dù email được thêm trước. Nếu thứ tự quan trọng thì phải nói rõ bằng LinkedHashMap.
Một cái bẫy mà ai cũng dính ít nhất một lần. List.of(...), Map.of(...) và những thứ tương tự không phải đều mutable. Các factory method này trả về collection immutable:
List.of("a").add("b");List.of is immutable -> java.lang.UnsupportedOperationException: nullThông báo đúng là null — exception này không mang theo chi tiết nào. Khi một repository hay một library trao cho bạn một list, hãy copy bằng new ArrayList<>(list) trước khi sửa nó.
Generics: type parameter, bound và erasure
Type parameter là chỗ trống cho một type mà caller sẽ điền vào. List<String> là List<E> với E được cố định thành String, và compiler sẽ từ chối mọi thứ khác:
List<String> names = new ArrayList<>();
names.add(42);Unsafe.java:7: error: incompatible types: int cannot be converted to String
names.add(42);
^Bạn sẽ hiếm khi tự viết type parameter, nhưng đọc chúng thì liên tục. Đây đúng là hình dạng Spring Data dùng, và trong đó chẳng có phép thuật nào — một interface với hai parameter, một cho entity và một cho id của nó:
interface CrudRepository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
}
class UserRepository implements CrudRepository<User, Long> {
private final Map<Long, User> rows = new LinkedHashMap<>();
public User save(User entity) {
rows.put(entity.id(), entity);
return entity;
}
public Optional<User> findById(Long id) {
return Optional.ofNullable(rows.get(id));
}
public List<User> findAll() {
return List.copyOf(rows.values());
}
}findById(1) = Optional[User[id=1, name=An]]
findById(99) = Optional.empty
findAll = [User[id=1, name=An], User[id=2, name=Binh]]So với bản thật. CrudRepository của Spring Data, decompile từ spring-data-commons 4.1.1, đúng bản mà Boot 4.1.1 resolve ra:
public interface org.springframework.data.repository.CrudRepository<T, ID> extends org.springframework.data.repository.Repository<T, ID> {
public abstract <S extends T> S save(S);
public abstract java.util.Optional<T> findById(ID);
public abstract boolean existsById(ID);
public abstract java.lang.Iterable<T> findAll();
public abstract long count();
public abstract void deleteById(ID);JpaRepository<User, Long> chính là thứ đó, sâu qua vài tầng interface. Nên khi bạn viết interface UserRepository extends JpaRepository<User, Long>, bạn đang nói entity là User, primary key là Long — và signature của mọi method kế thừa được viết lại theo đó. findById trả về Optional<User> và nhận vào Long, vì chính bạn đã khai như vậy.
ResponseEntity<T> đọc y hệt: parameter là type của body, nên compiler biết getBody() trả về gì mà không cần cast.
ResponseEntity<User> response = ResponseEntity.ok(new User(1L, "An"));
String name = response.getBody().name();
System.out.println("status : " + response.getStatusCode() + ", body name = " + name);status : 200 OK, body name = AnType parameter có bound thì hạn chế thứ được thay vào. <T extends Number> nghĩa là "bất kỳ type nào là Number", và chính điều đó cho phép thân method gọi các method của Number:
static <T extends Number> double sum(List<T> values) {
double total = 0;
for (T n : values) total += n.doubleValue();
return total;
}sum(int) = 6.0
sum(double) = 4.0Type erasure, và chỗ duy nhất nó lộ ra
Generics tồn tại cho compiler. Sau khi compile, các type argument bị xoá, và lúc runtime chỉ còn lại List:
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println("erasure: a.getClass() == b.getClass() -> " + (a.getClass() == b.getClass()));
System.out.println("erasure: both are " + a.getClass().getName());erasure: a.getClass() == b.getClass() -> true
erasure: both are java.util.ArrayListĐó là lý do hai overload chỉ khác nhau ở type argument thì không compile được — sau erasure chúng là cùng một method:
class Clash {
void save(List<String> names) {}
void save(List<Integer> ids) {}
}Clash.java:5: error: name clash: save(List<Integer>) and save(List<String>) have the same erasure
void save(List<Integer> ids) {}
^Erasure trở thành vấn đề của Spring khi framework phải deserialize vào một generic type. Chỉ với List.class, một HTTP client không thể biết các phần tử phải trở thành object User. Câu trả lời của Spring là ParameterizedTypeReference: bạn tạo một anonymous subclass, subclass đó ghi lại generic superclass của nó vào class file, và type sống sót:
List<User> users = new ArrayList<>();
System.out.println("erased : " + users.getClass().getName());
ParameterizedTypeReference<List<User>> ref = new ParameterizedTypeReference<>() {};
System.out.println("retained : " + ref.getType());erased : java.util.ArrayList
retained : java.util.List<User>Cặp {} ở cuối mới là thứ làm toàn bộ việc — nó tạo ra một anonymous subclass, và generic supertype của một subclass là một trong số ít thông tin generic mà class file còn giữ lại. Thiếu nó thì type argument biến mất.
Lambda, functional interface và Stream
Functional interface là interface có đúng một abstract method. Lambda là một instance của nó, viết ngay tại chỗ. java.util.function cung cấp sẵn bốn dạng bạn sẽ gặp liên tục:
| Interface | Method | Ý nghĩa | Lambda |
|---|---|---|---|
Function<T,R> | R apply(T) | nhận một, trả về một cái khác | s -> s.length() |
Supplier<T> | T get() | không nhận gì, sinh ra một giá trị | () -> new Order(...) |
Consumer<T> | void accept(T) | nhận một, không trả về gì | o -> System.out.println(o) |
Predicate<T> | boolean test(T) | nhận một, trả lời có hoặc không | o -> o.total() > 100 |
Function<String, Integer> length = s -> s.length();
Function<String, Integer> lengthRef = String::length;
Supplier<Order> fallback = () -> new Order("SKU-0", 0, "NONE");
Consumer<String> log = System.out::println;
Predicate<Order> paid = o -> o.status().equals("PAID");
System.out.println("Function : " + length.apply("Spring") + " / " + lengthRef.apply("Spring"));
System.out.println("Supplier : " + fallback.get());
System.out.println("Predicate : " + paid.test(new Order("SKU-1", 120, "PAID")));
log.accept("Consumer : written by System.out::println");Function : 6 / 6
Supplier : Order[sku=SKU-0, total=0, status=NONE]
Predicate : true
Consumer : written by System.out::printlnString::length và System.out::println là method reference — cách viết tắt cho một lambda chỉ làm mỗi việc gọi một method. Có bốn dạng:
| Dạng | Ví dụ | Lambda tương đương |
|---|---|---|
| static method | Integer::parseInt | s -> Integer.parseInt(s) |
| method của một object cụ thể | System.out::println | x -> System.out.println(x) |
| method của chính parameter | String::length | s -> s.length() |
| constructor | ArrayList::new | () -> new ArrayList<>() |
Interface tự viết cũng chạy y như vậy; @FunctionalInterface không bắt buộc nhưng khiến compiler kiểm tra giúp quy tắc một method duy nhất:
@FunctionalInterface
interface Discount {
int apply(int amount);
}
Discount flat = amount -> amount - 10;
System.out.println("Custom FI : " + flat.apply(100));Custom FI : 90Spring nhận lambda theo đúng hai cách đó. Một là nơi sinh ra giá trị hoặc exception — orElseThrow(() -> new UserNotFoundException(id)) chỉ tạo exception khi thật sự cần tới. Hai là callback để config, khi một method trao cho bạn một builder và bạn chỉnh nó: cfg -> cfg.something(...). Cả hai đều là functional interface bình thường; không có gì riêng của Spring ở đây cả.
Một stream pipeline, từng stage một
Stream là một pipeline các thao tác chạy trên một nguồn. Các intermediate operation (filter, map, sorted) trả về một stream khác và tự nó không làm gì; một terminal operation (toList, sum, forEach) mới là thứ khiến toàn bộ chạy.
List<Order> orders = List.of(
new Order("SKU-1", 120, "PAID"),
new Order("SKU-2", 40, "PAID"),
new Order("SKU-3", 300, "CANCELLED"),
new Order("SKU-4", 250, "PAID"));
System.out.println("-- pipeline built, nothing has run yet --");
var pipeline = orders.stream()
.peek(o -> System.out.println("source -> " + o.sku()))
.filter(o -> o.status().equals("PAID"))
.peek(o -> System.out.println(" paid -> " + o.sku()))
.filter(o -> o.total() >= 100)
.peek(o -> System.out.println(" >=100 -> " + o.sku()))
.map(Order::sku);
System.out.println("-- terminal operation starts here --");
List<String> result = pipeline.toList();
System.out.println("result = " + result);-- pipeline built, nothing has run yet --
-- terminal operation starts here --
source -> SKU-1
paid -> SKU-1
>=100 -> SKU-1
source -> SKU-2
paid -> SKU-2
source -> SKU-3
source -> SKU-4
paid -> SKU-4
>=100 -> SKU-4
result = [SKU-1, SKU-4]
Hai điều trong đoạn trace đó đáng giá hơn cả cú pháp. Không có gì được in ra giữa "pipeline built" và "terminal operation starts here" — các intermediate operation chưa hề chạy. Và thứ tự không phải bốn lượt quét qua list: SKU-1 đi hết tới cuối trước khi SKU-2 được đọc, còn SKU-3 dừng ngay ở filter đầu tiên nên filter thứ hai không bao giờ nhìn thấy nó.
mapToInt kèm sum là pipeline còn lại bạn sẽ viết mỗi tuần:
int revenue = orders.stream()
.filter(o -> o.status().equals("PAID"))
.mapToInt(Order::total)
.sum();revenue = 410Trong một ứng dụng Spring, việc này gần như luôn là cùng một bài toán: lấy List<UserEntity> mà repository trả về và tạo ra List<UserResponse> cho phía gọi.
Optional: giá trị trả về của findById
Optional<T> là một cái hộp chứa hoặc một giá trị, hoặc không gì cả. Nó tồn tại để "không có kết quả" được thể hiện ngay trong type trả về thay vì là một null mà caller quên kiểm tra. Spring Data dùng nó đúng vào việc đó: Optional<T> findById(ID).
| Method | Trả về | Dùng khi |
|---|---|---|
isPresent() / isEmpty() | boolean | bạn chỉ cần câu trả lời có hay không |
get() | T, hoặc throw | gần như không bao giờ — xem bên dưới |
orElse(other) | T | bạn có sẵn một giá trị thay thế rẻ tiền |
orElseGet(supplier) | T | giá trị thay thế tốn công tạo ra |
orElseThrow(supplier) | T, hoặc throw exception của bạn | caller buộc phải hỏng nếu không có |
map(fn) | Optional<R> | bạn chỉ muốn một field bên trong giá trị |
filter(pred) | Optional<T> | rỗng trừ khi giá trị còn thoả thêm điều kiện |
ifPresent(consumer) | void | chỉ làm gì đó khi có giá trị |
static Optional<User> findById(Long id) {
return Optional.ofNullable(ROWS.get(id));
}
User user = findById(1L).orElseThrow(() -> new NoSuchElementException("user 1 not found"));
String email = findById(99L).map(User::email).orElse("(none)");
findById(1L).ifPresent(u -> System.out.println("ifPresent : " + u.email()));
findById(99L).ifPresent(u -> System.out.println("never printed"));present : Optional[User[id=1, name=An, email=an@example.com]]
missing : Optional.empty
orElseThrow ok : An
map + orElse : (none)
ifPresent : an@example.com
filter : Optional.emptyorElseThrow kèm lambda là con ngựa kéo chính ở tầng service: nó mở hộp lấy giá trị, hoặc ném ra đúng exception mà tầng HTTP sẽ dịch lại.
findById(99L).orElseThrow(() -> new IllegalStateException("user 99 not found"));orElseThrow : java.lang.IllegalStateException: user 99 not foundBa cách dùng sai Optional
Gọi get() mà không kiểm tra. Đây là NullPointerException với vài bước lòng vòng thêm:
findById(99L).get();get() on empty : java.util.NoSuchElementException: No value presentDùng isPresent() rồi get(). Nó compile được và chạy được, nhưng đó chính là cái null check mà bạn đang muốn thoát khỏi, giờ dài thành ba dòng. map, orElse và orElseThrow nói cùng một điều chỉ trong một dòng.
Đặt Optional vào field hoặc parameter. Nó được thiết kế để làm type trả về. Làm field thì tốn thêm một object cho mỗi instance và phá vỡ serialization hoàn toàn — Optional không implement Serializable:
new ObjectOutputStream(new ByteArrayOutputStream()).writeObject(Optional.of("x"));as a field : NotSerializableException: java.util.OptionalVới parameter thì một overload hoặc một argument nullable bình thường rõ ràng hơn. Trả về Optional, còn lưu và truyền thì dùng giá trị bên trong.
Record: hình dạng DTO mà Spring bind JSON vào
Record là một vật chứa dữ liệu immutable. Một dòng khai báo ra các field, một canonical constructor, một accessor cho mỗi component, cùng equals, hashCode và toString:
record CreateUserRequest(String name, String email, int age) {
CreateUserRequest {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
name = name.trim();
}
}Phần thân ở đó là một compact constructor — không có danh sách parameter, không có lệnh gán. Nó chạy trước khi các field được gán, nên vừa validate được vừa chuẩn hoá được bằng cách gán lại parameter, đúng như dòng name.trim().
toString : CreateUserRequest[name=An, email=an@example.com, age=30]
accessor : An / an@example.com
equals : true
hashCode : true
compact : IllegalArgumentException: name must not be blankĐể ý tên accessor: req.name(), không phải req.getName(). Record không theo quy ước JavaBean.
Tính immutable do compiler ép, không phải do quy ước. Các field là final, và bản thân class cũng final:
record Point(int x, int y) {
void moveRight() {
this.x = x + 1;
}
}RecordFinal.java:3: error: cannot assign a value to final variable x
this.x = x + 1;
^RecordInherit.java:3: error: cannot inherit from final Point3
class Sub extends Point3 {
^Đó đúng là thứ một DTO cần: một giá trị đi vào từ request, được validate một lần rồi không đổi nữa. Jackson — bản 3.1.5, đúng bản Boot 4.1.1 quản lý — bind thẳng vào record, dùng canonical constructor để deserialize:
ObjectMapper mapper = JsonMapper.builder().build();
String json = mapper.writeValueAsString(req);
CreateUserRequest back = mapper.readValue(
"{\"name\":\"Binh\",\"email\":\"binh@example.com\",\"age\":25}",
CreateUserRequest.class);serialize : {"name":"An","email":"an@example.com","age":30}
deserialize : CreateUserRequest[name=Binh, email=binh@example.com, age=25]
round trip : trueVòng round trip đó chính là lý do record chiếm chỗ của DTO. Cùng một class vừa mô tả request body vừa là class mà JSON parser dựng được, không cần setter và không có gì phải giữ đồng bộ.
record | class có getter/setter | |
|---|---|---|
| Số dòng cho ba field | 1 | khoảng 40, hoặc một annotation Lombok |
| Sửa được sau khi tạo | không | có |
equals / hashCode | sinh ra từ mọi component | tự viết hoặc nhờ công cụ sinh |
| Validation | compact constructor | constructor hoặc setter |
Làm @Entity của JPA được không | không | có |
Dòng cuối là dòng cần nhớ. JPA đòi một constructor không có parameter và các field không final, nên record không thể làm entity. Dùng record cho các DTO ở rìa ứng dụng, còn entity ở giữa thì vẫn là một class bình thường.
Annotation và reflection: vì sao Spring không phải phép thuật
Đây là phần quan trọng nhất. Annotation là metadata trơ. Nó gắn thông tin vào một khai báo và tự nó không thay đổi gì hết. Phải có thứ khác đọc nó rồi hành động — và một khi bạn đã tự viết phần đọc đó, @Component với @GetMapping hết bí ẩn.
Khai báo một annotation
Một annotation type được khai báo bằng @interface, và hai annotation gắn trên chính nó quyết định nó được đặt ở đâu và sống được bao lâu:
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Handler {
String value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Route {
String path();
String method() default "GET";
}value() là trường hợp đặc biệt: annotation chỉ có duy nhất một element tên value thì viết được @Handler("/users") thay vì @Handler(value = "/users"). Element nào có default thì được phép bỏ qua.
Giờ đặt chúng lên một class. Hai annotation nữa được gắn vào có chủ ý, với retention yếu hơn, để chứng minh một điều ngay sau đây:
@Handler("/users")
@DevNote("retention SOURCE - discarded by javac")
@BuildOnly("retention CLASS - in the class file, not loaded at runtime")
public class UserController {
@Route(path = "/{id}")
public String findOne(String id) {
return "user " + id;
}
@Route(path = "", method = "POST")
public String create(String body) {
return "created " + body;
}
public String notARoute() {
return "never mapped";
}
}Gọi thẳng class đó chứng minh rằng annotation không làm gì cả:
-- the annotation on its own changes nothing --
user 7Không route nào được map, không có dispatch, không có gì bị chặn lại. Đó là một lời gọi method bình thường trên một object bình thường.
Đọc lại nó bằng reflection
Reflection là API để soi một class lúc runtime. Mười lăm dòng biến đống metadata ở trên thành một bảng route chạy được:
Class<?> type = UserController.class;
if (!type.isAnnotationPresent(Handler.class)) {
System.out.println(type.getSimpleName() + " has no @Handler, skipped");
return;
}
String base = type.getAnnotation(Handler.class).value();
Object instance = type.getDeclaredConstructor().newInstance();
Map<String, Method> routes = new LinkedHashMap<>();
Method[] methods = type.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName));
for (Method m : methods) {
Route route = m.getAnnotation(Route.class);
if (route == null) continue;
routes.put(route.method() + " " + base + route.path(), m);
}
routes.forEach((key, m) -> System.out.println("mapped " + key + " -> " + m.getName() + "()"));
System.out.println("GET /users/{id} => " + routes.get("GET /users/{id}").invoke(instance, "42"));
System.out.println("POST /users => " + routes.get("POST /users").invoke(instance, "{\"name\":\"An\"}"));-- route table built by reading annotations --
mapped POST /users -> create()
mapped GET /users/{id} -> findOne()
-- dispatch --
GET /users/{id} => user 42
POST /users => created {"name":"An"}Toàn bộ mánh nằm ở đó. getAnnotation đọc metadata, getDeclaredMethods liệt kê ứng viên, newInstance dựng object và invoke gọi một method được chọn lúc runtime chứ không phải lúc compile. notARoute() không bao giờ được map vì nó không mang @Route. Phóng to chuyện này lên — quét cả một package thay vì một class, cache lại bảng, xử lý parameter và giá trị trả về — là bạn có bộ khung của việc một web framework làm lúc khởi động.
RetentionPolicy thật sự quyết định điều gì
@Retention là một bộ lọc có hai cửa, và nó là nguyên nhân phổ biến nhất khiến một annotation tự viết "chẳng làm gì cả".
| Policy | Trong source | Trong class file | Reflection nhìn thấy |
|---|---|---|---|
SOURCE | có | không | không |
CLASS (mặc định) | có | có | không |
RUNTIME | có | có | có |

UserController mang ba annotation với ba policy khác nhau. Lúc runtime, chỉ một cái còn tồn tại:
System.out.println(Arrays.toString(UserController.class.getAnnotations()));[@Handler("/users")]Nhưng @BuildOnly chưa biến mất — nó vẫn nằm trong class file, chỉ là ở một attribute mà class loader không phơi ra. javap -v cho thấy cả hai attribute nằm cạnh nhau:
RuntimeVisibleAnnotations:
0: #31(#32=s#33)
Handler(
value="/users"
)
RuntimeInvisibleAnnotations:
0: #35(#32=s#36)
BuildOnly(
value="retention CLASS - in the class file, not loaded at runtime"
)@DevNote không có trong attribute nào cả. Tìm trong class file các descriptor của annotation type thì chỉ thấy ba trên bốn:
strings UserController.class | grep -o -E 'L(Handler|Route|DevNote|BuildOnly);' | sort -uLBuildOnly;
LHandler;
LRoute;javac đã xoá nó. Đó đúng là mục đích của SOURCE: @Override và @SuppressWarnings là những kiểm tra lúc compile, sau đó không còn gì để nói, nên chúng không để lại dấu vết.
⚠️ Retention mặc định là
CLASS, không phảiRUNTIME. Một annotation bạn định đọc bằng reflection mà khai báo thiếu@Retentionthì vẫn compile ngon, vẫn build ra bình thường, vàgetAnnotations()lặng lẽ không trả về gì cho nó.
Annotation của chính Spring cũng được dựng y như vậy
Không có gì ở trên là đặc quyền của một ví dụ đồ chơi. Soi bằng reflection vào chính các annotation thật của Spring Framework 7.0.9 cho thấy đúng hai quyết định đó — retention RUNTIME, và ghép lại bằng meta-annotation:
for (Class<?> a : new Class<?>[] { Component.class, Service.class, RestController.class, GetMapping.class }) {
Retention r = a.getAnnotation(Retention.class);
// ... collect a.getAnnotations(), skipping the java.lang.annotation.* ones
System.out.printf("@%-15s retention=%-8s meta-annotated with: %s%n", a.getSimpleName(), r.value(), meta);
}@Component retention=RUNTIME meta-annotated with: @Indexed
@Service retention=RUNTIME meta-annotated with: @Component
@RestController retention=RUNTIME meta-annotated with: @Controller, @ResponseBody
@GetMapping retention=RUNTIME meta-annotated with: @RequestMappingCái nào cũng RUNTIME, vì Spring đọc chúng bằng reflection lúc khởi động — y hệt cách bảng route ở trên làm. Và một annotation có thể mang annotation của riêng nó: @Service chính là một @Component, @RestController chính là @Controller cộng @ResponseBody. Chính cách ghép đó khiến Spring coi một class gắn @Service là component mà không cần container phải có trường hợp riêng nào cho @Service.
Bạn kiểm chứng được cùng quan hệ đó trên type của mình:
System.out.println("direct @Stereotype? " + type.isAnnotationPresent(Stereotype.class));
for (Annotation a : type.getAnnotations()) {
System.out.println(a.annotationType().getSimpleName() + " meta-annotated @Stereotype? "
+ a.annotationType().isAnnotationPresent(Stereotype.class));
}direct @Stereotype? false
Handler meta-annotated @Stereotype? trueUserController không được gắn @Stereotype, nhưng annotation mà nó được gắn thì có mang — nên một bộ scan chịu nhìn lên một tầng sẽ tìm thấy. Chính ý tưởng duy nhất đó, áp dụng lúc khởi động lên mọi class trên classpath, là phần lớn những gì Spring Boot làm trước khi code của bạn chạy.
Bạn cần làm được gì trước khi sang bài 2
| Bạn cần làm được | Dấu hiệu đạt |
|---|---|
| viết một interface với hai implementation, và một caller chỉ nhắc tên interface | caller không có new cho cả hai implementation |
chọn được giữa List, Set và Map và biết cái nào immutable | List.of(...) throw khi gọi add |
đọc được Repository<T, ID> và nói ra T với ID là gì trong JpaRepository<User, Long> | findById trả về Optional<User> |
viết lambda cho Function, Supplier, Consumer và Predicate | và dạng method reference của từng cái |
| dựng một stream pipeline và gọi tên terminal operation của nó | thiếu nó thì không có gì chạy |
mở một Optional mà không gọi get() | map, orElse, orElseThrow |
| viết một record có compact constructor | field là final, accessor không có tiền tố get |
khai báo một annotation RUNTIME rồi đọc lại bằng reflection | getAnnotation rồi invoke |
Nếu dòng cuối đã chắc, phần còn lại của series chỉ là config và bề mặt API chứ không còn là chuyện bí ẩn.
FAQ
Có bắt buộc phải dùng Java 21 không, hay Java 17 là đủ?
Spring Boot 4.1.1 yêu cầu tối thiểu Java 17 — class file của nó là major version 61 — nên bản 17 chạy được mọi thứ trong series. Ở đây dùng Java 21 vì đó là bản LTS hiện tại và vì record, var và Stream.toList() đều đã ổn định trong nó. Không phần nào của series phụ thuộc vào tính năng chỉ có ở Java 21.
Vì sao annotation tự viết của tôi không có tác dụng gì?
Hai nguyên nhân, theo thứ tự này. Hoặc retention không phải RUNTIME nên reflection không nhìn thấy — mặc định là CLASS, và CLASS vô hình lúc runtime. Hoặc không có gì đọc nó: annotation tự thân không bao giờ đổi hành vi, phải có thứ gọi getAnnotation rồi xử lý kết quả. Trong Spring, thứ đọc đó là container lúc khởi động, và nó chỉ nhìn những class nó scan tới.
Reflection có chậm tới mức phải lo không?
Với những việc mô tả ở đây thì không. Spring trả chi phí reflection một lần lúc khởi động khi dựng metadata, chứ không phải mỗi request. Reflection trong một vòng lặp nóng của code bạn viết lại là chuyện khác — hãy cache object Method thay vì tra lại mỗi lần.
Entity nên dùng record hay class?
Class. JPA đòi một constructor không có parameter và các field không final, mà record không có cả hai. Record hợp với DTO ở rìa — request body và response — còn entity ở giữa vẫn là class bình thường.
Optional.orElse khác Optional.orElseGet ở chỗ nào?
orElse nhận một giá trị, nên argument được tính bất kể có cần hay không. orElseGet nhận một Supplier, nên giá trị thay thế chỉ được dựng khi Optional rỗng. Dùng orElse cho hằng số, và orElseGet mỗi khi việc tạo ra giá trị thay thế tốn kém — một lần gọi database, một object graph, một list mới.
Vì sao Spring cần ParameterizedTypeReference khi đã có generics?
Vì generics bị erase. Lúc runtime một List<User> chỉ là một List, nên một client phải dựng object từ response body sẽ không có gì để dựng. ParameterizedTypeReference được tạo dưới dạng anonymous subclass, mà generic supertype của một subclass thì có được giữ trong class file, nên getType() vẫn báo được java.util.List<User>.
Kết luận
Không có gì trong danh sách này là Spring cả. Interface cho framework một chỗ để thay implementation vào, generics cho phép một interface repository phục vụ mọi entity, Optional biến "không tìm thấy" thành một type trả về, record rút DTO xuống một dòng, còn annotation cộng reflection là cơ chế để một framework đọc code của bạn rồi hành động. Bảng route dựng ở trên là bản phác năm mươi dòng của thứ Spring làm trên cả một classpath lúc khởi động.
Bài tiếp theo nói về chính cái framework đó: Spring Framework và Spring Boot khác nhau thế nào, auto-configuration thật sự làm gì, một starter chứa những gì, và embedded server ở đâu ra.