Một dự án Spring Boot khởi đầu gọn gàng: controller gọi service, service gọi repository. Sau hai năm, @Entity xuất hiện trong response JSON, service import HttpServletRequest, và muốn kiểm thử một quy tắc giá bạn phải khởi động cả database. Hexagonal Architecture, còn gọi là Ports and Adapters, là cách tổ chức mã để chặn quá trình đó: logic nghiệp vụ nằm ở trung tâm, mọi thứ bên ngoài (HTTP, database, cổng thanh toán, hàng đợi) cắm vào qua các cổng được định nghĩa rõ. Bài này dựng một ứng dụng đặt hàng nhỏ bằng Spring Boot 3.3.5 và Java 21 theo đúng mô hình đó, chạy test thật, rồi chỉ ra khi nào cách làm này đáng, khi nào là thừa.

Bài nối tiếp DDD trong Java: Entity, Value Object và Aggregate: miền đã tự bảo vệ, giờ ta quyết định đặt nó ở đâu trong kiến trúc. Nền tảng tiêm phụ thuộc có ở Dependency Injection: tự viết IoC container để hiểu Spring, còn cách ẩn lớp lưu trữ sau một giao diện đã được bàn ở Repository pattern và Unit of Work với JPA.

Vấn đề mà Hexagonal Architecture giải quyết: ứng dụng bị kéo theo framework

Hexagonal Architecture ra đời để sửa điểm yếu của kiến trúc phân lớp truyền thống (controller, service, repository) có một chiều phụ thuộc duy nhất: từ trên xuống database. Hệ quả:

  • Nghiệp vụ phụ thuộc vào tầng lưu trữ, nên đổi database hay tách module đều đau.
  • Kiểm thử use case cần database thật hoặc mock dày đặc của chính các lớp JPA.
  • Các chi tiết kỹ thuật (annotation, kiểu của framework) rò rỉ lên miền, và ngược lại miền rò rỉ ra API.

Alistair Cockburn đặt tên và mô tả vấn đề này trong bài gốc về Hexagonal Architecture (công bố năm 2005): logic nghiệp vụ bị trộn với giao diện người dùng và hệ thống bên ngoài, khiến kiểm thử tự động khó và việc phát triển bị chặn khi database chưa sẵn sàng. Mục tiêu ông đề ra là để ứng dụng được điều khiển đồng đẳng bởi người dùng, chương trình khác, test tự động hay batch, và được phát triển, kiểm thử tách biệt khỏi thiết bị và database lúc chạy.

Hexagonal Architecture: port và adapter

Ý tưởng cốt lõi của Hexagonal Architecture có hai khái niệm. Theo bài gốc, port là một "cuộc hội thoại có chủ đích" với bên ngoài, được định nghĩa bằng API; adapter chuyển đổi giữa một công nghệ cụ thể (giao diện web, test harness, HTTP, SQL, mock trong bộ nhớ) và một port. Nhiều adapter có thể cắm vào cùng một port. Hình lục giác chỉ là cách vẽ để có chỗ cho các port, con số sáu không có ý nghĩa gì.

Cockburn chia tác nhân thành hai phía, và đây là cách phân loại hữu ích nhất khi viết code:

PhíaTên gọiAi gọi aiVí dụ trong bài
TráiPrimary / drivingBên ngoài gọi vào ứng dụngHTTP controller, test
PhảiSecondary / drivenỨng dụng gọi ra bên ngoàiDatabase, cổng thanh toán

Quy tắc phụ thuộc của Hexagonal Architecture: mũi tên luôn chỉ vào trong. Adapter biết port, port biết miền, miền không biết ai. Phía driven dùng đúng nguyên lý đảo ngược phụ thuộc: ứng dụng khai báo interface theo nhu cầu của mình, adapter bên ngoài hiện thực nó.

Cấu trúc package theo Hexagonal Architecture cho ứng dụng đặt hàng

Toàn bộ project Hexagonal Architecture nằm ở code/s6-24/. Tên package thể hiện đúng các vòng:

demo.hex
├── domain                     Order (Java thuần)
├── application
│   ├── PlaceOrderService      hiện thực use case
│   └── port
│       ├── in   PlaceOrderUseCase          (driving)
│       └── out  OrderRepositoryPort, PaymentPort   (driven)
├── adapter
│   ├── in.web                 OrderController (HTTP)
│   └── out
│       ├── persistence        OrderPersistenceAdapter + JPA entity
│       └── payment            FakePaymentAdapter
└── config                     BeanConfig: lắp ráp

Trong Hexagonal Architecture, miền nằm ở trung tâm và ở đây không có annotation nào. Ta bắt đầu từ trong ra ngoài.

Miền: Java thuần, không import framework

public final class Order {
    public enum Status { PLACED, PAYMENT_FAILED }

    public record Line(String sku, int quantity, long unitPriceVnd) {
        public Line {
            if (quantity <= 0) throw new IllegalArgumentException("Số lượng phải > 0");
            if (unitPriceVnd < 0) throw new IllegalArgumentException("Đơn giá âm");
        }
        public long subtotal() { return quantity * unitPriceVnd; }
    }

    public Order(UUID id, long customerId, List<Line> lines, Status status) {
        if (lines == null || lines.isEmpty())
            throw new IllegalArgumentException("Đơn phải có ít nhất 1 dòng hàng");
        this.id = id; this.customerId = customerId;
        this.lines = List.copyOf(lines); this.status = status;
    }
    public long total() { return lines.stream().mapToLong(Line::subtotal).sum(); }
    public void markPaymentFailed() { status = Status.PAYMENT_FAILED; }
}

Để bài ngắn gọn, đơn giá dùng long VND thay vì Value Object Money như bài trước; trong hệ thống thật bạn nên giữ Money.

Port vào và port ra: giao diện do ứng dụng sở hữu

Trong Hexagonal Architecture, port vào mô tả những gì bên ngoài được yêu cầu ứng dụng làm. Port ra mô tả những gì ứng dụng cần, đặt tên theo ngôn ngữ nghiệp vụ chứ không theo công nghệ: OrderRepositoryPort và PaymentPort, không phải JpaOrderDao hay StripeClient.

public interface PlaceOrderUseCase {
    record Command(long customerId, List<Order.Line> lines) {}
    Order place(Command command);
}

public interface OrderRepositoryPort {
    Order save(Order order);
    Optional<Order> findById(UUID id);
}

public interface PaymentPort {
    boolean charge(UUID orderId, long customerId, long amountVnd);
}

Use case: điều phối, không biết công nghệ

public class PlaceOrderService implements PlaceOrderUseCase {
    private final OrderRepositoryPort orders;
    private final PaymentPort payment;

    public PlaceOrderService(OrderRepositoryPort orders, PaymentPort payment) {
        this.orders = orders; this.payment = payment;
    }

    @Override
    public Order place(Command c) {
        Order order = new Order(UUID.randomUUID(), c.customerId(), c.lines(), Order.Status.PLACED);
        if (!payment.charge(order.id(), order.customerId(), order.total())) {
            order.markPaymentFailed();
        }
        return orders.save(order);
    }
}

Lớp này không có @Service hay @Transactional. Cách lắp ráp ở dưới sẽ cho thấy vì sao vẫn chạy được trong Spring. Một cân nhắc thực tế: ranh giới transaction thường đặt ở use case; bạn có thể thêm @Transactional (và chấp nhận một annotation của Spring trong lớp application) hoặc dùng decorator ở lớp config để giữ lớp này sạch.

Adapter: nơi công nghệ được phép xuất hiện

Adapter vào qua HTTP

Adapter của Hexagonal Architecture ở phía web chỉ dịch JSON thành Command, gọi port vào, rồi dịch kết quả thành JSON. Nó dùng record riêng cho request và response, không lộ Order của miền ra API:

@RestController
@RequestMapping("/orders")
class OrderController {
    private final PlaceOrderUseCase placeOrder;
    OrderController(PlaceOrderUseCase placeOrder) { this.placeOrder = placeOrder; }

    record LineRequest(String sku, int quantity, long unitPriceVnd) {}
    record PlaceRequest(long customerId, List<LineRequest> lines) {}
    record OrderResponse(String id, String status, long total) {}

