Kiến trúc phân tầng — Controller → Service → Repository
Ba tầng và quy tắc
┌─────────────────────────────────────────────┐
│ Tầng Controller │
│ - Nhận HTTP request │
│ - Validate đầu vào (với @Valid) │
│ - Gọi phương thức service │
│ - Trả HTTP response │
│ - Biết về DTO, KHÔNG biết về entity │
├─────────────────────────────────────────────┤
│ Tầng Service │
│ - Chứa TẤT CẢ logic nghiệp vụ │
│ - Quản lý transaction │
│ - Điều phối nhiều repository │
│ - Chuyển đổi giữa entity và DTO │
│ - Ném business exception │
├─────────────────────────────────────────────┤
│ Tầng Repository │
│ - CHỈ truy cập dữ liệu │
│ - Không có logic nghiệp vụ │
│ - Không biết DTO │
│ - CHỈ biết entity │
└─────────────────────────────────────────────┘
Quy tắc 1: Dependency chảy xuống dưới. Controller → Service → Repository.
Quy tắc 2: Mỗi tầng có một trách nhiệm duy nhất.
Quy tắc 3: Service layer là ranh giới transaction.
Quy tắc 4: DTO dừng ở tầng service.
Vai trò của Service Layer
Service layer có 5 trách nhiệm cốt lõi:
Thực thi quy tắc nghiệp vụ
public PostResponse publishPost(Long id) {
Post post = findPostOrThrow(id);
if (!"DRAFT".equals(post.getStatus())) {
throw new BadRequestException("Chỉ bài nháp mới được phát hành. Trạng thái hiện tại: " + post.getStatus());
}
if (post.getCategory() == null) {
throw new BadRequestException("Cần chọn danh mục trước khi phát hành");
}
post.setStatus("PUBLISHED");
post.setPublishedAt(LocalDateTime.now());
return postMapper.toResponse(postRepository.save(post));
}
Quản lý Transaction
@Transactional
public PostResponse createPost(CreatePostRequest request) {
// Tất cả thao tác này xảy ra trong MỘT transaction.
// Nếu bất kỳ bước nào thất bại, TẤT CẢ đều rollback.
User author = findAuthor(request.getAuthorId());
String slug = generateUniqueSlug(request.getTitle());
Post post = new Post(request.getTitle(), slug, request.getContent());
post.setAuthor(author);
assignTags(post, request.getTagIds());
return postMapper.toResponse(postRepository.save(post));
}
Điều phối nhiều Repository
Chuyển đổi DTO ↔ Entity
Cross-cutting concern (logging, auditing, caching)
@Service Annotation và thiết kế dựa trên Interface
Có nên dùng Interface?
Tranh luận trong cộng đồng Spring. Khuyến nghị cho series này: bỏ qua interface, dùng class @Service cụ thể. Đơn giản hơn. Mockito có thể mock class cụ thể dễ dàng.
// Đơn giản và đủ cho hầu hết dự án
@Service
public class PostService {
// Không cần interface — Mockito mock được class này trực tiếp
}
Quy ước đặt tên Service
Đặt tên theo domain nghiệp vụ: PostService, UserService, AuthenticationService, NotificationService.
Tránh: PostManager, PostHelper, PostUtils.
Quản lý Transaction với @Transactional
Transaction là gì?
Transaction là nhóm thao tác database phải tất cả thành công hoặc tất cả thất bại. Không có thành công một phần.
@Transactional trong Spring
@Service
public class PostService {
// @Transactional bọc toàn bộ phương thức trong database transaction.
// Nếu BẤT KỲ exception nào được ném, TẤT CẢ thay đổi database đều rollback.
@Transactional
public PostResponse createPost(CreatePostRequest request) {
User author = userRepository.findById(request.getAuthorId()).orElseThrow(...);
Post post = new Post(request.getTitle(), generateSlug(request.getTitle()), request.getContent());
post.setAuthor(author);
Post saved = postRepository.save(post); // INSERT
assignTags(saved, request.getTagIds()); // Nhiều INSERT vào post_tags
// Nếu assignTags() ném exception ở đây,
// INSERT bài viết CŨNG bị rollback!
return postMapper.toResponse(saved);
}
}
Cách @Transactional hoạt động phía sau
Spring tạo proxy xung quanh service class:
Controller gọi postService.createPost(request)
↓
Spring Proxy:
1. BEGIN TRANSACTION
2. Gọi phương thức PostService.createPost() thực tế
3a. Nếu thành công → COMMIT TRANSACTION
3b. Nếu ném exception → ROLLBACK TRANSACTION
Lưu ý quan trọng: @Transactional chỉ hoạt động trên phương thức public được gọi từ bên ngoài class. Gọi nội bộ (method A gọi method B trong cùng class) bỏ qua proxy!
Hành vi Rollback
Mặc định:
- Unchecked exception (RuntimeException) → ROLLBACK
- Checked exception (Exception không phải RuntimeException) → KHÔNG ROLLBACK
// Rollback trên TẤT CẢ exception
@Transactional(rollbackFor = Exception.class)
// Rollback trên checked exception cụ thể
@Transactional(rollbackFor = {IOException.class, ParseException.class})
Transaction chỉ đọc và Hiệu suất
@Transactional(readOnly = true)
Cho phương thức chỉ đọc dữ liệu (không insert, update, hay delete):
@Transactional(readOnly = true)
public PostResponse getPostById(Long id) {
Post post = postRepository.findByIdWithDetails(id).orElseThrow(...);
return postMapper.toResponse(post);
}
Lợi ích hiệu suất
- Hibernate bỏ qua dirty checking — không kiểm tra entity thay đổi.
- Flush mode chỉ đọc — không sinh UPDATE hay INSERT.
- Tối ưu cấp database — một số database dùng đường dẫn thực thi nhanh hơn.
- Ngăn ghi vô tình — nếu vô tình gọi
entity.setStatus(), thay đổi KHÔNG được lưu.
Pattern: Annotation cấp Class + cấp Method
@Service
@Transactional(readOnly = true) // Mặc định TẤT CẢ phương thức: chỉ đọc
public class PostService {
// Kế thừa readOnly = true — không cần annotation
public PostResponse getPostById(Long id) { ... }
// Kế thừa readOnly = true
public Page<PostResponse> getPosts(String status, Pageable pageable) { ... }
// GHI ĐÈ: phương thức này cần quyền ghi
@Transactional // readOnly = false (mặc định)
public PostResponse createPost(CreatePostRequest request) { ... }
@Transactional
public PostResponse publishPost(Long id) { ... }
@Transactional
public void deletePost(Long id) { ... }
}
Pattern xuất sắc vì: hầu hết phương thức service là đọc → tự động được tối ưu. Phương thức ghi được đánh dấu rõ ràng.
Propagation và Isolation Level
Transaction Propagation
Propagation định nghĩa điều gì xảy ra khi phương thức transactional gọi phương thức transactional khác.
// Propagation.REQUIRED (MẶC ĐỊNH)
// "Tham gia transaction hiện có, hoặc tạo mới nếu chưa có."
// Đây là cái bạn cần 99% trường hợp.
@Transactional(propagation = Propagation.REQUIRED)
// Propagation.REQUIRES_NEW
// "Luôn tạo transaction mới, độc lập."
// Dùng cho audit logging — log phải tồn tại dù thao tác chính thất bại.
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(String action, String description) {
auditLogRepository.save(new AuditLog(action, description, LocalDateTime.now()));
// Commit độc lập với transaction gọi
}
}
Transaction Isolation
MariaDB mặc định là REPEATABLE_READ, phù hợp cho hầu hết ứng dụng. Hiếm khi cần thay đổi.
Lời khuyên thực tế
- Dùng
Propagation.REQUIRED(mặc định) ở mọi nơi - Dùng
Propagation.REQUIRES_NEWchỉ cho audit logging - Dùng
Isolation.DEFAULT— để MariaDB xử lý - Tập trung vào đặt
@Transactionalđúng chỗ thay vì tinh chỉnh propagation/isolation
Pattern Logic nghiệp vụ — Khi nào Throw, khi nào Return
Pattern 1: Throw khi không tìm thấy (Hầu hết trường hợp)
public PostResponse getPostById(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
return postMapper.toResponse(post);
}
Pattern 2: Return Optional (Khi vắng mặt là bình thường)
public Optional<PostResponse> findPostBySlug(String slug) {
return postRepository.findBySlug(slug).map(postMapper::toResponse);
}
Pattern 3: Guard Clause — trả về sớm
Đặt validation trước, happy path cuối:
@Transactional
public PostResponse createPost(CreatePostRequest request) {
// Guard clause — validate mọi thứ trước khi làm bất kỳ công việc nào
User author = findActiveUser(request.getAuthorId());
String slug = generateSlug(request.getTitle());
if (postRepository.existsBySlug(slug)) {
throw new DuplicateResourceException("Post", "slug", slug);
}
// Happy path — tất cả validation đã pass
Post post = new Post(request.getTitle(), slug, request.getContent());
post.setAuthor(author);
assignTags(post, request.getTagIds());
return postMapper.toResponse(postRepository.save(post));
}
Pattern 4: Service trả DTO, không bao giờ Entity
// ✅ TỐT — trả DTO, controller không chạm entity
public PostResponse getPostById(Long id) { ... }
// ❌ XẤU — trả entity, controller phải xử lý lazy loading và ánh xạ
public Post getPostById(Long id) { ... }
Pattern 5: Phương thức helper private
private Post findPostOrThrow(Long id) {
return postRepository.findByIdWithDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
}
private User findActiveUser(Long userId) {
User user = userRepository.findById(userId).orElseThrow(...);
if (!user.isActive()) throw new BadRequestException("User không hoạt động: " + userId);
return user;
}
private String generateUniqueSlug(String title) { ... }
private void assignTags(Post post, List<Long> tagIds) { ... }
Logging với SLF4J & Logback
Thiết lập cơ bản
SLF4J và Logback đã đi kèm với spring-boot-starter-web. Không cần dependency thêm.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class PostService {
// Quy ước: khai báo là trường private static final
private static final Logger log = LoggerFactory.getLogger(PostService.class);
@Transactional
public PostResponse createPost(CreatePostRequest request) {
log.info("Tạo bài viết: title='{}', authorId={}", request.getTitle(), request.getAuthorId());
// ... logic nghiệp vụ ...
log.info("Bài viết đã tạo: id={}, slug='{}'", saved.getId(), saved.getSlug());
return postMapper.toResponse(saved);
}
}
Mức Log
| Mức | Mục đích | Hiển thị mặc định |
|---|---|---|
TRACE |
Thông tin chẩn đoán rất chi tiết | Không |
DEBUG |
Thông tin chẩn đoán cho development | Không |
INFO |
Sự kiện hoạt động bình thường | Có |
WARN |
Tình huống có thể gây hại | Có |
ERROR |
Sự kiện lỗi | Có |
Best practice
// ✅ TỐT — dùng tham số hóa (dấu ngoặc nhọn), không nối chuỗi
log.debug("Xử lý bài viết id={}, title='{}'", post.getId(), post.getTitle());
// ❌ XẤU — nối chuỗi xảy ra ngay cả khi DEBUG bị tắt
log.debug("Xử lý bài viết id=" + post.getId() + ", title='" + post.getTitle() + "'");
// Luôn truyền exception là tham số cuối trong log lỗi
log.error("Không thể xử lý bài viết id={}", postId, exception);
Cấu hình
logging.level.root=INFO
logging.level.com.example.blogapi=DEBUG
logging.level.org.hibernate.SQL=DEBUG
# Ghi log ra file
logging.file.name=logs/blog-api.log
logging.logback.rollingpolicy.max-file-size=10MB
logging.logback.rollingpolicy.max-history=30
Xem trước Unit Test cho Service Layer
@ExtendWith(MockitoExtension.class)
class PostServiceTest {
@Mock private PostRepository postRepository;
@Mock private UserRepository userRepository;
@Mock private PostMapper postMapper;
@InjectMocks
private PostService postService;
@Test
void getPostById_khiTonTai_traVePostResponse() {
// Arrange — thiết lập dữ liệu test và hành vi mock
when(postRepository.findByIdWithDetails(1L)).thenReturn(Optional.of(testPost));
when(postMapper.toResponse(any(), anyLong())).thenReturn(expectedResponse);
// Act — gọi phương thức cần test
PostResponse result = postService.getPostById(1L);
// Assert — xác minh kết quả
assertNotNull(result);
assertEquals(1L, result.getId());
verify(postRepository, times(1)).findByIdWithDetails(1L);
}
@Test
void getPostById_khiKhongTonTai_nemResourceNotFoundException() {
when(postRepository.findByIdWithDetails(999L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> postService.getPostById(999L));
}
}
Điểm mấu chốt: nhờ constructor injection, chúng ta test mà không cần database, Spring context, hay network call. Test chạy trong mili-giây.
Thực hành: Tái cấu trúc Blog API với Service Layer & Transaction đúng cách
PostService production-quality hoàn chỉnh
@Service
@Transactional(readOnly = true) // Mặc định: tất cả phương thức chỉ đọc
public class PostService {
private static final Logger log = LoggerFactory.getLogger(PostService.class);
private final PostRepository postRepository;
private final UserRepository userRepository;
private final PostMapper postMapper;
// ... các dependency khác
// ========== TẠO ==========
@Transactional // Ghi đè: thao tác ghi
public PostResponse createPost(CreatePostRequest request) {
log.info("Tạo bài viết: title='{}', authorId={}", request.getTitle(), request.getAuthorId());
User author = findActiveUser(request.getAuthorId());
String slug = generateUniqueSlug(request.getTitle());
Post post = new Post(request.getTitle(), slug, request.getContent());
post.setAuthor(author);
assignTags(post, request.getTagIds());
Post saved = postRepository.save(post);
log.info("Bài viết đã tạo: id={}, slug='{}'", saved.getId(), saved.getSlug());
return postMapper.toResponse(saved);
}
// ========== ĐỌC ==========
public PostResponse getPostById(Long id) {
log.debug("Lấy bài viết: id={}", id);
Post post = postRepository.findByIdWithAllDetails(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
long commentCount = commentRepository.countByPostId(id);
return postMapper.toResponse(post, commentCount);
}
// ========== CẬP NHẬT ==========
@Transactional
public PostResponse updatePost(Long id, UpdatePostRequest request) {
log.info("Cập nhật bài viết: id={}", id);
Post post = findPostOrThrow(id);
if (request.getTitle() != null) {
post.setTitle(request.getTitle().trim());
post.setSlug(generateUniqueSlug(request.getTitle()));
}
// Dirty checking: Hibernate tự phát hiện thay đổi và sinh UPDATE
return postMapper.toResponse(post);
}
// ========== XÓA ==========
@Transactional
public void deletePost(Long id) {
log.info("Xóa bài viết: id={}", id);
Post post = findPostOrThrow(id);
log.warn("Xóa vĩnh viễn bài viết: id={}, title='{}'", post.getId(), post.getTitle());
postRepository.delete(post);
}
// ========== HELPER PRIVATE ==========
private Post findPostOrThrow(Long id) { ... }
private User findActiveUser(Long userId) { ... }
private String generateUniqueSlug(String title) { ... }
private void assignTags(Post post, List<Long> tagIds) { ... }
}
Điều gì làm Service này production-quality?
@Transactional(readOnly = true)cấp class — đọc được tối ưu mặc định@Transactionalcấp method — chỉ phương thức ghi mở write transaction- Logging có cấu trúc tại mọi điểm quan trọng
- Guard clause validate trước khi làm việc
- Helper private loại bỏ trùng lặp
- DTO conversion xảy ra khi session còn mở
- Custom exception cung cấp thông báo lỗi rõ ràng
Bài tập
- Xây dựng
UserServicetheo cùng pattern: create, getById, getAll (phân trang), update, deactivate. - Tạo
StatsServicetổng hợp dữ liệu từ nhiều repository — tổng user, tổng post theo trạng thái. - Thêm logging vào
GlobalExceptionHandler:log.warncho 4xx,log.errorcho 5xx. - Cấu hình logging theo môi trường: dev = DEBUG, prod = WARN + ghi file.
Tổng kết
- Kiến trúc phân tầng: Controller xử lý HTTP, Service xử lý logic nghiệp vụ, Repository xử lý truy cập dữ liệu.
- @Transactional: Bọc phương thức trong database transaction. Rollback trên unchecked exception.
- readOnly = true: Tối ưu đọc bằng cách bỏ qua dirty checking. Đặt cấp class, ghi đè cho phương thức ghi.
- Propagation:
REQUIRED(mặc định) cho 99% trường hợp.REQUIRES_NEWcho audit logging. - Business logic pattern: Throw khi không tìm thấy, guard clause trước, trả DTO không bao giờ entity, helper private.
- Logging: SLF4J với tham số hóa. INFO cho sự kiện, WARN cho bất thường, ERROR cho thất bại. Truyền exception là tham số cuối.
Tham chiếu nhanh
| Khái niệm | Mô tả |
|---|---|
| Kiến trúc phân tầng | Controller → Service → Repository, mỗi tầng có trách nhiệm rõ |
@Transactional |
Bọc phương thức trong transaction, rollback trên unchecked exception |
readOnly = true |
Tối ưu thao tác đọc, bỏ qua dirty checking |
Propagation.REQUIRED |
Tham gia transaction hiện có hoặc tạo mới (mặc định) |
Propagation.REQUIRES_NEW |
Luôn tạo transaction mới độc lập |
| Guard clause | Validate đầu phương thức, happy path cuối |
| SLF4J | API logging Java tiêu chuẩn |
| Logback | Implementation logging mặc định trong Spring Boot |
| Tham số hóa | log.info("id={}", id) — không nối chuỗi |
| Mockito | Framework mock cho unit test |
| Arrange-Act-Assert | Pattern test: thiết lập → thực thi → xác minh |
