TungDaDev's Blog

multi-module spring boot

Multi module spring boot.webp
Published on
/11 mins read/

Khi quy mô một hệ thống phần mềm doanh nghiệp vượt mốc hàng trăm nghìn dòng code và hàng chục kỹ sư cùng commit hàng ngày, kiến trúc Monolith đơn module (single-module) truyền thống sẽ nhanh chóng bộc lộ những điểm gãy chết người: thời gian build kéo dài, ranh giới domain bị xóa nhòa, circular dependencies xuất hiện tràn lan, và một lỗi nhỏ ở module phụ trợ có thể làm sập toàn bộ ứng dụng.

Tuy nhiên, chuyển dịch vội vã sang Microservices khi ranh giới nghiệp vụ chưa ổn định thường dẫn đến thảm họa lớn hơn: Distributed Monolith (Monolith phân tán) với độ trễ mạng, chi phí hạ tầng tăng vọt và sự phức tạp của distributed transactions.

Modular Monolith triển khai dưới dạng Multi-Module Project trong Spring Boot chính là "điểm cân bằng vàng" (sweet spot) về mặt kiến trúc. Nó mang lại tính đóng gói (encapsulation), ranh giới rõ ràng (clear boundaries), kiểm soát luồng phụ thuộc một chiều (unidirectional dependencies), trong khi vẫn giữ được sự đơn giản trong deployment và transaction ACID cục bộ.

Bài viết này đi sâu vào toàn bộ bức tranh kỹ thuật của một kiến trúc Multi-Module Spring Boot chuẩn production: từ phân tầng Clean Architecture, cơ chế quản lý Bean & Connection Pool, cô lập JPA, giải quyết "ca khó" JDBC PostgreSQL, đến việc tự động hóa kiểm tra ranh giới bằng ArchUnit.


# bản đồ phụ thuộc chuẩn clean architecture

Sai lầm phổ biến nhất khi triển khai Multi-Module là chia module theo "cảm tính" hoặc chia theo layer kỹ thuật nông cạn (controller-module, service-module, repository-module). Cách chia này dẫn đến việc thay đổi một tính năng nghiệp vụ đòi hỏi phải sửa code xuyên suốt tất cả các module, phá vỡ nguyên lý Common Closure Principle (CCP).

Kiến trúc chuẩn production kết hợp giữa Domain-Driven Design (DDD) và Clean/Hexagonal Architecture, chia hệ thống thành các module độc lập theo luồng phụ thuộc nghiêm ngặt:

# nguyên tắc cốt lõi của dependency rule

  1. Module core-domain là "trái tim" bất khả xâm phạm: Hoàn toàn không phụ thuộc vào Spring Boot, Hibernate, Jackson hay bất kỳ thư viện bên thứ ba nào (chỉ dùng Java SE thuần túy). Mọi thay đổi về framework không bao giờ được phép làm ảnh hưởng đến domain logic.
  2. Luồng phụ thuộc luôn hướng vào trong: Tầng hạ tầng (Infrastructure/Adapters) phụ thuộc vào Tầng ứng dụng (Application), và Tầng ứng dụng phụ thuộc vào Domain.
  3. Module app-bootstrap đóng vai trò là "Composer" duy nhất: Chỉ có module này mới biết đầy đủ các module khác để thực hiện lắp ráp (Assembly) và kích hoạt Spring Boot context.

# cấu hình gradle multi-module & compile avoidance

Để tối ưu hóa thời gian build và ngăn ngừa rò rỉ dependencies ngầm định, cấu hình build tool phải được thiết kế chặt chẽ:

// settings.gradle
rootProject.name = 'enterprise-modular-monolith'
 
include 'common-kernel'
include 'core-domain'
include 'core-application'
include 'infra-persistence'
include 'infra-messaging'
include 'api-rest'
include 'app-bootstrap'

# phân biệt api vs implementation trong compile avoidance

TIP

Quy Tắc Biên Dịch Gradle:

  • Dùng implementation: Khi một dependency chỉ là chi tiết nội bộ của module. Nếu dependency này thay đổi ABI, các module phụ thuộc phía trên không cần phải biên dịch lại (Compile Avoidance giúp tăng tốc độ build lên 3-5 lần).
  • Dùng api: Khi types của dependency xuất hiện trực tiếp trong public method signature của module hiện tại.
// core-application/build.gradle
plugins {
    id 'java-library'
}
 
dependencies {
    // Xuất khẩu core-domain ra ngoài cho các adapter sử dụng
    api project(':core-domain')
    implementation project(':common-kernel')
 
    // Chỉ dùng Validation API trừu tượng, không kéo Hibernate Validator cồng kềnh vào core
    implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
}

# quản trị hạ tầng & connection pool

