Thiết kế Service Layer & Logic nghiệp vụ

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

  1. Hibernate bỏ qua dirty checking — không kiểm tra entity thay đổi.
  2. Flush mode chỉ đọc — không sinh UPDATE hay INSERT.
  3. Tối ưu cấp database — một số database dùng đường dẫn thực thi nhanh hơn.
  4. 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_NEW chỉ 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
WARN Tình huống có thể gây hại
ERROR Sự kiện lỗi

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?

  1. @Transactional(readOnly = true) cấp class — đọc được tối ưu mặc định
  2. @Transactional cấp method — chỉ phương thức ghi mở write transaction
  3. Logging có cấu trúc tại mọi điểm quan trọng
  4. Guard clause validate trước khi làm việc
  5. Helper private loại bỏ trùng lặp
  6. DTO conversion xảy ra khi session còn mở
  7. Custom exception cung cấp thông báo lỗi rõ ràng

Bài tập

  1. Xây dựng UserService theo cùng pattern: create, getById, getAll (phân trang), update, deactivate.
  2. Tạo StatsService tổng hợp dữ liệu từ nhiều repository — tổng user, tổng post theo trạng thái.
  3. Thêm logging vào GlobalExceptionHandler: log.warn cho 4xx, log.error cho 5xx.
  4. 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_NEW cho 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

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *