Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

multi-module spring boot

Tung Bui Thanh (TungDaDev)15 mins read

Multi module spring boot.webp

Hệ thống doanh nghiệp lên tới vài trăm nghìn dòng code, mỗi ngày có cả chục người cùng commit, thì cái monolith một module kiểu truyền thống bắt đầu lòi ra đủ thứ vấn đề: build lâu, ranh giới domain nhoè dần, circular dependency mọc khắp nơi, và một lỗi nhỏ ở module phụ cũng đủ kéo sập cả ứng dụng.

Nhưng vội nhảy sang Microservices trong khi ranh giới nghiệp vụ còn chưa rõ thì thường còn tệ hơn: ra một cái Distributed Monolith, vừa dính độ trễ mạng, vừa tốn tiền hạ tầng, lại thêm cái khổ của distributed transaction.

Modular Monolith, làm dưới dạng Multi-Module Project trong Spring Boot, là chỗ dung hoà khá ổn: code vẫn chia module rõ ràng, phụ thuộc đi một chiều, mà deploy thì vẫn đơn giản như monolith, transaction ACID vẫn chạy cục bộ như cũ.

Bài này đi qua những gì cần có để một project Multi-Module Spring Boot chạy ổn trên production: chia tầng theo Clean Architecture, quản lý Bean và Connection Pool, tách JPA cho từng module, xử lý một ca khó với JDBC PostgreSQL, và cuối cùng là để ArchUnit tự kiểm tra ranh giới module.


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

Lỗi hay gặp nhất khi làm Multi-Module là chia module theo cảm tính, hoặc chia theo layer kỹ thuật cho có (controller-module, service-module, repository-module). Chia kiểu đó thì sửa một tính năng là phải lội qua sửa đủ mọi module, đi ngược hẳn với Common Closure Principle (CCP).

Cách làm ổn hơn là kết hợp Domain-Driven Design (DDD) với Clean/Hexagonal Architecture: chia hệ thống thành các module độc lập, phụ thuộc đi theo một chiều chặt chẽ:

# mấy nguyên tắc của dependency rule

  1. core-domain là vùng cấm đụng vào: hoàn toàn không phụ thuộc Spring Boot, Hibernate, Jackson hay thư viện bên thứ ba nào, chỉ Java SE thuần. Đổi framework thế nào thì domain logic cũng không được bị ảnh hưởng.
  2. Phụ thuộc luôn hướng vào trong: tầng hạ tầng (Infrastructure/Adapters) phụ thuộc tầng ứng dụng (Application), tầng ứng dụng phụ thuộc Domain.
  3. app-bootstrap là nơi lắp ráp duy nhất: chỉ module này biết hết các module còn lại, ráp chúng lại với nhau và khởi động Spring Boot context.

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

Muốn build nhanh và không để dependency lén lọt sang module khác thì cấu hình build tool phải chặt ngay từ đầu:

// 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 'worker-scheduler'
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 dependency chỉ là chi tiết bên trong module. Nó không lọt vào compile classpath của các module phía trên, nên dependency đó có đổi ABI thì module phía trên cũng không phải biên dịch lại (Compile Avoidance). Project càng nhiều module thì build incremental càng nhanh thấy rõ, mức nhanh bao nhiêu tuỳ project.
  • Dùng api khi type của dependency lộ ra ngay 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'
}

Gradle còn cho thêm một lớp bảo vệ miễn phí: module nào không khai báo dependency tới module kia thì không import được class của nó, biên dịch báo lỗi ngay. Đây là lợi thế lớn nhất của multi-module so với chia package trong một module.


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

Kết nối tới hạ tầng nào (Relational DB, NoSQL, Cache) cũng đi kèm một Connection Pool thật, ăn tài nguyên của hệ điều hành (socket descriptor, native memory thread, OS buffer).

Nếu module nào cũng tự cấu hình một DataSource (HikariCP) hay một RedisConnectionFactory (Lettuce), thì hệ thống 6 module có thể lặng lẽ mở cả trăm connection ngồi không xuống database, chẳng mấy chốc là cạn pool của PostgreSQL/MySQL:

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

