Spring Production-Ready/Mini-challenge: TaskFlow v4 — hiện đại hoá tầng Java
8/26
Bài 8 / 26~30 phútModern Java trong SpringMiễn phí lượt xem

Mini-challenge: TaskFlow v4 — hiện đại hoá tầng Java

Refactor TaskFlow: DTO thành record, client ra ngoài thành @HttpExchange, kết quả service thành sealed Result, rồi bật virtual threads và đo lại.

🎯 Đề bài

Sáu bài trước dạy bạn năm kỹ thuật rời rạc trên những mẩu code minh hoạ nhỏ của TaskFlow. Bài này ghép chúng lại trên một lát cắt thật của codebase — phần chưa ai đụng tới kể từ ngày viết đầu tiên.

TaskFlow đang ở trạng thái "v3": chạy tốt, traffic thấp thì không ai để ý, nhưng ba mảng dưới đây vẫn giữ nguyên pattern cũ mà bài 01, 04, 05, 06 đã chỉ ra là có vấn đề.

  1. Tính năng bình luận task (TaskCommentService) dùng một DTO class mutable có setter — y hệt lỗi bài 01 đã mổ xẻ, chỉ khác chỗ đứng.
  2. TaskFlowOrchestrator (bài 02 đã dùng làm ví dụ virtual threads) gọi ba service ngoài — thanh toán, tồn kho, giao hàng — bằng RestTemplate trần, không timeout.
  3. OrderService.placeOrder vẫn là "chữ ký nói dối" bài 06 đã chỉ ra: ném IllegalStateException cho hai lỗi nghiệp vụ hoàn toàn dự đoán được, và chưa từng được sửa trong bài đó.

Nhiệm vụ của bạn: refactor cả ba mảng qua đúng năm bước, rồi bật virtual threads cho luồng đặt hàng và đo xem quyết định đó có thật sự đáng làm hay không. Không có lời giải nào đúng duy nhất cho từng dòng code — nhưng có tiêu chí quan sát được cho từng bước, nêu ở mục "Yêu cầu" dưới đây.

🔍 Phân tích I-P-O

Mô tả
InputTaskFlow "v3": TaskCommentService với DTO mutable, ba client RestTemplate không timeout trong TaskFlowOrchestrator, OrderService.placeOrder ném IllegalStateException
ProcessNăm bước tuần tự: record hoá DTO, RestClient có timeout, gộp thành @HttpExchange, sealed Result thay exception, bật virtual threads và đo lại
OutputTaskFlow "v4": DTO bất biến đúng chỗ, client outbound có timeout + dịch lỗi, kết quả nghiệp vụ là kiểu dữ liệu compiler kiểm được, cấu hình luồng đúng với hồ sơ tải thật, kèm số đo throughput trước/sau

Điều kiện đúng (invariant) cần giữ xuyên suốt cả năm bước:

  • Hành vi nghiệp vụ không đổi: bình luận vẫn lưu đúng nội dung, đơn hàng hết hàng hoặc vượt hạn mức vẫn bị từ chối — chỉ đổi cách báo lỗi, không đổi quy tắc.
  • Không transaction nào commit dữ liệu của một đơn hàng đã bị từ chối.
  • Timeout và cách dịch lỗi cấu hình ở bước 2 không được mất đi khi chuyển sang bước 3.

📦 Concept mapping

BướcKỹ thuậtBài gốcĐúng learningOutcome
1DTO mutable → record, validation trên componentBài 01 — Record làm DTOImplement record làm DTO request/response
2RestTemplateRestClient với timeout + onStatusBài 04 — RestClientImplement HTTP client outbound với timeout và xử lý lỗi tường minh
3RestClient thủ công → interface @HttpExchangeBài 05 — HTTP InterfaceImplement HTTP client outbound với timeout và xử lý lỗi tường minh
4IllegalStateException → sealed Result → ProblemDetailBài 06 — Sealed ResultDesign kết quả nghiệp vụ bằng sealed type và pattern matching
5Bật virtual threads, đo throughput, soát ba cạm bẫyBài 02 + Bài 03Choose bật/không bật virtual threads theo hồ sơ tải

