Tôi Xóa Toàn Bộ JSP Khỏi Spring Boot — Và Đây Là Lý Do
Tháng trước, tôi mở lại một dự án Spring Boot mình đã bắt đầu từ đầu năm: Shop thương mại điện tử cho mẹ và bé.
Có giỏ hàng, thanh toán MoMo, chatbot AI Gemini, upload ảnh Cloudinary, có dữ liệu thật, ảnh thật. Về mặt kỹ thuật, project hoàn toàn ổn — nó chạy được.
Rồi tôi gửi link GitHub cho một anh senior đang làm ở một công ty fintech nhờ review. Anh ấy trả lời trong 3 phút:
Em đang dùng JSP năm 2025 😃
JSP Là Gì Và Tại Sao Nó Là Vấn Đề
JSP (JavaServer Pages) là công nghệ server‑side rendering của Java, ra đời năm 1999 — tức là hơn 25 năm trước. Và tong project của tôi, các controller dùng JSP trông như thế này:
@Controller
@RequiredArgsConstructor
public class AuthController {
@GetMapping("/login")
public String loginPage(Model model) {
return "guest/login";
}
@PostMapping("/register")
public String register(
@Valid @ModelAttribute RegisterDTO registerDTO,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
return "guest/register";
}
// ...
return "redirect:/login";
}
}
Thư mục view:
src/main/webapp/WEB-INF/views/
├── guest/
│ ├── login.jsp
│ ├── register.jsp
│ ├── home.jsp
│ └── cart.jsp
├── admin/
│ ├── dashboard.jsp
│ └── products/list.jsp
└── common/
├── header.jsp
└── footer.jsp
Về mặt kỹ thuật: không có lỗi gì. Nó chạy được. Vấn đề nằm ở chỗ khác.
Lý Do 1 — JSP Không Còn Là “Standard” Trên Thị Trường
Tôi lên LinkedIn lọc 50 JD “Java Backend Intern / Fresher” trong vòng 30 phút. Kết quả là: 0 JD nào yêu cầu JSP. Những thứ họ yêu cầu:
* REST API / RESTful
* Spring Boot
* React / Angular / Vue
* JWT / OAuth2
* MySQL / PostgreSQL
* Redis, Docker
Không ai nhắc đến JSP. Hiện tại, mô hình phổ biến là tách biệt Frontend và Backend:
* Backend: REST API, trả JSON.
* Frontend (React/Vue/Angular): gọi API, render UI.
JSP là mô hình cũ: backend render HTML, trả luôn về browser. Một team làm tất cả. Nếu không phải bảo trì hệ thống cũ, ít ai còn dùng JSP trong project mới.
Lý Do 2 — Code Bị Chồng Chéo Mô Hình
Trong project của tôi, tồn tại song song hai loại controller:
controller/
├── AuthController.java ← @Controller (JSP)
├── CartController.java ← @Controller (JSP)
├── HomeController.java ← @Controller (JSP)
└── rest/
├── AuthRestController.java ← @RestController
├── CartRestController.java ← @RestController
└── ProductRestController.java ← @RestController
Cùng một chức năng login nhưng có đến hai nơi để xử lý, với hai cách implement khác nhau. Ai đọc code lần đầu sẽ không biết nên nhìn vào đâu.
Lý Do 3 — JSP Chặn Tôi Học Những Thứ Quan Trọng Hơn
Khi dùng JSP, mình phải lo:
HTML template trong file .jsp.
JSTL tags (:forEach>, :if>).
Cách truyền Model xuống view.
redirect vs forward.
Spring Security CSRF token trong form.
Tất cả những thứ đó rất ít xuất hiện trong công việc backend thực tế. Nếu chuyển sang REST API thuần, mình có thể dồn thời gian vào:
Redis caching strategy.
RabbitMQ / event‑driven architecture.
JWT refresh token rotation.
Docker + CI/CD.
Resilience4j circuit breaker.
Đây mới là những thứ mà recruiter và senior thường hỏi, và cũng là thứ team dùng hàng ngày.
Quá Trình Xóa JSP — Mất Bao Lâu Và Khó Không
Tôi xin nói thật: tổng cộng khoảng 2 ngày, nhưng 80% thời gian là refactor, không phải xóa. Bước 1 — Xóa JSP và các phụ thuộc liên quan
src/main/webapp/
Và các file controller JSP:
controller/HomeController.java
controller/CartController.java
controller/CheckoutController.java
controller/admin/ (toàn bộ)
config/WebConfig.java
Xóa 4 dependency trong pom.xml:
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
</dependency>
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-taglibs</artifactId>
</dependency>
Xóa 2 dòng trong application.yml:
spring:
mvc:
view:
prefix: /WEB-INF/views/
suffix: .jsp
Sau bước này, project sẽ không compile được vì các REST controller vẫn import stuff liên quan đến JSP. Đây là chuyện bình thường, tiếp tục.
Bước 2 — Viết lại AuthController thành REST Controller cũ (trả về tên JSP):
@PostMapping("/register")
public String register(@ModelAttribute RegisterDTO dto, Model model) {
try {
userService.registerUser(dto);
return "redirect:/login";
} catch (Exception e) {
model.addAttribute("error", e.getMessage());
return "guest/register";
}
}
Controller mới (trả về JSON):
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<AuthResponse> register(
@Valid @RequestBody RegisterRequest request) {
return ApiResponse.success(
authService.register(request),
"Đăng ký thành công"
);
}
}
Response API:
{
"success": true,
"message": "Đăng ký thành công",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "550e8400-e29b-41d4-a716-446655440000",
"tokenType": "Bearer",
"expiresIn": 900,
"user": {
"id": 1,
"fullName": "Nguyễn Test",
"email": "test@gmail.com",
"role": "CUSTOMER"
}
},
"timestamp": "2025-01-15T10:30:00"
}
Đây là format mà bất kỳ frontend nào — React, Vue, Flutter, mobile app — đều có thể consume được.
Bước 3 — Những Thứ Tôi Học Được Ngoài Dự Kiến
Khi không còn JSP để “che giấu” lỗi trong view, tôi phát hiện ra một vấn đề trong code cũ: UserService đang dùng Exception quá chung chung:
public User registerUser(RegisterDTO dto) throws Exception {
if (userRepository.existsByEmail(dto.getEmail())) {
throw new Exception("Email đã tồn tại");
}
// ...
}
Tôi phải xây dựng lại Global Exception Handler chuẩn — thứ mà hệ thống production thực sự cần:
public class AppException extends RuntimeException {
private final HttpStatus status;
public static AppException conflict(String message) {
return new AppException(HttpStatus.CONFLICT, "CONFLICT", message);
}
// ...
}
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AppException.class)
public ResponseEntity<ErrorResponse> handleAppException(AppException ex) {
return ResponseEntity
.status(ex.getStatus())
.body(ErrorResponse.of(ex.getStatus().value(), ex.getErrorCode(), ex.getMessage()));
}
}
Khi đăng ký email trùng, client nhận được:
{
"status": 409,
"errorCode": "CONFLICT",
"message": "Email đã tồn tại",
"timestamp": "2025-01-15T10:30:00"
}
HTTP status code đúng. Error code có thể dùng để i18n. Timestamp để debug. Đây mới là API đúng nghĩa.
Khi Tưởng Xong — Nhưng Chưa Xong
Xóa JSP xong, tôi nghĩ phần khó đã qua.
Rồi tôi bắt đầu setup phần tiếp theo: Flyway để quản lý database migration, Redis cho giỏ hàng, RabbitMQ cho notification. Những thứ này sẽ nâng project lên một tầm khác — ít nhất là trên giấy.
Thực tế thì... hơi khác một chút.
Lỗi 1 — Circular Dependency Flyway Vừa thêm Flyway vào pom.xml và restart, app báo ngay:
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'flyway' defined in class path resource [org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration$FlywayConfiguration.class]: Circular depends-on relationship between 'flyway' and 'entityManagerFactory'
Spring đang bị vòng lặp:
Kết quả:
Không tạo được entityManagerFactory
→ userRepository chết → CustomUserDetailsService chết → JwtAuthenticationFilter chết → app sập 💀
và thủ phạm trong application.properties:
defer-datasource-initialization=true bảo Spring delay mọi thứ liên quan datasource cho đến sau khi Hibernate khởi xong. Nhưng Flyway cần datasource để chạy migration trước Hibernate. Hai bên chờ nhau mãi mãi.
Bảng Tổng Hợp 2 Lỗi
| Lỗi | Root Cause | Fix |
|---|---|---|
| Flyway circular dependency | defer-datasource-initialization=true | Đổi thành false, ddl-auto=none |
| V2 migration failed | CREATE INDEX IF NOT EXISTS không support | DROP IF EXISTS + CREATE |
Tuần đầu refactor, tôi chưa viết được một dòng feature nào. Toàn bộ thời gian là chiến đấu với config. Nhưng đây chính xác là thứ tôi cần học — production không bao giờ sạch như tutorial.
Swagger UI — Thứ Tôi Thích Nhất Sau Khi Xong
Khi app khởi động sạch lần đầu tiên, điều đầu tiên tôi làm là mở:
Toàn bộ API hiện ra, có nhóm tag rõ ràng, có schema request/response, có nút "Try it out". Không cần viết một dòng doc nào — springdoc-openapi tự generate từ annotation.