Mỗi hạ tầng kết nối (Relational DB, NoSQL, Cache) đều gắn liền với một Connection Pool vật lý tiêu tốn tài nguyên hệ điều hành (Socket descriptors, native memory threads, OS buffers).

Nếu mỗi module tự cấu hình một DataSource (HikariCP) hoặc một RedisConnectionFactory (Lettuce), một hệ thống gồm 6 module có thể âm thầm mở hàng trăm kết nối nhàn rỗi xuống database, nhanh chóng làm cạn kiệt connection pool của PostgreSQL/MySQL:

Total Connections = Tổng số (MaxPoolSize * AppInstances) của các module

Nếu có 6 module, mỗi module đặt MaxPoolSize = 20, và scale 5 instance container:

Total Connections = 6 * 20 * 5 = 600 connections

Con số này vượt xa mức tải mặc định (100 connections) của PostgreSQL, gây sập server ngay lập tức.

# giải pháp: single source of truth & shared factory

Tập trung hóa việc khởi tạo ConnectionFactory tại một module hạ tầng dùng chung (common-infra), các module nghiệp vụ chỉ inject và cấu hình serializer riêng:

@Configuration("sharedRedisInfrastructureConfiguration")
public class SharedRedisInfrastructureConfiguration {
 
    @Bean("sharedRedisConnectionFactory")
    @Primary
    public RedisConnectionFactory sharedRedisConnectionFactory(RedisProperties redisProperties) {
        RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration(
                redisProperties.getHost(),
                redisProperties.getPort()
        );
 
        GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
        poolConfig.setMaxTotal(50);
        poolConfig.setMaxIdle(20);
        poolConfig.setMinIdle(5);
 
        LettucePoolingClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
                .poolConfig(poolConfig)
                .commandTimeout(Duration.ofMillis(2000))
                .build();
 
        return new LettuceConnectionFactory(serverConfig, clientConfig);
    }
}

# cô lập ranh giới persistence (jpa & database)

Một sai lầm "chết người" khác trong kiến trúc Spring Boot là đặt @EntityScan và @EnableJpaRepositories ở main class Application quét toàn bộ package gốc (ví dụ: basePackages = "com.company.*").

Hành vi này dẫn đến việc:

  • Hibernate Session Factory nạp toàn bộ Entity của tất cả các domain vào cùng một Persistence Unit.
  • Lập trình viên dễ dàng tạo quan hệ @ManyToOne hoặc @ManyToMany xuyên qua các module khác nhau, biến database thành một mớ spaghetti không thể phân tách.
  • Tốc độ startup chậm chạp và unit test của một module buộc phải dựng schema của toàn bộ ứng dụng.

# thiết lập cô lập jpa cho từng module

package com.company.billing.infra.persistence;
 
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
 
@Configuration("billingJpaConfiguration")
@EntityScan(basePackages = {
    "com.company.billing.infra.persistence.entity"
})
@EnableJpaRepositories(
    basePackages = {
        "com.company.billing.infra.persistence.repository"
    },
    entityManagerFactoryRef = "billingEntityManagerFactory",
    transactionManagerRef = "billingTransactionManager"
)
public class BillingJpaConfiguration {
    // Chỉ rõ phạm vi EntityManager & TransactionManager của module Billing
}

IMPORTANT

Quy tắc Vàng về Giao tiếp Liên Module: Tuyệt đối không dùng Foreign Key hoặc Navigation Property JPA (order.getPayment().getUser()) xuyên qua ranh giới module. Các module chỉ giao tiếp với nhau thông qua ID tham chiếu thuần túy (e.g. UUID paymentId) hoặc thông qua Domain Events bất đồng bộ (@TransactionalEventListener).


# bẫy jdbc postgresql: bytea vs stringtype

Trong nhiều dự án tài chính - ngân hàng lớn, các kiến trúc sư thường cấu hình JDBC connection URL của PostgreSQL với cờ:

jdbc:postgresql://postgres-db:5432/core_db?stringtype=unspecified

# bản chất cơ chế & nguyên nhân gốc rễ

Theo mặc định, PostgreSQL JDBC Driver gửi các tham số Java String xuống DB dưới dạng VARCHAR (OID 1043). Khi bật stringtype=unspecified, driver sẽ gửi chuỗi dưới dạng kiểu không xác định (UNKNOWN / OID 705), buộc engine của PostgreSQL phải tự suy diễn kiểu (type resolution) dựa trên ngữ cảnh câu query.

Mục đích ban đầu là hỗ trợ ép kiểu linh hoạt cho UUID, JSONB hoặc ENUM mà không cần custom JPA Type Converter. Tuy nhiên, điều này tạo ra 3 thảm họa chết người trong Hibernate 6 / Spring Boot 3:

# giải pháp kiến trúc dứt điểm