▶️ Starter — trạng thái xuất phát của TaskFlow v3

Mảng 1 — bình luận task, DTO mutable:

public class TaskCommentRequest {

    private Long taskId;
    private String authorEmail;
    private String body;

    public Long getTaskId() { return taskId; }
    public void setTaskId(Long taskId) { this.taskId = taskId; }

    public String getAuthorEmail() { return authorEmail; }
    public void setAuthorEmail(String authorEmail) { this.authorEmail = authorEmail; }

    public String getBody() { return body; }
    public void setBody(String body) { this.body = body; }
}
@RestController
@RequestMapping("/api/tasks")
public class TaskCommentController {

    private final TaskCommentService taskCommentService;

    TaskCommentController(TaskCommentService taskCommentService) {
        this.taskCommentService = taskCommentService;
    }

    @PostMapping("/{taskId}/comments")
    public TaskCommentResponse addComment(@PathVariable Long taskId, @RequestBody TaskCommentRequest request) {
        request.setTaskId(taskId);   // gan them sau khi Jackson da dung constructor rong
        return taskCommentService.addComment(request);
    }
}

Không có @Valid nào ở đây — bình luận rỗng hoặc email sai định dạng vẫn lưu được vào DB, chỉ phát hiện khi ai đó đọc lại và thấy dữ liệu bẩn.

Mảng 2 — ba client gọi ngoài, không timeout:

@Component
public class PaymentClient {

    private final RestTemplate restTemplate;

    PaymentClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public PaymentResult charge(String orderId) {
        return restTemplate.postForObject(
                "http://payment-service.internal/charge/" + orderId, null, PaymentResult.class);
    }
}

InventoryClient.reserve(orderId)ShippingClient.schedule(orderId) viết theo đúng khuôn trên, chỉ đổi URL đích (inventory-service.internal, shipping-service.internal) — ba downstream khác nhau, ba service khác nhau, không phải một service gọi ba lần. Cả ba đều được TaskFlowOrchestrator.process từ bài 02 gọi tuần tự, và cả ba đều thiếu đúng thứ Profile Service ở bài 04 từng thiếu: connect timeout, read timeout.

Thử đoán trước khi đọc tiếp

Bài 03 kể chuyện TaskFlow gãy vào đợt Cyber Monday khi traffic nhảy từ khoảng 300 lên 3.000 request/giây. Với ba client RestTemplate không timeout ở trên, nếu Payment Service ở downstream chậm đi một chút (không chết hẳn) trong đúng đợt burst đó, chuyện gì sẽ xảy ra cho TaskFlow? Viết dự đoán của bạn trước khi đọc bước 2.

Mảng 3 — chữ ký nói dối chưa được sửa:

public class OrderService {

    public void placeOrder(Order order) {
        Order locked = orderRepository.lockForUpdate(order.orderId());
        orderRepository.markPending(locked);   // ghi truoc de tranh 2 request cung dat 1 don

        if (!inventory.hasStock(order.productId(), order.quantity())) {
            throw new IllegalStateException("khong du hang");
        }
        if (creditLimit.exceeds(order.customerId(), order.total())) {
            throw new IllegalStateException("vuot han muc tin dung");
        }
        orderRepository.confirm(locked);
    }
}

Order đã là record từ trước (bài 06 dùng nó qua order.productId(), order.quantity()...), nên record hoá không phải việc của mảng này. Vấn đề nằm ở chữ ký void và hai throw — đúng như bài 06 mục 1 đã chỉ ra, chỉ khác một chi tiết: markPending khoá đơn hàng và ghi trạng thái "đang xử lý" trước khi hai điều kiện nghiệp vụ được kiểm tra, để tránh hai request cùng đặt một đơn. Giữ chi tiết này trong đầu — nó quay lại ở bước 4.

Yêu cầu — năm bước

Bước 1 — DTO → record

