Về bản web

caltalys.dev

return user; - một dòng code, bốn tầng trung gian, và một vòng tròn khép lại

Bài cuối: bốn khuôn mặt của lớp trung gian xếp thẳng hàng trong đúng một dòng return, và vì sao nổ StackOverflowError lại là điều may mắn.

Caltalys · 23/10/2026 · 16 phút đọc

Thằng em nhắn. Nhưng lần này, lần đầu tiên sau hai mươi bài, nó không hỏi gì cả.

thang em Anh. Team em có junior mới. Hôm nay nó dính một quả, và em ngồi gỡ với nó nguyên buổi chiều. Gỡ xong em nhận ra em phải kể anh nghe - vì lúc ngồi cạnh nó, em thấy chính em của hai năm trước. Và vì cái bug đó… anh ơi, nó là tất cả mọi thứ anh từng dạy em, va vào nhau trong đúng một dòng code.

Rồi nó kể. Mình chép lại gần như nguyên văn, vì câu chuyện này xứng đáng được kể bằng giọng của nó.


Junior của nó viết một endpoint đơn giản nhất trên đời:

@RestController
public class UserController {
    @GetMapping("/users/{id}")
    public User get(@PathVariable Long id) {
        return repo.findById(id).orElseThrow();
    }
}

Chạy local với dữ liệu seed: đẹp. Deploy lên staging, gọi thử với user thật: nổ.

com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
Caused by: java.lang.StackOverflowError
  at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(...)
  at ...User.getOrders(...)
  at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(...)
  at ...Order.getUser(...)
  at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(...)
  at ...User$HibernateProxy$aBcD1234.getOrders(...)
  ... (hơn một nghìn dòng nữa, cùng một điệp khúc)

(Một chuyện phải nói trước khi bạn đi so với máy mình: package com.fasterxml.jackson... là của Jackson 2, bản đi cùng Boot 3.x - môi trường của cả series này. Từ Boot 4, theo ghi chú migration của Jackson 3, nó đổi thành tools.jackson.databind...; riêng @JsonIgnore vẫn nằm nguyên chỗ cũ.)

Junior nhìn cái stacktrace dài nhất đời nó, rồi nhìn cái method bốn dòng của nó, rồi nói: “Em có làm gì đâu anh. Em chỉ… return.”

Thằng em bảo: đúng. Và đó chính là chỗ để bắt đầu. Hôm nay không đi từ code xuống - đi ngược từ vụ nổ lên, bóc từng nấc, xem cái chữ return kia đã đi qua tay những ai.

Nấc thứ nhất - đọc điệp khúc. Cái stacktrace kia không phải nhiễu. Nó là một bài hát có lời: serialize → getOrders → serialize → getUser → serialize → getOrders… Có một kẻ nào đó đang đi vòng tròn qua các getter của entity, đi mãi không dừng, cho tới khi cái stack - vốn hữu hạn - hết chỗ chứa dấu chân. StackOverflowError không phải lỗi logic; nó là tiếng gãy của sàn nhà dưới chân một người đi lặp vô tận. Và để ý cái tên ở dòng trên cùng: thứ bay tới tay bạn là JsonMappingException - tên của kẻ chép thuê. Tiếng gãy thật nằm ở getCause(). Lại một lần nữa, cái tên ở tầng ngoài không phải tên của chuyện đã xảy ra. Câu hỏi đầu tiên của mọi cuộc điều tra: kẻ đang đi là ai, và ai thuê nó đi?

Nấc thứ hai - kẻ được thuê. Tên trên stacktrace: Jackson. Nhưng khoan - junior đâu có gọi Jackson? Nó chỉ return user;. Vậy ai gọi?

Chỗ này thằng em dừng lại và dựng cho junior một thứ mà - nó thú nhận với mình - chính nó cũng phải lục lại ký ức, vì mình dạy nó từ tận đầu series, hai mươi bài trước. Nên mình dựng lại ở đây, coi như bạn chưa từng đọc: khi method controller của bạn return một object, object đó không tự biến thành JSON. DispatcherServlet - cửa khẩu của mọi request - không biết làm gì với một cục User. Nó cầm giá trị trả về, đi dọc một cái List những người-xử-lý-hàng-trả-về, hỏi từng người đúng một câu: “hàng này mày nhận không?” Ai giơ tay trước, thắng, và toàn quyền định đoạt. Với @RestController, người giơ tay là người phụ trách @ResponseBody - và cách anh ta “định đoạt” là thuê Jackson: cầm object của bạn, đi dọc mọi getter, chép những gì thấy được thành JSON. (Còn nếu bạn đã ở đây từ bài @Controller mở màn series - đúng vậy: chính cái List đó, chính luật ai-giơ-tay-trước đó, quay lại đúng nơi mọi chuyện bắt đầu.)