    @PostMapping @ResponseStatus(HttpStatus.CREATED)
    OrderResponse place(@RequestBody PlaceRequest req) {
        var lines = req.lines().stream()
            .map(l -> new Order.Line(l.sku(), l.quantity(), l.unitPriceVnd())).toList();
        Order o = placeOrder.place(new PlaceOrderUseCase.Command(req.customerId(), lines));
        return new OrderResponse(o.id().toString(), o.status().name(), o.total());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    Map<String, String> invalid(IllegalArgumentException e) { return Map.of("error", e.getMessage()); }
}

Adapter ra: persistence tách mô hình lưu trữ khỏi mô hình miền

Annotation JPA chỉ xuất hiện trong OrderJpaEntity. Adapter dịch qua lại giữa entity lưu trữ và Order của miền:

@Component
class OrderPersistenceAdapter implements OrderRepositoryPort {
    private final SpringDataOrderRepository jpa;
    OrderPersistenceAdapter(SpringDataOrderRepository jpa) { this.jpa = jpa; }

    @Override
    public Order save(Order o) {
        OrderJpaEntity e = new OrderJpaEntity();
        e.id = o.id(); e.customerId = o.customerId(); e.status = o.status().name();
        o.lines().forEach(l -> e.lines.add(
            new OrderJpaEntity.LineEmbeddable(l.sku(), l.quantity(), l.unitPriceVnd())));
        jpa.save(e);
        return o;
    }

    @Override
    public Optional<Order> findById(UUID id) {
        return jpa.findById(id).map(e -> new Order(e.id, e.customerId,
            e.lines.stream().map(l -> new Order.Line(l.sku(), l.quantity(), l.unitPriceVnd())).toList(),
            Order.Status.valueOf(e.status)));
    }
}

Đây là chi phí lớn nhất của Hexagonal Architecture: ta viết thêm một lớp ánh xạ giữa hai mô hình. Đổi lại, miền không bị JPA ràng buộc (không cần constructor rỗng, không cần proxy lazy, không cần setter). Adapter thanh toán giả lập chỉ vài dòng và từ chối giao dịch trên 10 triệu đồng, đủ để thử nhánh thất bại; thay bằng adapter HTTP thật sau này mà không chạm vào miền hay use case.

Lắp ráp Hexagonal Architecture bằng cấu hình Spring

Theo tài liệu Spring về @Configuration và @Bean, lớp @Configuration là nguồn khai báo bean, và các phương thức @Bean có thể nhận phụ thuộc qua tham số. Ta tận dụng điều đó để khai báo use case, vốn là lớp Java thường, thành bean:

@Configuration
class BeanConfig {
    @Bean PaymentPort paymentPort() { return new FakePaymentAdapter(); }

    @Bean PlaceOrderUseCase placeOrderUseCase(OrderRepositoryPort orders, PaymentPort payment) {
        return new PlaceOrderService(orders, payment);
    }
}

Đây là "nơi duy nhất biết cả hai phía". Spring tiêm OrderRepositoryPort (do OrderPersistenceAdapter đăng ký bằng @Component) vào use case qua tham số phương thức. Ưu điểm: lớp application có thể kiểm thử bằng new mà không cần Spring.

Kiểm thử với Hexagonal Architecture: use case không cần Spring, không cần database

Đây là phần thưởng thực tế nhất của Hexagonal Architecture. Bài kiểm thử use case chỉ cần một adapter giả trong bộ nhớ và một lambda cho PaymentPort:

class PlaceOrderServiceTest {
    static class InMemoryOrders implements OrderRepositoryPort {
        final Map<UUID, Order> store = new HashMap<>();
        public Order save(Order o) { store.put(o.id(), o); return o; }
        public Optional<Order> findById(UUID id) { return Optional.ofNullable(store.get(id)); }
    }

