Triển khai Hexagonal Architecture Pattern (Port & Adapter) với RESTful API trong Spring Boot

Triển khai Hexagonal Architecture Pattern (Port & Adapter) với RESTful API trong Spring Boot

Hexagonal Architecture là một pattern về kiến trúc của ứng dụng, nó chia ứng dụng làm 3 lớp: application, port và adapter, các chức năng chính (core) được định nghĩa trong application thông qua port tương tác với adaptor (là lớp vận hành trực tiếp tương tác với db/file/services). Các chức năng core sẽ không bị ảnh hưởng bởi cách implement ở tầng adaptor.

Triển khai Hexagonal Architecture

Với định nghĩa trên ta hay xem một ví dụ RESTful APIs CRUD khi triên khai theo Hexagonal Architecture sẽ như thế nào.

Như trong bài phân lớp trong ứng dụng ta có thể chia thành các lớp như: controller, service, validator, v.v…, nếu trong một dự án có nhiều domain(nghiệp vụ), việc chia như vậy có thể gặp nhiều khó khăn. Khi áp dụng Hexagonal Architecture ta có thể tách biệt được từng domain, giúp cho code dễ dàng hơn.

Dưới đây là hướng dẫn triển khai Hexagonal Architecture cho 1 domain nghiệp vụ, khi có nhiều có thể áp dụng tương tự.

Giả sử ta có yêu cầu xây dựng RESTful APIs cho quản lý đối tượng Products với chức năng core là

  • Thêm Product
  • Sửa
  • Xóa
  • Get by Id
  • Search

application nơi chứa cấu hình chung cho toàn bộ dự án, có thể chứa 1 endoint “Hello” và AdviceController cho điều khiển ngoại lệ

domain trong package này sẽ chia thành nhiều các domain khác nhau, ngay cả dự án có một domain về product chúng ta cũng tạo ra package riêng.

Trong một domain nghiệp vụ, đây là nơi sẽ triển khai Hexagonal Architecture, đây là chi tiết bên trong một domain

application: nơi định nghĩa các core service của domain, nếu là RESTful API sẽ định nghĩa các endpoint, nếu là các hàm service được dùng bởi các domain khác ta có thể định nghĩa là các interface.

Ta có thể thấy ProductApis là một interface định nghĩa toàn bộ các API liên quan đến Product tại đây.

@RequestMapping("/api/v1/products")
public interface ProductApis {

    @Operation(summary = "Create new product")
    @ApiResponses({
            @ApiResponse(responseCode = "201", description = "Product created successfully"),
            @ApiResponse(responseCode = "400", description = "Invalid product data",
                    content = @Content(mediaType = "application/problem+json",
                            schema = @Schema(implementation = ProblemDetail.class)))
    })
    @PostMapping
    Mono<ResponseEntity<ProductResponse>> createProduct(
            @RequestBody ProductCommand product,
            @Parameter(hidden = true) ServerWebExchange exchange
    );
}

Hàm implement cho interface này sẽ gọi đến một interface thuộc incomming Action (giới thiệu ở bên dưới), do vậy nó cũng không quan tâm action đó được implement như thế nào, chỉ yêu cầu thực hiện Action

@RestController
@RequiredArgsConstructor
@Slf4j
public class ProductApisImpl implements ProductApis {

    private final AddProductAction addProductAction;

    @Override
    public Mono<ResponseEntity<ProductResponse>> createProduct(ProductCommand product,
                                                               ServerWebExchange exchange) {

        String idempotencyKey = exchange.getRequest().getHeaders().getFirst("Idempotency-Key");

        log.info("Idempotency-Key: {}", idempotencyKey);

        return productValidator.validateSaveProductCommand(product)
                .then(addProductAction.handle(idempotencyKey, product).map(ResponseEntity::ok));
    }
   
}