Giả sử có 6 module, mỗi module đặt MaxPoolSize = 20, rồi scale lên 5 instance container:

Total Connections = 6 * 20 * 5 = 600 connections

Con số này vượt xa mức mặc định max_connections = 100 của PostgreSQL. Server không sập, nhưng từ connection thứ 101 trở đi sẽ bị từ chối với lỗi FATAL: sorry, too many clients already, nghĩa là instance mới scale lên không khởi động nổi hoặc request bắt đầu lỗi. HikariCP mặc định minimumIdle bằng maximumPoolSize, nên pool nào cũng mở đủ connection ngay lúc khởi động dù chẳng dùng tới. Mỗi connection PostgreSQL lại là một process riêng trên server, tốn vài MB RAM, nên cả trăm connection ngồi không cũng là gánh nặng thật.

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

Gom việc tạo ConnectionFactory về một module hạ tầng dùng chung (common-infra), còn các module nghiệp vụ chỉ việc inject vào và cấu hình serializer của riêng mình. Thật ra nếu không ai tự khai báo thì Spring Boot vốn đã auto-configure đúng một DataSource và một RedisConnectionFactory cho cả ứng dụng; vấn đề chỉ phát sinh khi từng module tự tạo bean riêng. Đoạn dưới là ví dụ khi bạn cần tự tạo factory (để thêm pool):

@Configuration("sharedRedisInfrastructureConfiguration")
public class SharedRedisInfrastructureConfiguration {
 
    @Bean("sharedRedisConnectionFactory")
    @Primary
    public RedisConnectionFactory sharedRedisConnectionFactory(RedisProperties redisProperties) {
        RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration(
                redisProperties.getHost(),
                redisProperties.getPort()
        );
 
        GenericObjectPoolConfig<StatefulConnection<?, ?>> 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);
    }
}

Vài chỗ cần biết với đoạn này. Lettuce mặc định dùng một connection dùng chung cho mọi thread (connection thread-safe, các lệnh được multiplex trên cùng socket), nên đa số ứng dụng không cần pool; pool chỉ đáng dùng khi có lệnh blocking (BLPOP, XREAD BLOCK) hoặc transaction MULTI. Muốn dùng LettucePoolingClientConfiguration thì phải thêm dependency org.apache.commons:commons-pool2. Kiểu generic của pool config phải là GenericObjectPoolConfig<StatefulConnection<?, ?>>, viết <?> sẽ không biên dịch. Cuối cùng, tự tạo factory kiểu này là bỏ qua các thuộc tính khác trong RedisProperties như password, SSL, database index; nhớ set lại nếu dùng.


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

Một lỗi nặng khác hay gặp trong Spring Boot là đặt @EntityScan và @EnableJpaRepositories ở main class Application rồi cho quét cả package gốc (ví dụ: basePackages = "com.company.*").

Làm vậy thì:

  • Hibernate Session Factory nạp hết Entity của mọi domain vào chung một Persistence Unit.
  • Dev rất dễ tạo quan hệ @ManyToOne hay @ManyToMany xuyên module, lâu dần database thành một đĩa spaghetti không gỡ ra nổi.
  • Startup chậm, và unit test của một module cũng phải dựng schema của cả ứng dụng.

Có hai mức cô lập. Mức nhẹ: vẫn một EntityManagerFactory chung, mỗi module chỉ quét đúng package entity/repository của mình, ranh giới được giữ bằng Gradle và ArchUnit. Lưu ý @EntityScan không tạo persistence unit riêng: nhiều module cùng khai báo @EntityScan thì Spring Boot gộp hết package lại cho một EntityManagerFactory auto-configure. Mức mạnh: mỗi module có EntityManagerFactory và TransactionManager riêng (vẫn dùng chung một DataSource), như ví dụ dưới.

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

package com.company.billing.infra.persistence;
 
import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
 