Refactor TaskCommentRequest thành record bất biến, đặt Bean Validation đúng chỗ theo cơ chế bài 01.

Tiêu chí hoàn thành quan sát được: không còn method set* nào trên DTO; authorEmail/body có constraint (@NotBlank, @Email, @Size) đặt trên record component; controller dùng @Valid; build fail rõ ràng nếu bạn giữ nguyên dòng request.setTaskId(taskId) cũ (record không có setter — đây là tín hiệu compiler, không phải thứ bạn tự nhớ để tránh).

Bước 2 — RestTemplate → RestClient

Chuyển cả ba client (Payment, Inventory, Shipping) sang RestClient, khai connect timeout và read timeout tường minh cho từng client, dịch lỗi 4xx/5xx bằng onStatus.

Tiêu chí hoàn thành quan sát được: cả ba client — không chỉ một — có connect timeout và read timeout khai riêng; gọi một downstream trả 4xx/5xx không còn ném thẳng HttpClientErrorException/HttpServerErrorException lên controller; RestClient dựng qua bean @Configuration, không có RestClient.create() rải rác trong service.

Bước 3 — RestClient thủ công → @HttpExchange

Gộp mỗi client thành một interface gắn @PostExchange, đăng ký bean qua HttpServiceProxyFactory bọc đúng RestClient đã cấu hình ở bước 2.

Tiêu chí hoàn thành quan sát được: PaymentClient/InventoryClient/ShippingClient là interface, không còn implementation viết tay; TaskFlowOrchestrator.process không đổi một dòng logic nghiệp vụ nào, chỉ đổi kiểu bean tiêm vào; gọi một downstream trả lỗi vẫn nhận đúng exception nghiệp vụ đã dịch ở bước 2 — không "mất" cấu hình khi đổi sang interface.

Bước 4 — IllegalStateException → sealed Result

OrderService.placeOrder trả về sealed PlaceOrderResult thay vì ném exception; controller switch map từng nhánh sang ProblemDetail.

Tiêu chí hoàn thành quan sát được: PlaceOrderResult là sealed interface, permits đủ nhánh khớp hai điều kiện nghiệp vụ cộng nhánh thành công; switch ở controller không có default — thêm một nhánh Result mới mà quên chỗ xử lý phải làm build gãy ngay; viết một test gọi placeOrder với input vượt hạn mức tín dụng, rồi tự kiểm tra (query lại, không chỉ đọc code): đơn hàng có bị kẹt ở trạng thái "đang xử lý" trong DB hay không.

Bước 5 — Bật virtual threads, đo throughput, soát ba cạm bẫy

Bật spring.threads.virtual.enabled=true cho luồng chứa TaskFlowOrchestrator, đo throughput ở ít nhất hai mức concurrency trước và sau khi bật, rồi tự kiểm ba cạm bẫy bài 03 đã dạy.

Tiêu chí hoàn thành quan sát được: có bảng số đo throughput thật (req/giây) ở tối thiểu hai mức tải đồng thời, cả trước lẫn sau khi bật — không phải latency của một request; bật JFR trong lúc load test và không có event jdk.VirtualThreadPinned nào trỏ vào code TaskFlow; có một giới hạn tường minh (Semaphore hoặc tương đương) đứng trước bước ghi DB trong OrderService, kích thước khớp maximumPoolSize của HikariCP.

Ba cạm bẫy cố ý — đọc kỹ trước khi bắt đầu

Ít nhất một trong năm bước trên chứa đúng cái bẫy bài trước đã cảnh báo mà không nhắc lại tên. Đừng coi việc code biên dịch và chạy được là bằng chứng đã xong — quan sát hiệu ứng phụ (dữ liệu ghi trong DB, stack trace lúc pin, throughput thật) mới là tiêu chí hoàn thành, đúng như liệt kê ở trên.

💡 Gợi ý theo bậc

Đọc gợi ý 1 trước, tự thử, chỉ mở gợi ý 2 nếu vẫn bí. Đừng nhảy thẳng xuống Lời giải.