Nếu không thể loại bỏ tham số stringtype=unspecified do phụ thuộc vào các module legacy cũ, toàn bộ query liên quan phải tuân thủ chuẩn:

Chuyển sang Native SQL với cú pháp CAST(:param AS text) hoặc (?1::text):

public interface UserRepository extends JpaRepository<UserEntity, UUID> {
 
    @Query(value = """
        SELECT * FROM users u
        WHERE (CAST(:email AS text) IS NULL OR u.email = CAST(:email AS text))
          AND (CAST(:status AS text) IS NULL OR u.status = CAST(:status AS text))
        """, nativeQuery = true)
    Page<UserEntity> searchUsers(
        @Param("email") String email,
        @Param("status") String status,
        Pageable pageable
    );
}

# tự động hóa kiểm soát ranh giới với archUnit

Một kiến trúc dù được thiết kế hoàn hảo đến đâu trên sơ đồ cũng sẽ bị thoái hóa theo thời gian nếu không có cơ chế rào chắn tự động trong pipeline CI/CD. Lập trình viên mới có thể vô tình import một class từ infrastructure vào domain.

ArchUnit là công cụ phân tích bytecode Java cho phép biến các nguyên tắc kiến trúc thành các bài Unit Test tự động chạy mỗi khi build:

package com.company.architecture;
 
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
 
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
 
@AnalyzeClasses(packages = "com.company", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureEnforcementTest {
 
    @ArchTest
    public static final ArchRule domain_must_not_depend_on_frameworks =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage(
                "org.springframework..",
                "jakarta.persistence..",
                "org.hibernate.."
            )
            .because("Domain model phải là POJO thuần túy, độc lập tuyệt đối với Framework!");
 
    @ArchTest
    public static final ArchRule strict_hexagonal_layers =
        layeredArchitecture()
            .consideringAllDependencies()
            .layer("Domain").definedBy("..domain..")
            .layer("Application").definedBy("..application..")
            .layer("Adapters").definedBy("..infra..")
            .layer("Bootstrap").definedBy("..app..")
 
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Adapters", "Bootstrap")
            .whereLayer("Application").mayOnlyBeAccessedByLayers("Adapters", "Bootstrap")
            .whereLayer("Adapters").mayOnlyBeAccessedByLayers("Bootstrap");
}

# checklist triển khai enterprise

Hạng mục kiểm traTiêu chuẩn production bắt buộcHậu quả nếu vi phạm
Bean NamingMọi @Configuration và @Bean mang prefix module tường minh.Xung đột ConflictingBeanDefinitionException khi hợp nhất code.
Override Safetyspring.main.allow-bean-definition-overriding=false.Ghi đè bean âm thầm, lỗi runtime ngẫu nhiên cực khó debug.
Connection PoolsTái sử dụng ConnectionFactory dùng chung (Hikari/Lettuce).Cạn kiệt Socket Descriptors và Connection Pool của Database server.
JPA BoundariesKhông đặt @EntityScan bao quát ở root; mỗi module tự cấu hình.Chậm startup time; mất tính đóng gói; rò rỉ session Hibernate.
Cross-Module LinkKhông dùng JPA Relationship (@OneToOne, @ManyToOne) xuyên module.Chặt đứt khả năng tách thành Microservice độc lập trong tương lai.
Thread PoolsMỗi module định nghĩa Executor riêng biệt; cấm SimpleAsyncTaskExecutor.Một tác vụ nặng của module này làm nghẽn toàn bộ luồng của module khác.
PostgreSQL CastLuôn dùng CAST(:param AS text) trong Native Query khi bật unspecified.Sập câu query với lỗi could not determine data type of parameter.
ArchUnit CI GateChạy toàn bộ ArchUnit tests trong pipeline build Maven/Gradle.Mã nguồn nhanh chóng thoái hóa thành "Big Ball of Mud".

# kết luận

Kiến trúc Multi-Module Spring Boot không đơn thuần là việc tạo ra nhiều thư mục con trong project. Đó là một cam kết kỷ luật về mặt thiết kế phần mềm: biến ranh giới logic thành ranh giới vật lý, đảo ngược các luồng phụ thuộc để bảo vệ domain cốt lõi, và áp dụng các quy chuẩn quản trị nghiêm ngặt cho Bean và tài nguyên hạ tầng.

Khi được triển khai đúng đắn, Modular Monolith đem lại tốc độ phát triển vượt trội của một codebase duy nhất, sự an toàn tuyệt đối của ACID transaction, đồng thời sẵn sàng 100% để phân tách thành các microservices độc lập trong tương lai mà không cần phải viết lại nghiệp vụ từ đầu.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.

← Previous postkiến trúc RabbitMQ
Next post →rabbitmq pattern