@Configuration("billingJpaConfiguration")
@EnableJpaRepositories(
    basePackages = {
        "com.company.billing.infra.persistence.repository"
    },
    entityManagerFactoryRef = "billingEntityManagerFactory",
    transactionManagerRef = "billingTransactionManager"
)
public class BillingJpaConfiguration {
 
    // Persistence unit riêng của Billing, nhưng dùng chung DataSource (một Hikari pool)
    @Bean("billingEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean billingEntityManagerFactory(
            EntityManagerFactoryBuilder builder, DataSource dataSource) {
        return builder
                .dataSource(dataSource)
                .packages("com.company.billing.infra.persistence.entity")
                .persistenceUnit("billing")
                .build();
    }
 
    @Bean("billingTransactionManager")
    public PlatformTransactionManager billingTransactionManager(
            @Qualifier("billingEntityManagerFactory") EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

Các bean mà entityManagerFactoryRef và transactionManagerRef trỏ tới phải được khai báo thật, chỉ ghi tên trong annotation thì app sẽ không khởi động được (NoSuchBeanDefinitionException). Ở đây không dùng @EntityScan nữa, vì package entity đã khai báo trong builder.packages(...). EntityManagerFactoryBuilder là bean Spring Boot 3.x cung cấp sẵn, nó mang theo các cấu hình spring.jpa.*.

Cái giá của mức cô lập này cần nói rõ. Khi đã có từ hai TransactionManager trở lên, @Transactional không ghi tên sẽ lỗi NoUniqueBeanDefinitionException, trừ khi đánh @Primary cho một cái; tốt nhất ghi rõ @Transactional("billingTransactionManager"). Và một use case ghi vào hai module sẽ chạy trên hai transaction riêng: module A commit xong, module B lỗi thì A không rollback theo. Đó chính là lúc phải dùng Domain Event/Outbox như hộp dưới đây, đúng như khi đã tách microservice. Nếu nghiệp vụ còn cần transaction ACID xuyên module thì mức nhẹ (một EntityManagerFactory) hợp hơn.

IMPORTANT

Quy tắc khi các module nói chuyện với nhau: Không dùng Foreign Key hay Navigation Property của JPA (order.getPayment().getUser()) vượt qua ranh giới module. Các module chỉ tham chiếu nhau bằng ID trần (ví dụ UUID paymentId) hoặc báo cho nhau qua Domain Events (ví dụ @TransactionalEventListener chạy sau khi transaction commit, kèm @Async nếu muốn bất đồng bộ). Event trong bộ nhớ sẽ mất nếu app chết ngay sau commit; việc quan trọng thì lưu event vào bảng outbox trong cùng transaction.


# bẫy jdbc postgresql: bytea vs stringtype

Ở khá nhiều dự án tài chính, ngân hàng lớn, JDBC connection URL của PostgreSQL hay được cấu hình kèm cờ này:

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

# cơ chế bên dưới và nguyên nhân gốc

Mặc định PostgreSQL JDBC Driver gửi tham số Java String xuống DB dưới dạng VARCHAR (OID 1043). Bật stringtype=unspecified thì driver gửi chuỗi với kiểu không xác định (UNKNOWN / OID 705), để PostgreSQL tự đoán kiểu (type resolution) dựa vào ngữ cảnh câu query.

Ý ban đầu là để ép kiểu cho UUID, JSONB hay ENUM cho tiện, khỏi phải viết JPA Type Converter riêng. Có điều khi lên Hibernate 6 / Spring Boot 3, các query kiểu "tham số có thể null" hay vướng mấy lỗi khó chịu dưới đây. Chúng không cùng một nguyên nhân, nên cần tách ra:

  • could not determine data type of parameter $1: với stringtype=unspecified, tham số được gửi ở dạng unknown. Trong biểu thức :email IS NULL không có gì để PostgreSQL suy ra kiểu, nên nó chịu.
  • function lower(bytea) does not exist (hoặc operator does not exist: character varying = bytea): xảy ra khi truyền null mà Hibernate không suy được kiểu của tham số (hay gặp ở native query). Nó bind null dạng binary, driver gửi xuống thành bytea. Lỗi này đến từ Hibernate, không phải từ stringtype, nhưng hai lỗi thường xuất hiện cùng một query nên dễ bị gộp làm một.
  • Native query kèm Pageable có Sort: Spring Data nối ORDER BY bằng đúng tên property bạn truyền vào, không dịch sang tên cột như JPQL. Sort theo createdAt trong khi cột là created_at là query lỗi; còn nếu lấy thẳng tham số sort từ request mà không whitelist thì thành chỗ hở cho SQL injection.

# cách xử lý dứt điểm

Nếu không bỏ được stringtype=unspecified vì còn dính mấy module legacy, thì mọi query liên quan phải theo một chuẩn:

Chuyển sang Native SQL và ép kiểu rõ ràng bằng CAST(:param AS text) hoặc (?1::text). Có kiểu rõ ràng thì PostgreSQL không phải đoán, và null dạng bytea cũng không lọt vào được:

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))
        """,
        countQuery = """
        SELECT count(*) 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
    );
}