Bước 1:

  • Gợi ý 1: taskId tới từ URL, authorEmail/body tới từ JSON body — hai nguồn dữ liệu khác nhau bị nhét vào một object mutable vì tiện. Record bất biến không cho bạn "gán thêm sau" như dòng request.setTaskId(taskId). Có cần giữ taskId trong chính DTO nhận từ body không, hay tham số @PathVariable ở method controller đã đủ?
  • Gợi ý 2: Ôn lại callout pitfall mục 3 của bài 01 — khai canonical constructor tường minh làm mất copy constraint tự động. Bạn có cần compact constructor ở đây không, hay @NotBlank/@Email/@Size trên component là đủ?

Bước 2:

  • Gợi ý 1: Ba downstream có cùng SLA không? Payment nằm trên đường quyết định đơn hàng có thành công hay không; Shipping chỉ đặt lịch, sai vài giây không ai chết. Connect/read timeout của cả ba có nhất thiết phải bằng nhau?
  • Gợi ý 2: Đếm lại: bạn vừa khai timeout cho đúng mấy trong ba client? Một lỗi hay gặp là sửa xong client đầu tiên rồi quên hai client còn lại — kiểm bằng cách đọc lại từng file, đừng tin trí nhớ.

Bước 3:

  • Gợi ý 1: HttpServiceProxyFactory cần một RestClient đã cấu hình sẵn để bọc qua RestClientAdapter. Bạn tiêm bean nào ở bước 3 — bean đã có timeout từ bước 2, hay một RestClient.create() mặc định mới? Nhầm 1 của bài 05 nói đúng chỗ này.
  • Gợi ý 2: Ba downstream có ba base URL khác nhau. Một RestClient.Builder bean chung có đủ, hay mỗi client cần một bean RestClient riêng với baseUrl khác nhau rồi mới bọc RestClientAdapter quanh từng cái?

Bước 4:

  • Gợi ý 1: Hai câu throw trong code gốc ứng với đúng hai nhánh lỗi. Nhánh thành công cần mang dữ liệu gì để controller trả về đúng response — cả Order hay chỉ một phần?
  • Gợi ý 2: Đọc lại thứ tự dòng code trong starter: markPending đứng ở đâu so với hai điều kiện if? Nếu giữ nguyên thứ tự đó khi đổi throw thành return, dòng ghi đó có kịp chạy trước khi bạn biết kết quả là thất bại không? Đọc lại mục Pitfall của bài 06 (đoạn nói về "khi write bắt buộc xảy ra trước") trước khi quyết định sửa bằng cách nào.

Bước 5:

  • Gợi ý 1: Gửi một request, so latency trước/sau khi bật cờ — phép đo đó có trả lời đúng câu hỏi "virtual threads có ích cho TaskFlow không" không? Bài 02 mục 5 đã cảnh báo đúng sai lầm này bằng tên.
  • Gợi ý 2: Ba cạm bẫy bài 03 xảy ra ở phần nào của TaskFlow bạn vừa chạm tới trong bốn bước trên? AuditLogger (đã sửa ở bài 03) và pool HikariCP đứng sau OrderService — chỗ nào trong số đó cần soát lại sau khi bạn vừa đổi executor?

✅ Lời giải tham khảo

Phần dưới là một cách làm hợp lý cho cả năm bước — không phải cách duy nhất. Chỉ mở phần này sau khi đã tự thử với Gợi ý ở trên.

Bước 1 — record hoá TaskCommentRequest

public record TaskCommentRequest(
        @NotBlank @Email String authorEmail,
        @NotBlank @Size(max = 2000) String body
) {
    public TaskCommentRequest {
        authorEmail = authorEmail == null ? null : authorEmail.strip().toLowerCase();
        body = body == null ? null : body.strip();
    }
}
@PostMapping("/{taskId}/comments")
public TaskCommentResponse addComment(@PathVariable Long taskId, @RequestBody @Valid TaskCommentRequest request) {
    return taskCommentService.addComment(taskId, request);
}