core trong mục này chứa ports, như ta thấy luôn có incomming và outgoing

  • incomming đại diện cho các chức năng được yêu cầu từ phía người dùng (User Interface)
  • outgoing đại diện cho các chức năng ở phía infrastructure (hạ tầng)

Toàn bộ là định nghĩa ở đây được viết dưới dạng interface.

Nên chia nhỏ mỗi action/nghiệp vụ thành một interface và có hậu tố là Action để dễ dàng phân biệt với các Interface khác, khi có Action hiểu đây là các Action của Ports.

public interface AddProductAction {
    /**
     * Handles the addition of a product based on the provided command.
     *
     * @param command the command containing product details
     * @return a Mono that emits the response containing product details
     */
    Mono<ProductResponse> handle(String idempotencyKey, ProductCommand command);
}

Thông thường ở trong code sẽ có thêm một package model là các đối tượng thể hiện dữ liệu đầu vào/ra của các action, nhưng do trong dự án này đã tách các model ra một thư viện riêng nên ta không thấy package đó

Trong package core ta sẽ thấy có các Facade class, đây là các implement cho Action interface. Các facade sẽ implement incomming Action bằng cách gọi outgoing interface. Ta thấy ProductDatabase là một outgoing interface, có hàm addProduct, cách nó thực hiện như thế nào Facade cũng không quan tâm.

@Component
@RequiredArgsConstructor
public class AddProductActionFacade implements AddProductAction {

    private final ProductDatabase productDatabase;

    @Override
    public Mono<ProductResponse> handle(String idempotencyKey, ProductCommand command) {

        if (command.uuid() == null || command.uuid().isEmpty()) {
            command = command.withUuid(java.util.UUID.randomUUID().toString());
        }

        return productDatabase.addProduct(idempotencyKey, command);
    }
}

infrastructure nơi implement các interface có yêu cầu kết nối với các hệ thống bên ngoài, đây chính là nơi chứa các Adaptor, là nơi kết nối với các Ports.

Ta sẽ thấy ProductDatabaseAdaptor là một implement của interface ProductDatabase nó sẽ quyết định sử dụng database nào, cách lưu trữ ra sao, ta có thể định nghĩa nhiều loại Adaptor cho interface ProductDatabase, lựa chọn implement nào có thể thực hiện khi khởi chạy app.

Nếu có nhiều Adaptor cho một interface ta có thể tách thêm package để dễ kiểm soát code. Nếu có implement cho lưu Mongo và PostgresQL có thể tách thành

  • infrastructure.mongo.ProductDatabaseMongoAdaptor
  • infrastructure.postgres.ProductDatabasePostgresAdaptor

Đây là một implement cho ProductDatabaseAdaptor

Ta có thể thấy addProduct sẽ lưu dữ liệu vào trong database thông qua ProductRepository.

Các đoạn code khác bạn có thể chưa cần quan tâm.

public class ProductDatabaseAdaptor implements ProductDatabase {

    private final ProductRepository productRepository;
    private final ProductQueryRepository queryRepository;
    private final ProductMapper productMapper;
    private final ProductEventSupplier productEventSupplier;
    private final ProductQueryMapper productQueryMapper;
    private final WebClient webClient;
    private final RetryUtil databaseRetryUtil;
    private final ReactiveElasticsearchTemplate elasticsearchTemplate;
    private final CircuitBreakerRegistry circuitBreakerRegistry;
    private final RetryRegistry retryRegistry;

    @Value("${app.cached.product.uri}")
    private String cachedProductUri;

    @Value("${app.cached.product.enabled}")
    private boolean cachedProductEnabled;

    @Value("${app.concurrency.product-operations:50}")
    private int maxConcurrentOperations;

    private Semaphore concurrencyLimiter;

    @PostConstruct
    public void initialize() {
        concurrencyLimiter = new Semaphore(maxConcurrentOperations);
        log.info("Initialized product operations concurrency limiter with {} permits", maxConcurrentOperations);
    }

