Nguyen Le PhongNguyen Le Phong

Áp dụng Hexagonal Architecture vào một feature nhỏ

Một hướng dẫn thực tế về cách áp dụng Hexagonal Architecture cho một feature nhỏ: vẽ boundary, đặt tên port bằng ngôn ngữ business, giữ adapter ở rìa hệ thống, test phần core, và biết khi nào pattern này đáng với chi phí của nó.

Feature ban đầu nghe có vẻ đủ nhỏ để xong trước giờ ăn trưa. Một bạn customer support cần một nút để refund một order, báo cho người mua, và để lại một dòng audit trail ngắn cho finance. Rule trong buổi họp rất rõ: chỉ refund order đã thanh toán, không cho refund sau ba mươi ngày, và không gửi email trước khi refund được ghi nhận. Rồi có người mở codebase ra, và feature nhỏ ấy bắt đầu chạm vào API controller, payment SDK, database transaction, email template, và vài test đang chạy chậm vì phải dùng hạ tầng thật.

Đây là lúc Hexagonal Architecture trở nên thực tế. Không phải như một sơ đồ để treo lên, cũng không phải lý do để sắp xếp lại toàn bộ codebase. Nó hữu ích vì cho team một cách bình tĩnh để hỏi: quyết định business thật sự nằm ở đâu, và phần nào chỉ là thế giới bên ngoài đang giúp quyết định đó xảy ra?

Với feature refund, phần bên trong hexagon không phải Postgres, Stripe, SendGrid, Next.js, hay một queue. Phần bên trong là rule quyết định refund có được phép hay không và chuyện gì phải xảy ra khi refund được chấp nhận. Phần bên ngoài là mọi thứ đưa request vào hoặc thực hiện side effect. Controller ở bên ngoài vì nó dịch HTTP thành một command. Payment provider ở bên ngoài vì nó chuyển tiền qua một vendor. Database ở bên ngoài vì nó lưu lại sự kiện. Email sender ở bên ngoài vì nó nói chuyện với dịch vụ gửi mail. Audit logger ở bên ngoài vì nó ghi quyết định vào nơi đủ bền.

Một bước đầu hữu ích là viết feature bằng ngôn ngữ đời thường trước khi viết interface. Một support agent yêu cầu refund cho một order. Hệ thống load order, kiểm tra order có được refund không, ghi nhận refund, yêu cầu payment provider hoàn tiền, ghi audit entry, và gửi notification. Câu này đã cho thấy boundary. Những từ như order, refund, ba mươi ngày, và audit entry thuộc về sản phẩm. Những từ như HTTP, SQL, Stripe, và SMTP thì không.

Từ đó, port nên nghe giống feature hơn là nghe giống tool. Một driven port có thể là OrderStore với findByIdrecordRefund. Một port khác có thể là RefundPaymentGateway với refund. Một port nữa có thể là BuyerNotifier. Nếu tên interface chứa tên vendor, boundary có lẽ đã bắt đầu leak. StripeRefundGateway là tên adapter hợp lý, nhưng không phải tên port tốt. Port mô tả điều core cần. Adapter mô tả cách thế giới bên ngoài đáp ứng điều đó.

Phía input cũng cần sự cẩn thận tương tự. Controller không nên chứa refund rule. Nó nên parse request, xác thực người thao tác nếu đó là trách nhiệm của nó, tạo một command nhỏ như RequestRefund, gọi use case, rồi dịch kết quả ngược lại thành HTTP. Cách này giữ web framework không trở thành nơi business policy âm thầm tích tụ theo thời gian. Nếu sau này một CLI command hoặc internal admin tool cần cùng behavior, nó có thể gọi cùng use case qua một driving adapter khác.

Core use case có thể rất bình thường, và đó là một điểm tốt. Nó nhận command, hỏi OrderStore để lấy order, hỏi order xem refund có được phép không, ghi nhận refund được chấp nhận, gọi payment gateway, ghi audit entry, và yêu cầu notifier gửi message ở đúng thời điểm. Điều quan trọng không phải là có bao nhiêu class. Điều quan trọng là code có thể được đọc mà không cần biết payment vendor, database library, hay HTTP framework. Reviewer có thể tập trung vào chính refund policy.