Vậy là rõ kẻ đi vòng tròn: Jackson, một người chép thuê tận tụy, được cái List thuê để chép object của bạn. Câu hỏi kế: vì sao đường của nó lại là một vòng tròn?

Nấc thứ ba - tấm bản đồ có vòng. Vì entity của junior là một cặp quan hệ hai chiều - thứ bạn học ở mùa mapping: User giữ List<Order> orders, và mỗi Order giữ lại User user. Trong DB, đó là một cột FK hiền lành. Nhưng trong RAM, đó là hai mũi tên trỏ vào nhau - một vòng tròn bằng tham chiếu. Với code của bạn, vòng đó vô hại: bạn biết đường, bạn không đi lại chỗ cũ. Nhưng Jackson là khách bộ hành không có trí nhớ: hợp đồng của nó là “thấy getter thì đi”, và nó không ghi lại mình từng qua đâu. user.getOrders() → với từng order: order.getUser() → lại là user → getOrders() → … Người chép thuê tận tụy gặp tấm bản đồ có vòng: nó sẽ chép cho tới khi sàn nhà gãy.

Nấc thứ tư - và những bước chân đều tính phí. Nó hỏi junior: “trước khi nổ, mày có nhìn log SQL không?” Junior mở lại - và ngơ ra: đúng hai câu SELECT. Vụ nổ tới trước cả câu thứ ba.

Hai câu đó ở đâu ra? Vì các getter mà Jackson dẫm lên không phải getter thường. getOrders() của một entity là tờ giấy hẹn - bạn nhớ chứ: lazy field không chứa dữ liệu, nó chứa một lời hứa trỏ về cuốn sổ, chạm vào là cuốn sổ chạy SELECT trả nợ. Mà sổ còn mở không, ở tận tầng serialize, ngoài transaction của service? Còn - vì Open-Session-In-View đang bật mặc định, cái lời-mời-sẵn của Boot mà không ai nhớ mình đã nhận. Thế là mỗi giấy hẹn Jackson dẫm phải lần đầu tiên đều thành một câu SQL lén, phát ra từ một tầng mà không dòng code nào của junior đứng tên.

Nhưng chỉ có hai, vì Jackson đi theo chiều sâu. Nó chạm user.getOrders() - một SELECT, cả đống order về sổ - rồi bước vào order đầu tiên, và ngay trong đó nó gặp order.getUser(). Vòng khép lại tại phần tử con số một. Order thứ hai chưa từng tới lượt; getItems() của từng order cũng chưa. Cái hóa đơn bề rộng mà bạn đang chờ nghe - nó có thật, nhưng nó chưa được xuất.

Và đây là chỗ phải tách cho thật sắc, vì gộp hai thứ này lại là hiểu sai cả hai: cái vòng tròn không phải thứ sinh ra hóa đơn đó. Khi Jackson từ một order đi ngược về getUser(), nó nhận lại đúng cái User đang nằm sẵn trong sổ - không SELECT. Gọi tiếp getOrders() trên đó: collection đã initialize từ vòng đầu - cũng không SELECT. Từ nhịp thứ hai trở đi, vòng lặp không tốn thêm một câu SQL nào; nó chỉ tốn stack. Hai hóa đơn khác nhau đi chung một chuyến đi: bề rộng của đồ thị lazy tính tiền bằng query, còn cái vòng tính tiền bằng chiều sâu. Cái thứ hai làm sàn nhà gãy. Cái thứ nhất mới là cái âm thầm đắt.

Và thằng em chốt với junior một câu làm mình muốn vỗ đùi: “cái StackOverflowError này, mày phải cảm ơn nó. Vì nếu quan hệ của mày không tạo vòng - nó sẽ không nổ. Nó chỉ lặng lẽ serialize nửa cái database vào một cục JSON, kèm một trận query storm, đều đặn mỗi request, và không ai biết gì cho tới hóa đơn tháng sau. Vụ nổ là phiên bản may mắn.”