    @Test
    void marksOrderWhenPaymentIsDeclined() {
        var service = new PlaceOrderService(new InMemoryOrders(), (id, c, amount) -> false);
        Order o = service.place(new Command(7, List.of(new Order.Line("AO-01", 1, 100_000))));
        assertEquals(Order.Status.PAYMENT_FAILED, o.status());
    }
}

Lambda dùng được vì PaymentPort chỉ có một phương thức trừu tượng. Test này chạy trong vài mili giây, không khởi động Spring. Ngoài ra có một test end-to-end bằng @SpringBootTest với cổng ngẫu nhiên, gọi HTTP thật qua RestClient vào controller, đi qua use case rồi xuống H2 qua adapter JPA. Chạy bằng mvn test với JDK 21 (đặt JAVA_HOME trỏ về JDK 21), kết quả thật:

POST nho  -> {"id":"79629d20-…","status":"PLACED","total":500000}
POST lon  -> {"id":"87e97d33-…","status":"PAYMENT_FAILED","total":25000000}
POST rong -> 422 UNPROCESSABLE_ENTITY {"error":"Đơn phải có ít nhất 1 dòng hàng"}
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.hex.PlaceOrderServiceTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in demo.hex.EndToEndTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.hex.ArchitectureTest
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

(Id được rút gọn và log SQL được lược bỏ cho dễ đọc.) Ba ca end-to-end của Hexagonal Architecture cho thấy: đơn nhỏ được đặt và thanh toán thành công, đơn 25 triệu bị adapter thanh toán từ chối nên có trạng thái PAYMENT_FAILED, đơn rỗng bị miền từ chối và adapter web dịch thành HTTP 422.

Giữ Hexagonal Architecture bằng ArchUnit

Hexagonal Architecture chỉ tồn tại nếu có thứ bảo vệ nó. Theo hướng dẫn ArchUnit, thư viện này phân tích bytecode đã biên dịch để kiểm tra phụ thuộc giữa package, lớp và tầng, chạy trong framework test thông thường, và mỗi quy tắc là một trường @ArchTest. Ba quy tắc của dự án này:

@AnalyzeClasses(packages = "demo.hex", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
    @ArchTest
    static final ArchRule domainIsFrameworkFree = noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAnyPackage(
            "org.springframework..", "jakarta.persistence..", "com.fasterxml..",
            "..adapter..", "..application..");

    @ArchTest
    static final ArchRule applicationDoesNotKnowAdapters = noClasses().that().resideInAPackage("..application..")
        .should().dependOnClassesThat().resideInAnyPackage(
            "..adapter..", "org.springframework..", "jakarta.persistence..");

    @ArchTest
    static final ArchRule webAdapterDoesNotTouchPersistence = noClasses().that().resideInAPackage("..adapter.in..")
        .should().dependOnClassesThat().resideInAPackage("..adapter.out..");
}

Để biết quy tắc có thật sự bắt lỗi, tôi thử phá nó: thêm @Component của Spring vào lớp Order trong miền rồi chạy lại. Kết quả thật:

Tests run: 3, Failures: 1 -- in demo.hex.ArchitectureTest
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..domain..'
  should depend on classes that reside in any package ['org.springframework..', ...]' was violated (1 times):
Class <demo.hex.domain.Order> is annotated with <org.springframework.stereotype.Component> in (Order.java:0)

Build đỏ ngay, trước khi code lên review. Sau khi hoàn tác, cả ba quy tắc xanh trở lại. Lưu ý kỹ thuật: ArchUnit đọc class file bằng ASM đóng gói sẵn, nên nếu chạy test bằng JDK mới hơn phiên bản ASM hỗ trợ bạn sẽ gặp lỗi "Unsupported class file major version"; hãy chạy trên JDK 21 hoặc dùng bản ArchUnit mới hơn.

Biến thể của Hexagonal Architecture và các kiến trúc liên quan

Hexagonal Architecture thuộc cùng họ với Onion Architecture của Jeffrey Palermo và Clean Architecture của Robert C. Martin: cả ba đều đặt miền ở trung tâm và để phụ thuộc chỉ vào trong. Khác biệt chủ yếu ở cách đặt tên các vòng và mức độ quy định. Một số lựa chọn thực tế khi áp dụng Hexagonal Architecture:

  • Một module hay nhiều module Maven: tách domain, application, adapter thành module riêng buộc trình biên dịch chặn phụ thuộc sai, nhưng nặng nề hơn; một module cộng ArchUnit là điểm khởi đầu hợp lý.
  • Có cần mô hình lưu trữ riêng không: nếu miền đơn giản, nhiều đội để @Entity ngay trên mô hình miền và chấp nhận đánh đổi. Tách như bài này an toàn hơn nhưng tốn thêm mã ánh xạ (có thể giảm bằng MapStruct).
  • Port ra theo nhu cầu: mỗi use case khai báo đúng những gì nó cần; tránh một port "to" kiểu OrderService gom mọi thao tác.
  • Modulith: nhiều module hexagonal trong một ứng dụng, giao tiếp qua port hoặc sự kiện, là bước đệm trước khi tách microservice. Bài về Observer pattern và event bus giải thích cơ chế sự kiện có thể dùng ở đây.

Khi nào không nên dùng Hexagonal Architecture

  • Ứng dụng CRUD mỏng: nếu service chỉ chuyển dữ liệu từ form xuống bảng, các port và adapter chỉ thêm tầng trung gian không mang lại giá trị.
  • Prototype và dự án ngắn hạn: chi phí của Hexagonal Architecture không kịp hoàn vốn.
  • Đội chưa quen: Hexagonal Architecture vượt quá khả năng bảo trì của đội còn tệ hơn kiến trúc đơn giản.
  • Truy vấn đọc nặng báo cáo: ép truy vấn đọc đi qua miền và port ra là lãng phí; cho phép đường đọc riêng (CQRS nhẹ) đi thẳng vào SQL hoặc projection.

Đánh đổi cần nói thẳng với khách hàng: Hexagonal Architecture mua khả năng kiểm thử và khả năng thay đổi công nghệ bằng mã ánh xạ và nhiều file hơn. Giá trị rõ nhất khi miền có quy tắc phức tạp, sống lâu, hoặc có nhiều cách điều khiển (HTTP, batch, message) và nhiều hệ thống ngoài.

Lỗi thường gặp khi áp dụng Hexagonal Architecture

  • Port đặt theo công nghệ: JpaOrderRepository trong lớp application kéo JPA vào trong. Đặt tên và kiểu theo ngôn ngữ nghiệp vụ.
  • Trả entity JPA ra controller: làm lộ schema và gây lỗi lazy loading. Dùng record request/response riêng.
  • Adapter vào gọi thẳng adapter ra: bỏ qua use case nên mất ranh giới. ArchUnit bắt được lỗi này.
  • Use case phình thành "service thần thánh": mỗi use case một lớp nhỏ, một mục đích.
  • Nghiệp vụ rò vào adapter: ví dụ adapter persistence tự tính trạng thái đơn. Mọi quy tắc phải ở miền.
  • Ánh xạ hai chiều thiếu test: thêm trường vào miền mà quên adapter, dữ liệu mất lặng lẽ. Thêm test vòng khứ hồi lưu rồi nạp lại.

Checklist áp dụng Hexagonal Architecture với Spring Boot

  • Miền là Java thuần; ArchUnit cấm import Spring, JPA, Jackson.
  • Mỗi use case có port vào riêng; mỗi nhu cầu ngoài có port ra riêng, đặt tên theo nghiệp vụ.
  • Adapter là nơi duy nhất dùng annotation công nghệ; mô hình lưu trữ tách khỏi mô hình miền khi miền đủ phức tạp.
  • Lắp ráp ở một lớp @Configuration; use case có thể khởi tạo bằng new trong test.
  • Test use case bằng adapter trong bộ nhớ; thêm một vài test end-to-end cho đường dẫn chính.
  • Cân nhắc chi phí: dự án CRUD đơn giản không cần đủ mọi lớp của Hexagonal Architecture.

Tóm lại, Hexagonal Architecture không phải về hình lục giác hay số lớp, mà về một quy tắc: phụ thuộc chỉ đi vào trong và mọi giao tiếp với bên ngoài đi qua cổng do ứng dụng định nghĩa. Với Spring Boot, quy tắc đó thực hiện được bằng vài package, một lớp cấu hình và vài test ArchUnit.

Bài tiếp theo

Khi Hexagonal Architecture đã tách bạch các vòng, việc nâng phiên bản framework trở thành thao tác nằm ở vòng ngoài. Mời bạn đọc tiếp Nâng cấp lên Spring Boot 3: Jakarta EE, Java 17 và observability.