Testing vì vậy cũng nhẹ hơn. Thay vì chạy một test cần database thật, payment sandbox, và email account, test cho core có thể dùng các in-memory adapter nhỏ. Một test nói rằng order đã paid và còn trong refund window sẽ trả về kết quả accepted và ghi refund như mong đợi. Một test khác nói rằng order quá ba mươi ngày sẽ bị reject và payment gateway không được gọi. Một test khác kiểm tra notification chỉ được gửi sau khi refund đã được ghi nhận. Những test này không hề giả theo nghĩa xem nhẹ thực tế. Chúng test phần hệ thống nơi lời hứa sản phẩm đang sống.

Các adapter thật vẫn cần test, nhưng đó là loại test khác. Test cho database adapter kiểm tra mapping, transaction, và query behavior. Test cho Stripe adapter kiểm tra request shape, error handling, và idempotency theo contract mình sở hữu. Test cho email adapter kiểm tra template và delivery call. Những test này nằm ở rìa hệ thống, nơi độ chậm và chi tiết integration là điều bình thường. Chúng không cần trở thành cách chính để team biết refund rule có đúng hay không.

Một lỗi hay gặp là vẽ quá nhiều port quá sớm. Một feature nhỏ không cần interface cho mọi helper, formatter, hay phép tính ngày tháng. Hexagonal Architecture hữu ích nhất ở boundary nơi core đi qua I/O, vendor, delivery mechanism, hoặc persistence. Nếu một function là pure, ổn định, và chỉ sống trong use case, bọc nó bằng interface thường chỉ làm code ồn hơn. Mục tiêu không phải làm code trông có kiến trúc. Mục tiêu là làm thay đổi bớt rủi ro ở những chỗ có khả năng thay đổi.

Một lỗi khác là để type bên ngoài đi vào bên trong. Nếu core nhận raw HTTP request, trả về database row, hoặc throw vendor-specific exception, boundary chỉ đang tồn tại trong cấu trúc thư mục. Core nên nhận command của chính nó và trả về result của chính nó. Adapter có thể dịch shape lộn xộn bên ngoài thành shape sạch bên trong, rồi dịch ngược lại. Việc dịch này có thể giống thêm việc ở ngày đầu, nhưng thường giúp bớt mơ hồ ở ngày thứ ba mươi, khi adapter thứ hai xuất hiện và team nhận ra adapter đầu tiên đã âm thầm định nghĩa product model.

Chi phí là thật. Sẽ có thêm tên cần đặt, thêm file cần mở, và thêm wiring cần làm. Với một màn CRUD mỏng chỉ sửa một bảng và gần như không có policy đáng kể, cấu trúc này có thể quá nặng. Một route handler trực tiếp đôi khi là thiết kế tử tế hơn. Nhưng với một feature có business rule, vendor call, side effect, và khả năng sống lâu, boundary thêm vào thường trả lại giá trị qua test nhanh hơn, review nhỏ hơn, và thay vendor an toàn hơn.

Tôi thường tìm ba tín hiệu trước khi áp dụng pattern này nghiêm túc. Thứ nhất, feature có rule mà mọi người bàn bằng ngôn ngữ product, không chỉ là field trong database. Thứ hai, ít nhất một dependency bên ngoài có khả năng thay đổi, fail, hoặc cần test cẩn thận. Thứ ba, team đã từng thấy đau vì test chậm hoặc change bị rải khắp nơi. Nếu có những tín hiệu đó, một hexagonal slice nhỏ là đủ. Không cần redesign cả application. Chọn feature refund, vẽ boundary quanh nó, đặt tên port, viết test cho core, và để adapter ở rìa.

Giá trị lặng lẽ của Hexagonal Architecture là nó làm phần quan trọng dễ nhìn hơn. Trong ví dụ refund, điều quan trọng không phải là code dùng một pattern nghe có vẻ đẹp. Điều quan trọng là một teammate có thể đọc use case và hiểu lời hứa đang được đưa ra cho customer, support, và finance. Database, payment SDK, và email provider vẫn quan trọng, nhưng chúng không còn được nói to hơn rule mà chúng đang phục vụ.

Nếu bạn muốn thử trong một codebase sẵn có, hãy bắt đầu với một feature đã hơi đau. Viết một câu business mô tả nó. Khoanh những từ thuộc về sản phẩm. Đặt port quanh phần việc bên ngoài. Test core mà không cần thế giới bên ngoài. Rồi dừng lại. Architecture trở nên hữu ích khi nó làm thay đổi tiếp theo bình tĩnh hơn thay đổi trước đó, không phải khi mọi folder đều có cái tên hoàn hảo.

Bạn thấy bài viết thế nào?