Bốn nấc, và bây giờ xếp lại được cho thẳng hàng: một cái List quyết ai xử lý giá trị trả về - khuôn mặt đầu tiên của series. Một tờ giấy hẹn đóng thế cho dữ liệu thật - khuôn mặt thứ hai. Một cuốn sổ còn mở và trung thành trả nợ - khuôn mặt thứ ba. Một thời điểm - serialize xảy ra ngoài transaction, ở nơi không ai canh - khuôn mặt thứ tư. Bốn tầng trung gian, tầng nào cũng làm đúng hợp đồng của nó, và một dòng return user; đi xuyên qua cả bốn. Còn tấm bản đồ mà cả bốn đi theo thì do chính junior vẽ - và nó cũng không vẽ sai: một quan hệ hai chiều là thứ hoàn toàn hợp lệ. Nó chỉ chưa bao giờ vẽ tấm bản đồ đó cho một khách bộ hành không có trí nhớ. Không có bug ở đâu cả - câu điệp khúc của hai mươi bài, hôm nay hát lần cuối.

Junior, tới đây, làm đúng điều mọi junior làm: google, và tìm thấy fix nhiều vote nhất - @JsonIgnore lên Order.user. Cắt một chiều là hết vòng. Nó gắn, chạy, hết nổ, sướng rơn.

Thằng em không nói gì, chỉ chỉ vào log SQL. Giờ mới là lúc cái cây được duyệt tới tận cùng, và giờ mới là lúc hóa đơn bề rộng chịu hiện ra đủ: hai câu ban nãy nở thành cả một tràng, mỗi order thêm một cú getItems(). Đúng cái N+1 của mùa trước, nở ra ở một tầng mà không dòng code nào của junior đứng tên. Vụ nổ ban nãy đã ngắt cầu dao trước khi hóa đơn kịp chạy hết - bây giờ cầu dao bị gỡ, và nó chạy hết.

Rồi nó để junior sướng thêm đúng một phút nữa, và hỏi một câu: “cái annotation đó mày gắn lên đâu?” - “Lên entity ạ.” - “Mà entity đó những ai đang dùng?”

Im lặng. Vì Order không phải của riêng endpoint này. Nó là kiểu dùng chung của cả app - và một annotation gắn lên entity là luật toàn cục. Ba tuần nữa, một API khác - /orders/{id} chẳng hạn - cần trả order kèm user, và trường đó đã bị tuyên án tử từ ba tuần trước, bởi một người sửa một bug ở một màn hình khác. Không exception. Chỉ là một field lặng lẽ biến mất khỏi payload ở nơi nó từng có mặt - @JsonIgnore không gán null cho ai cả, nó gạch tên property ra khỏi bản chép, và phía bên kia đường dây thì một field không tồn tại còn khó truy hơn một field bằng null. “Mày vừa fix một API bằng cách sửa hợp đồng của mọi API,” thằng em nói. “Đó không phải fix. Đó là chuyển bug từ chỗ ồn sang chỗ im.”

Fix thật thì series này đã hứa từ mùa cuốn sổ, và hôm nay trả nốt: đừng cho entity đi xa. Cử đại sứ. Entity là công dân của cuốn sổ - nó sinh ra để sống trong transaction, mang giấy hẹn, mang quan hệ hai chiều, mang cả cái hộ khẩu của persistence context. Bắt nó ra tầng web làm đại diện là bắt một công dân nội địa đi làm ngoại giao với toàn bộ giấy tờ tùy thân trong túi. Thay vào đó: một cái DTO - dựng trong transaction, đắp đúng những field mà API này cần, không hơn. Vòng tròn biến mất, vì cấu trúc DTO do bạn vẽ và bạn không vẽ vòng. Giấy hẹn biến mất, vì mọi món nợ đã trả trước khi rời service. Luật toàn cục biến mất, vì mỗi API một đại sứ riêng, hợp đồng riêng. Ba tầng bệnh, một liều thuốc - và liều thuốc chỉ là một nguyên tắc ranh giới. Còn hóa đơn bề rộng thì DTO không xoá được: nó chỉ dời chỗ trả nợ về nơi bạn nhìn thấy, và ở đó bạn mới gọi được cái tên đã học từ mùa trước - một câu JOIN FETCH, hoặc dựng thẳng DTO bằng projection.

Muốn tự kiểm chứng, và bài cuối thì đáng làm đủ bốn nhịp: bật spring.jpa.show-sql, gọi cái endpoint trả entity, đếm số câu SELECT chạy được trước lúc nổ - hai. Bắt lấy exception rồi in e.getCause() - StackOverflowError nằm ở đó, không nằm ở dòng trên cùng. Gắn @JsonIgnore vào, chạy lại, đếm lần nữa - giờ mới tới lượt hóa đơn bề rộng hiện ra. Rồi tắt spring.jpa.open-in-view và chạy lần cuối: vẫn đúng một dòng return user;, nhưng lần này nó ném LazyInitializationException chứ không nổ stack nữa - bạn vừa gỡ một trong bốn tầng, và hiện trường lập tức đổi mặt theo.