taskId không còn nằm trong DTO nhận JSON nữa — nó tới từ URL, một nguồn dữ liệu khác, và @PathVariable là đúng chỗ khai báo cho nó. Record chỉ giữ đúng phần dữ liệu thật sự tới từ body. TaskCommentService.addComment(taskId, request) ghép hai nguồn lại ở tầng service, nơi cả hai đều đã có mặt:

public TaskCommentResponse addComment(Long taskId, TaskCommentRequest request) {
    TaskComment comment = new TaskComment(taskId, request.authorEmail(), request.body(), Instant.now());
    taskCommentRepository.save(comment);
    return TaskCommentResponse.from(comment);
}

Bước 2 — RestClient với timeout riêng từng downstream

@Configuration
public class DownstreamClientConfig {

    @Bean
    RestClient paymentRestClient(RestClient.Builder builder) {
        return buildClient(builder, "http://payment-service.internal",
                Duration.ofSeconds(1), Duration.ofSeconds(3));
    }

    @Bean
    RestClient inventoryRestClient(RestClient.Builder builder) {
        return buildClient(builder, "http://inventory-service.internal",
                Duration.ofSeconds(1), Duration.ofSeconds(2));
    }

    @Bean
    RestClient shippingRestClient(RestClient.Builder builder) {
        return buildClient(builder, "http://shipping-service.internal",
                Duration.ofSeconds(2), Duration.ofSeconds(5));
    }

    private RestClient buildClient(RestClient.Builder builder, String baseUrl,
                                    Duration connectTimeout, Duration readTimeout) {
        ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
                .withConnectTimeout(connectTimeout)
                .withReadTimeout(readTimeout);
        ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.detect().build(settings);
        return builder.baseUrl(baseUrl).requestFactory(requestFactory).build();
    }
}

Payment nằm trên đường quyết định thành công của đơn hàng nên read timeout ngắn hơn Shipping, thứ chỉ đặt lịch và có thể chấp nhận chờ lâu hơn — không có một cặp số đúng cho mọi downstream, đúng như bài 04 đã nói.

@Component
public class PaymentClient {

    private final RestClient paymentRestClient;

    PaymentClient(RestClient paymentRestClient) {
        this.paymentRestClient = paymentRestClient;
    }

    public PaymentResult charge(String orderId) {
        return paymentRestClient.post()
                .uri("/charge/{orderId}", orderId)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> throwDownstream("payment", orderId, res))
                .onStatus(HttpStatusCode::is5xxServerError, (req, res) -> throwDownstream("payment", orderId, res))
                .body(PaymentResult.class);
    }

    private void throwDownstream(String service, String orderId, ClientHttpResponse response) throws IOException {
        throw new DownstreamServiceException(service, response.getStatusCode(),
                service + " tra loi cho don " + orderId + ": " + response.getStatusCode());
    }
}

InventoryClient/ShippingClient theo đúng khuôn trên, chỉ đổi client tiêm vào và path. DownstreamServiceException là một exception nghiệp vụ dùng chung cho cả ba, mang theo tên service gốc để @RestControllerAdvice log rõ downstream nào vừa hỏng — cùng pattern ProfileServiceException bài 04 đã dựng.

Bước 3 — chuyển thành HTTP Interface

public interface PaymentClient {

    @PostExchange("/charge/{orderId}")
    PaymentResult charge(@PathVariable String orderId);
}
@Bean
PaymentClient paymentClient(RestClient paymentRestClient) {
    RestClientAdapter adapter = RestClientAdapter.create(paymentRestClient);
    HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
    return factory.createClient(PaymentClient.class);
}

inventoryClient/shippingClient đăng ký theo đúng khuôn, mỗi bean bọc đúng RestClient riêng của downstream đó — ba adapter khác nhau bọc ba RestClient khác nhau, không dùng chung một adapter cho cả ba base URL. TaskFlowOrchestrator.process không đổi gì: paymentClient.charge(orderId) vẫn đúng lời gọi đó, chỉ khác paymentClient giờ là instance proxy thay vì @Component viết tay. Timeout và onStatus sống trong bean RestClient bị bọc, không mất đi khi đổi tầng gọi.