Trả về Page từ native query thì nên viết countQuery riêng như trên, Spring Data không phải lúc nào cũng tự sinh đúng câu đếm cho SQL native. Nếu cột status là kiểu ENUM của PostgreSQL thì so với text sẽ báo operator does not exist, lúc đó ép kiểu về đúng enum (CAST(:status AS user_status)) hoặc ép cột sang text (u.status::text).


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

Sơ đồ kiến trúc vẽ đẹp cỡ nào thì để lâu cũng xuống cấp, nếu pipeline CI/CD không có hàng rào tự động. Một bạn mới vào team hoàn toàn có thể lỡ tay import class từ infrastructure vào domain.

ArchUnit phân tích bytecode Java, cho phép viết các nguyên tắc kiến trúc thành unit test, build lần nào cũng tự chạy. Với multi-module, đặt test này ở module thấy được tất cả các module khác (thường là app-bootstrap), không thì ArchUnit chỉ quét được classpath của module chứa nó:

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 Naming@Configuration và @Bean nào cũng có prefix tên module rõ ràngDính ConflictingBeanDefinitionException khi gộp code
Override Safetyspring.main.allow-bean-definition-overriding=false (mặc định từ Boot 2.1, đừng bật lên)Bean bị ghi đè lặng lẽ, lỗi runtime lúc có lúc không, debug mệt
Connection PoolsDùng chung một ConnectionFactory (Hikari/Lettuce)Cạn socket descriptor và connection pool của database server
JPA BoundariesKhông đặt @EntityScan quét rộng ở root, module nào tự cấu hình module đóStartup chậm, mất đóng gói, rò session Hibernate
Cross-Module LinkKhông dùng JPA relationship (@OneToOne, @ManyToOne) xuyên moduleSau này hết đường tách ra thành microservice riêng
Thread PoolsModule nào có Executor riêng, không dùng SimpleAsyncTaskExecutor không giới hạn (trừ khi chạy virtual thread có giới hạn đồng thời)Một việc nặng ở module này làm nghẽn luồng của module khác
PostgreSQL CastBật unspecified thì Native Query luôn dùng CAST(:param AS text)Query sập với lỗi could not determine data type of parameter
ArchUnit CI GateChạy hết ArchUnit test trong pipeline build Maven/GradleCode nhanh chóng thành một "Big Ball of Mud"

# kết luận

Multi-Module Spring Boot đâu phải chỉ tạo thêm mấy thư mục con trong project. Nó đòi hỏi kỷ luật khi thiết kế: biến ranh giới logic thành ranh giới vật lý, đảo chiều phụ thuộc để giữ domain sạch sẽ, và quản lý Bean cùng tài nguyên hạ tầng cho chặt.

Làm đúng thì Modular Monolith cho bạn tốc độ phát triển của một codebase duy nhất, transaction ACID yên tâm, mà mai kia muốn tách ra thành các microservice độc lập thì cũng tách được, không 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 😎 👍🏻 🚀 🔥.