Kết Quả Sau 2 Ngày
| Trước | Sau |
|---|---|
| ~40 file JSP | 0 file JSP |
| 2 bộ controller chồng nhau | 1 bộ REST controller rõ ràng |
| throw new Exception(...) vô hướng | Custom AppException theo HTTP status |
| Không có API docs | Swagger UI tự động generate |
| Frontend phụ thuộc backend (JSP) | Frontend / Backend độc lập hoàn toàn |
Những Câu Hỏi Tôi Thường Nhận Được
1. “Không có JSP thì ai render HTML?”
Trong project này, tôi dùng React cho frontend — chạy riêng, gọi API từ Spring Boot.
Nếu bạn chỉ muốn làm backend, bạn có thể dùng Postman / Swagger để test API mà không cần frontend.
Mục tiêu của một backend developer là xây dựng API tốt, không phải xây dựng HTML.
2. “Mất 2 ngày — có đáng không?”
Flyway migration, Global Exception Handler, cấu hình Redis — những thứ này không phải feature, nhưng chúng là nền móng. Tuần tiếp theo tôi build JWT, Redis cart, RabbitMQ notification — và tất cả đều chạy trên nền móng này. Đáng
Tiếp Theo Trong Series Bài tiếp theo tôi sẽ viết về JWT 15 phút + Refresh Token 7 ngày:
Tại sao chọn 15 phút và 7 ngày?
Tôi implement như thế nào trong project này.
Tại sao không nên lưu JWT vào database.
Nếu bạn đang làm project Spring Boot tương tự, hoặc cũng đang phân vân giữa JSP vs REST, hãy để lại comment. Tôi sẽ đọc hết và trả lời.
All rights reserved