Bước 4 — sealed Result thay exception

public sealed interface PlaceOrderResult
        permits PlaceOrderResult.Success,
                PlaceOrderResult.OutOfStock,
                PlaceOrderResult.CreditLimitExceeded {

    record Success(Order order) implements PlaceOrderResult {}

    record OutOfStock(String productId, int requested) implements PlaceOrderResult {}

    record CreditLimitExceeded(String customerId, long total, long limit) implements PlaceOrderResult {}
}
@Transactional
public PlaceOrderResult placeOrder(Order order) {
    Order locked = orderRepository.lockForUpdate(order.orderId());
    orderRepository.markPending(locked);

    if (!inventory.hasStock(order.productId(), order.quantity())) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        return new PlaceOrderResult.OutOfStock(order.productId(), order.quantity());
    }
    if (creditLimit.exceeds(order.customerId(), order.total())) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        return new PlaceOrderResult.CreditLimitExceeded(
                order.customerId(), order.total(), creditLimit.limitOf(order.customerId()));
    }
    orderRepository.confirm(locked);
    return new PlaceOrderResult.Success(locked);
}

Đây đúng là chỗ bẫy nằm. markPending phải chạy trước hai điều kiện (bỏ nó xuống dưới sẽ mở lại race hai request cùng đặt một đơn, đúng lý do dòng comment trong starter đã giải thích) nên reorder không dùng được ở đây, khác với ví dụ assign trong bài 06 vốn chưa ghi gì trước khi kiểm quyền. Cách đúng là gọi setRollbackOnly() ngay trước mỗi return nhánh lỗi: transaction vẫn commit dòng markPendingreturn bình thường với proxy, nhưng setRollbackOnly() buộc nó rollback dù không có exception nào được ném. Thiếu dòng này, locked kẹt vĩnh viễn ở trạng thái "đang xử lý" cho một đơn hàng chưa từng thật sự đặt được — đúng hệt cơ chế bài 06 đã cảnh báo, chỉ khác do không thể reorder nên phải dùng công cụ còn lại.

@PostMapping("/orders")
ResponseEntity<Object> placeOrder(@RequestBody @Valid Order order) {
    return switch (orderService.placeOrder(order)) {
        case PlaceOrderResult.Success s ->
                ResponseEntity.ok(OrderResponse.from(s.order()));
        case PlaceOrderResult.OutOfStock r ->
                problem(HttpStatus.CONFLICT, "out-of-stock",
                        "San pham " + r.productId() + " khong du " + r.requested() + " don vi ton kho");
        case PlaceOrderResult.CreditLimitExceeded r ->
                problem(HttpStatus.CONFLICT, "credit-limit-exceeded",
                        "Khach hang " + r.customerId() + " vuot han muc: " + r.total() + "/" + r.limit());
    };
}

Bước 5 — bật virtual threads và đo đúng

# application.yml cua module chua TaskFlowOrchestrator
spring:
  threads:
    virtual:
      enabled: true

Đo throughput ở hai mức concurrency, trước và sau khi bật cờ — dùng công cụ load-test bất kỳ (wrk, hey, JMeter), miễn đo được số request thành công mỗi giây chứ không phải latency một request đơn lẻ:

wrk -t8 -c200  -d30s http://localhost:8080/api/orders/checkout-flow
wrk -t8 -c2000 -d30s http://localhost:8080/api/orders/checkout-flow
ConcurrencyReq/giây — trước (pool 200)Req/giây — sau (virtual threads)Tỉ lệ lỗi
200điền số đo thật của bạnđiền số đo thật của bạnđiền số đo thật
2000điền số đo thật của bạnđiền số đo thật của bạnđiền số đo thật

Không có con số "đúng" để chép vào bảng này — đây là bảng mẫu, số liệu phải tới từ lần chạy thật trên máy bạn. Kỳ vọng đúng theo bài 02 mục 4: chênh lệch chỉ lộ rõ ở mức 2.000, còn ở mức 200 hai cấu hình nên gần như ngang nhau.