    @Override
    public Mono<ProductResponse> addProduct(String idempotencyKey, ProductCommand command) {

        return Mono.fromCallable(() -> concurrencyLimiter.tryAcquire(0, TimeUnit.MILLISECONDS))
                .subscribeOn(Schedulers.boundedElastic())
                .flatMap(acquired -> {
                    if (!acquired) {
                        return Mono.error(new TooManyRequestsException("Too many concurrent product creation " +
                                "operations, please try again later"));
                    }

                    log.info("Acquired concurrency permit for product creation, available permits: {}",
                            concurrencyLimiter.availablePermits());

                    Mono<ProductResponse> saveProduct = Mono.fromCallable(() ->
                                    productMapper.toEntity(command))
                            .flatMap(productRepository::save)
                            .as(mono -> databaseRetryUtil
                                    .applyRetry(mono, "save product", log)) // #1
                            .map(productMapper::toResponse)
                            .doOnSuccess(response -> {
                                log.info("Product added successfully: {}", response);
                                publishAddEvent(idempotencyKey, response, ProductEventType.PRODUCT_ADDED); // #2
                            });

                    Mono<ProductResponse> result;

                    if (cachedProductEnabled && idempotencyKey != null) {
                        String cachedProductUriWithIdempotency = cachedProductUri + "idempotency/" + idempotencyKey;
                        log.info("Attempting to fetch product from cache using idempotency key: {}", idempotencyKey);

                        // Attempt to fetch from cache using idempotency key
                        result = fetchFromCache(cachedProductUriWithIdempotency, idempotencyKey)
                                .timeout(Duration.ofSeconds(2)) // Set 2-second timeout
                                .onErrorResume(TimeoutException.class, ex -> {
                                    log.warn("Cache request timed out for key: {}", idempotencyKey);
                                    return saveProduct;
                                })
                                .onErrorResume(Exception.class, ex -> {
                                    log.info("Idempotency key not found in cache (404), proceeding to add product");
                                    return saveProduct;
                                })
                                .doOnSuccess(response -> log.info("Product fetched from cache using idempotency key: {}", idempotencyKey));
                    } else {
                        log.info("Idempotency key not provided or caching is disabled, " +
                                "proceeding to add product without cache");
                        result = saveProduct;
                    }

                    return result.doFinally(signal -> {
                        concurrencyLimiter.release();
                        log.info("Released concurrency permit, signal: {}, available permits: {}",
                                signal, concurrencyLimiter.availablePermits());
                    });
                });
    }
}

Ta tổng kết lại luồng tuần tự của chương trình

RESTful endpoint -> APIs Interface -> Action Incomming (implemented by Facace) -> Facade -> Outgoing Action (implemented by Adaptor) -> Adapter connect to real db/files/services.

Khi nào thì dùng Hexagonal Architecture

Việc triển khai Hexagonal Architecture sẽ tăng thời gian implement của hệ thống, ảnh hưởng đến chi phí nhân công, nhưng nó cũng mang lại lợi ích:

  • Quản lý được nhiều domain nghiệp vụ trong một app
  • Dễ dàng trong việc triển khai phát triển phần mềm theo nguyên tắc DDD(domain driven design)
  • Tách biệt được interface (chức năng mong muốn) và implement (vận hành cụ thể)

Tổng kết

Hexagonal Architecture là kiến trúc phần mềm được áp dụng trong nhiều mô hình phát triển, ngôn ngữ lập trình khác nhau, bài viết chỉ giới hạn trong phạm vi triển khai cho các App viết bằng RESTful APIs

Hexagonal Architecture tương đối dễ triển khai, bạn dựa trên đoạn code mình mô phỏng, sau đó thực hiện tại máy local. Code trên được triển khai trên Spring Boot 3.5.3, sử dụng Reactive Web, Postgres lưu dữ liệu.

Written by :

user

Java Developer, System Architect, Learner and becoming Youtuber

View All Posts

Để 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 *