Còn một điều nữa, và mình để nó ở đây vì đây là chỗ duy nhất nó vừa. Khi đi dựng lại toàn bộ thí nghiệm của series này để chắc chắn từng con số trong hai mươi mốt bài đều chạy ra được, thứ làm mình mất thời gian nhất không phải cơ chế nào cả: cái làm một thí nghiệm thất bại gần như không bao giờ là cơ chế bị hiểu sai - nó là một mặc định phụ chưa được nói ra. Đọc một field qua cái vỏ CGLIB để xem thân hàm có chạy không, và nhận về false cả khi nó đã chạy - vì cái vỏ là một subclass, nó có bản sao field của riêng nó, và bạn vừa hỏi nhầm người. Kiểm lazy loading trong một unit test rồi thấy mọi thứ ngon lành - vì ngoài một request thật thì chẳng ai bật Open-Session-In-View, mà cũng chẳng ai đóng sổ giữa chừng để bạn có cái mà bắt. Không lần nào cơ chế trong bài sai. Mỗi lần đều là một tầng trung gian khác chen vào giữa câu hỏi và câu trả lời - chỉ khác là lần này nó chen vào cái thước đo, chứ không phải cái được đo.

Cho nên nếu thí nghiệm của bạn ra kết quả xanh mướt ngay từ lần chạy đầu, đừng mừng vội: câu hỏi tiếp theo không phải “cơ chế này có thật không”, mà là “cái tôi vừa đo có đúng là cái tôi định đo không”. Đó là bài thứ hai mươi hai, và là bài duy nhất mình không dựng được hiện trường cho nó - bạn chỉ gặp nó khi tự tay đi đo.

Kể đến đây, thằng em xuống dòng, và mình biết nó để dành phần này cuối cùng có chủ đích:

“Xong xuôi hết, junior nó ngồi thừ ra một lúc. Rồi nó quay sang em, mặt y hệt cái mặt em nhìn thấy trong gương hai năm trước, hỏi đúng một câu:

‘Anh ơi… hay là Spring nó ghét em?’

Em đứng hình mất năm giây, anh ạ. Rồi em nghe thấy chính mình trả lời - bằng đúng câu của anh, chữ nào chữ nấy:

‘Spring không ghét em đâu. Không có gì ghét em cả. Chỉ là giữa em và thứ em tưởng em đang chạm vào, luôn có vài lớp đứng giữa mà em chưa nhìn thấy. Hôm nay có bốn lớp. Và từ giờ, mỗi lần gặp một bug im lặng, câu đầu tiên em hỏi đừng là “mình sai ở đâu” - hãy hỏi: ở đây có tầng trung gian nào mình đang quên không? Chỉ một câu đó thôi. Còn lại là đi tìm.’

Nó gật gật, ghi câu đó vào sổ tay thật. Còn em thì ngồi đó, tự nhiên thấy… hết một vòng, anh nhỉ.”

Hết một vòng thật. Hai năm trước, một đứa debug tới hai giờ sáng và nghi cả framework ghét mình. Hôm nay, đứa đó ngồi cạnh một đứa khác đang debug, và trao lại đúng câu hỏi đã cứu nó. Mình không dạy nó Spring - Spring Boot bản 4 rồi sẽ thành bản 5, annotation này sẽ thành annotation khác. Mình chỉ dạy nó một phản xạ, và phản xạ đó vừa tự chứng minh là thứ duy nhất đáng truyền tay:

bug im lặng là cái giá của những tầng trung gian mà ta quên rằng chúng tồn tại.

Còn bạn - người đã đi cùng tới dòng này: cái List, cái proxy, cuốn sổ, cái thời điểm. Bốn khuôn mặt, hai mươi hiện trường, một câu hỏi. Từ mai, nó là của bạn. 😃

Tài nguyên đi kèm

  • ZIP Source demo - tập 5.4

    JDK 17. mvn spring-boot:run → Episode54Runner in khối [EP 5.4]: ba nhịp cho cùng một dòng return user; - để nguyên, cắt bằng @JsonIgnore, và dựng DTO trong transaction.

    https://caltalys.dev/downloads/book1/5.4-spring-deep-dive-demo.zip

https://caltalys.dev/return-user-finale

Caltalys · 23/10/2026 · Spring · Hibernate · Jackson · REST API