Soát pinning bằng đúng công cụ bài 03 đã dạy — JFR, chạy được trên mọi bản từ JDK 21 — song song với đợt load test ở mức 2.000:

java -XX:StartFlightRecording=duration=120s,filename=taskflow.jfr -jar taskflow.jar
jfr summary taskflow.jfr | grep VirtualThreadPinned

AuditLogger đã đổi sang ReentrantLock ở bài 03 nên không còn xuất hiện ở đây — nếu bạn thấy một stack trace mới trỏ vào code TaskFlow, đó là một synchronized khác chưa ai phát hiện.

Cuối cùng, dựng lại van chặn tải mà pool 200 cũ từng vô tình đảm nhiệm, khớp maximumPoolSize của HikariCP:

private final Semaphore dbGate = new Semaphore(10);

@Transactional
public PlaceOrderResult placeOrder(Order order) {
    dbGate.acquireUninterruptibly();
    try {
        // than method giu nguyen nhu Buoc 4
    } finally {
        dbGate.release();
    }
}

Không có dbGate này, đúng kịch bản bài 03 mục 3 lặp lại: virtual threads bỏ hàng đợi 200 của Tomcat, cả nghìn request cùng chạm HikariCP 10 kết nối cùng lúc, phần lớn timeout sau 30 giây dù DB hoàn toàn khoẻ mạnh.

🎓 Mở rộng

  • Circuit breaker cho ba downstream: Resilience4j đứng trước PaymentClient/InventoryClient/ShippingClient để cả ba tự ngắt tạm thời khi tỉ lệ lỗi vượt ngưỡng, thay vì để mỗi request tự chờ hết timeout rồi mới biết downstream đang hỏng.
  • ScopedValue thay vì ThreadLocal tự viết cho request-id truyền qua TaskFlowOrchestrator — đúng hướng dài hạn bài 03 mục 2 đã nhắc, áp dụng cho phần code bạn tự kiểm soát được (khác SecurityContextHolder/MDC của framework).
  • Instrument throughput đo ở bước 5 bằng Micrometer thay vì chỉ đọc output của wrk — biến số đo một lần thành dashboard theo dõi liên tục. Đây là chủ đề module tiếp theo của khoá.

✨ Checklist tự nghiệm thu

Đối chiếu với learningOutcomes của cả module — mỗi mục dưới nên trả lời được bằng bằng chứng cụ thể từ code bạn vừa viết, không chỉ "tôi nghĩ là đúng":

  • Record làm DTO: TaskCommentRequest là record; bạn giải thích được vì sao TaskComment (entity JPA phía dưới) vẫn phải là class mutable thường, không thể áp cùng công thức.
  • Bật/không bật virtual threads đúng hồ sơ tải: bạn nêu được lý do cụ thể (không phải "vì bài học nói vậy") khiến TaskFlowOrchestrator đủ điều kiện I/O-bound để bật, dựa theo bảng quyết định bài 02.
  • HTTP client outbound có timeout + xử lý lỗi: cả ba downstream đều có connect/read timeout riêng và onStatus dịch lỗi; sau khi chuyển sang @HttpExchange, cấu hình đó vẫn nguyên hiệu lực — bạn kiểm được bằng cách gọi thử một downstream trả lỗi.
  • Sealed Result cho kết quả nghiệp vụ: PlaceOrderResult exhaustive, controller switch không default; bạn đã tự kiểm bằng test rằng nhánh lỗi không để lại dữ liệu "đang xử lý" kẹt trong DB.
  • Chẩn đoán ba hỏng hóc virtual threads: bản ghi JFR sạch event jdk.VirtualThreadPinned, có Semaphore chặn đúng kích thước pool, và có số đo throughput thật ở ít nhất hai mức concurrency — không phải suy đoán.

Bài tiếp theo: Tổng kết module — Modern Java trong Spring

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Tổng kết — Modern Java trong Spring