Skip to content

บทที่ 17 — Spring Modulith — Modular Monolith ("monolith ที่แบ่งโมดูล") ก่อนกระโดด Microservices

← บทที่ 16: Spring AI | สารบัญ

บทที่ 8 สอน microservices — แต่บอกชัดว่า อย่าเริ่ม project ใหม่ด้วย microservices เพราะ complexity สูงเกินไปสำหรับทีมเล็กหรือโปรเจกต์เริ่มต้น เหตุผลคือต้องจัดการหลาย service แยกกัน ต้องคุยกันผ่าน network call และต้องรับมือ distributed transaction (ธุรกรรมที่กระจายข้าม service หลายตัว) — ทั้งหมดนี้เหนื่อยเกินไปถ้ายังไม่จำเป็น

Modular Monolith = ทางสายกลาง — deploy (การนำโปรแกรมขึ้น server เพื่อให้ใช้งานได้จริง) เป็นก้อนเดียวเหมือน monolith จึงเร็วและง่าย แต่ structure ภายในแยกชัดเป็น module ที่มีขอบเขตชัดเจนเหมือน microservices พอระบบโตขึ้นค่อย split แต่ละ module ออกไปเป็น microservice ทีละตัวได้

Spring Modulith (GA Q4 2023, ตอนนี้ 1.4+) = library ของ Spring ที่ทำให้:

  • กำหนด module ใน codebase ด้วย package convention (โครงสร้างชื่อ package ที่ตกลงร่วมกัน — จะอธิบายในรายละเอียดที่ Part 3 ข้างล่าง)
  • บังคับขอบเขต — module A เรียก internal class (class ที่ไม่ได้เปิด public) ของ module B → build fail
  • Event-driven communication (สื่อสารผ่าน event ไม่ใช่เรียก method ตรง) ระหว่าง module พร้อม persistence (กัน lost event เหมือน Outbox)
  • generate architecture documentation (สร้างเอกสารสถาปัตยกรรมอัตโนมัติ) เป็นรูปภาพ (ดู Part 6)
  • test แยก module ได้

🟡 API ยังเปลี่ยนได้ — pin version ก่อน upgrade Spring Modulith อยู่ใน track 1.x ที่ยังพัฒนาต่อ — semantics ของ Event Publication Registry, @ApplicationModuleListener และ event externalization มี behavior change ระหว่าง 1.0 → 1.2 → 1.4 ก่อน upgrade ให้อ่าน release notes ทุก minor version และ pin BOM version ในโปรเจกต์

บทนี้สอน:

  1. ปัญหาของ "monolith ที่ไม่มี structure"
  2. Spring Modulith setup
  3. Module boundary ผ่าน package
  4. Application Events + Externalization
  5. Testing module
  6. Documentation & verification
  7. ทาง migration ไปเป็น microservices

⏱️ ใช้เวลา: 2-3 ชั่วโมง · Spring Modulith 1.4.x

🚧 โซนขั้นสูง — ข้ามได้

บทนี้เป็น บทอ้างอิงระดับ architect เรื่องการจัดโครงสร้างโปรเจกต์ใหญ่ มือใหม่ ข้ามไปก่อนได้ — ตอนเริ่มต้นแค่แบ่ง package เป็น controller/service/repository (บทที่ 0) ก็เพียงพอ กลับมาอ่านเมื่อ codebase เริ่มใหญ่จนเริ่มสับสนว่าอะไรเรียกอะไร


ก่อนเริ่ม

✅ ผ่านบท 0-5 (Spring Boot พื้นฐาน + database) ✅ ผ่าน บท 8 microservices ถ้ามี (ไม่จำเป็นต้องอ่านก่อน แต่ช่วยให้เข้าใจบริบทว่าทำไมไม่ควรกระโดดไป microservices) ✅ ผ่าน บท 12 messaging ถ้ามี — event-driven ในบทนี้ใช้ Spring ApplicationEvent (built-in ไม่ต้องรู้ Kafka ก่อน); Kafka ใช้เฉพาะใน §4.3 (Externalization) เท่านั้น

⚠️ ระวังสับสน: @EventListener ธรรมดา (vanilla Spring) ทำงานแบบ synchronous ใน transaction เดียวกับ publisher เสมอ — ไม่ได้ async/after-commit/persist อัตโนมัติ ต้องใช้ @ApplicationModuleListener (ดู Part 4.2) เท่านั้นถึงจะได้ guarantee ที่บทนี้อธิบาย

🔑 ศัพท์ปูพื้น — ประเภท monolith:

  • Monolith (โมโน-ลิธ) = แอปก้อนเดียว deploy ทีเดียว
  • Modular Monolith = monolith ที่แบ่ง "โมดูล" ภายในให้ขอบเขตชัด
  • Big Ball of Mud = "ก้อนโคลนยักษ์" = คำที่นักพัฒนาทั่วโลกใช้เรียกระบบที่โค้ดพันกันยุ่งไม่มีโครงสร้างจนแก้แทบไม่ได้ (เป็น pattern ชื่อดังในวงการ software architecture ไม่ใช่แค่คำเปรียบเปรย)

🔑 module concept:

  • module (โมดูล) = กลุ่มโค้ดที่ทำเรื่องเดียวกัน (เช่น order, payment)
  • boundary (เบาน์-ดะรี) = "เส้นแบ่ง" ที่ห้ามข้าม — โมดูลอื่นเรียกเฉพาะส่วนที่เปิดให้เท่านั้น
  • seam (รอยตะเข็บ) = แนวที่จะแยก module ออกจากกันได้ในอนาคต
  • event-driven = สื่อสารกันด้วยการ "ประกาศเหตุการณ์" แทนการเรียกตรง

Part 1: ทำไมต้อง Modular Monolith

1.1 ปัญหา "Big Ball of Mud"

Monolith ปกติ — codebase โต → ทุกที่เรียกได้ทุกที่:

text
com.example.app/
├── controller/    UserController, OrderController, PaymentController, ...
├── service/       UserService, OrderService, PaymentService, ...
├── repository/    UserRepo, OrderRepo, PaymentRepo, ...
└── entity/        User, Order, Payment, ...

ผลคือ:

  • OrderService เรียก UserRepository ตรงๆ ข้าม layer
  • เปลี่ยน User entity → กระทบ 50 file
  • ไม่รู้ว่า "module" จริงๆ คืออะไร
  • จะ split ไป microservice → seam (รอยตะเข็บ — ดูคำอธิบายในศัพท์ปูพื้นด้านบน) ไม่ชัด → หา boundary (เส้นแบ่งขอบเขต) ที่ถูกต้องไม่ได้ → อาจใช้เวลานานถึง 6 เดือน

1.2 Modular Monolith

text
com.example.app/
├── user/              ← module: user
│   ├── User.java              (package-private)
│   ├── UserRepository.java    (package-private)
│   ├── UserService.java       (package-private)
│   └── api/                   ← public API ของ module
│       ├── UserApi.java       (public interface)
│       └── UserCreatedEvent.java
├── order/             ← module: order
│   ├── Order.java
│   └── api/
│       └── OrderApi.java
└── payment/           ← module: payment
    └── ...

กฎ:

  • ภายใน module — เรียกอะไรก็ได้ (package-private OK — หมายถึง class ที่ไม่ใส่ public: Java อนุญาตให้เรียกได้เฉพาะภายใน package เดียวกัน คนละแนวคิดกับ private ที่จำกัดแค่ในคลาสเดียว — package-private คือระดับการมองเห็นที่กว้างกว่า private แต่แคบกว่า public)
  • ข้าม module — เรียก api/ package เท่านั้น หรือผ่าน event
  • Spring Modulith บังคับกฎนี้ในตอน test/build — ผิด = fail

Part 2: Setup Spring Modulith

📌 BOM (Bill of Materials) = ไฟล์ที่กำหนด version ของทุก dependency ใน library ไว้กลาง ทำให้ไม่ต้องระบุ version ทีละ artifact

  • วิธีใช้: ประกาศ BOM ใน <dependencyManagement> แล้ว dependency อื่นๆ ใน <dependencies> ไม่ต้องใส่ <version> เพราะ BOM จัดการ version ให้ทั้งหมด
  • ตรวจ latest version ก่อนใช้ที่ spring.io/projects/spring-modulith
xml
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.modulith</groupId>
            <artifactId>spring-modulith-bom</artifactId>
            <version>1.4.0</version>  <!-- mid-2026 stable: 1.4.x — ตรวจ latest ที่ spring.io/projects/spring-modulith -->
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-core</artifactId>
    </dependency>
    <dependency>
        <!-- Required for production: persist events to DB to prevent data loss
             when a listener crashes after the publisher commits.
             เปลี่ยนเป็น spring-modulith-starter-mongodb หรือ -r2dbc-postgresql ถ้าไม่ใช้ JPA -->
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.modulith</groupId>
        <artifactId>spring-modulith-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Part 3: ประกาศ Module ผ่าน Package

ทุก direct sub-package ของ main application package = 1 module

java
// com/example/app/ShopApplication.java
@SpringBootApplication
public class ShopApplication { ... }
text
com/example/app/
├── ShopApplication.java
├── user/         ← module 1
├── order/        ← module 2
└── payment/      ← module 3

3.1 Module API vs Internal

ตาม convention มี 2 แบบ (ทำงานได้ทั้งคู่):

  • แบบที่ 1 (sub-package): กฎคือ — class ที่วางตรง root package ของ module (เช่น com.example.app.user) = public API เสมอ ส่วน class ที่วางใน sub-package ใดๆ ของ module (เช่น com.example.app.user.internal หรือแม้แต่ com.example.app.user.foo) = internal ทั้งหมดโดย default (Modulith 1.1+) — ชื่อ internal เป็นแค่ชื่อ convention ที่นิยมใช้ ไม่ใช่ชื่อบังคับ
    • com.example.app.userpublic API (interface, event, DTO)
    • com.example.app.user.internalinternal (impl, repo, entity)
  • แบบที่ 2 (api sub-folder): เพิ่ม sub-package ชื่อ api/ ใต้ module root (เหมือน diagram ใน Part 1.2) — ใช้ได้เหมือนกัน แค่ style ต่างกัน

📌 ตลอดบทนี้ใช้แบบที่ 1 เป็นหลัก ถ้าต้องการควบคุม dependency ชัดกว่านี้ใช้ @ApplicationModule(allowedDependencies = {...}) บน package-info.java

📌 ข้อควรรู้: ใน Java package ผูกกับ directory โดยตรงเสมอ (package com.example.app.user ต้องอยู่ในโฟลเดอร์ com/example/app/user) — นี่คือเหตุผลที่ diagram โครงสร้าง folder ด้านบนใช้แทนชื่อ module ได้เลย

เมื่อไรควรเลือกแบบที่ 2 (api/ sub-package)? — ถ้า module มี public type มากกว่า 2-3 ตัว (เช่น หลาย interface + หลาย event + หลาย DTO) แบบที่ 2 จะทำให้เห็น public contract ชัดกว่าในทันที เพราะ class ทั้งหมดใน api/ คือสิ่งที่ expose ออกมา; แบบที่ 1 เหมาะกับ module เล็กที่มี public type ไม่กี่ตัว

java
// com/example/app/user/UserApi.java          ← public
public interface UserApi {
    UserView find(Long id);
}

// com/example/app/user/UserCreatedEvent.java ← public event
public record UserCreatedEvent(Long userId, String email) {}

// com/example/app/user/internal/UserService.java  ← internal (package-private)
@Service
class UserService implements UserApi {
    private final UserRepository repo;
    // ...
}

ถ้า order module เขียน:

java
// com/example/app/order/OrderService.java
import com.example.app.user.internal.UserService;   // ⚠️ ใช้ internal!

→ Spring Modulith test (Part 6) จะ fail — กัน leak architecture

📌 convention .internal ต้องใช้ Modulith 1.1+ — รุ่นใหม่ default จะถือว่าทุก package ที่อยู่ใต้ module root และมี segment (ส่วนย่อยของชื่อ package ที่คั่นด้วย . เช่น user.internal มี segment ชื่อ internal) ชื่อ internal คือ internal (ไม่ต้องประกาศเพิ่ม) ถ้าใช้รุ่นเก่ากว่า หรือต้องการคุม dependency ชัดกว่านี้ ใช้ @ApplicationModule(allowedDependencies = {...}) บน package-info.java ของ module

⚠️ ที่อธิบายไว้ข้างบน ("sub-package ใดๆ = internal โดย default") เป็นการสรุปแบบง่ายสำหรับเข้าใจภาพรวม — กฎจริงของ Spring Modulith ใช้การวิเคราะห์ class visibility ที่ละเอียดกว่านั้น (ไม่ได้ดูแค่ชื่อ package) ถ้าต้องการความแม่นยำ 100% ให้ยึด convention ที่ตั้งชื่อ internal ชัดเจน (บรรทัดบน) เป็นหลัก และดู Application Module Detection Strategies ประกอบ

📌 package-info.java คืออะไร? ไฟล์พิเศษของ Java สำหรับแนบ annotation ลงบน package ทั้งหมด — สร้างไว้ใน package directory ที่ต้องการได้เลย เช่น:

java
// com/example/app/order/package-info.java
@ApplicationModule(allowedDependencies = {"user"})
package com.example.app.order;

import org.springframework.modulith.ApplicationModule;

ผลคือ module order จะสามารถเรียกได้เฉพาะ module user เท่านั้น — ถ้าเรียก payment โดยไม่ได้ประกาศ → test fail

📌 สังเกตว่า allowedDependencies รับชื่อ module เป็น String ("user") ไม่ใช่ class — เพราะ "module" เป็นแนวคิดระดับ package ไม่ใช่ type ใน Java ภาษา Java เองไม่มี syntax สำหรับอ้างอิง package โดยตรง จึงต้องใช้ชื่อ string แทน


Part 4: Communication ระหว่าง Module

มี 2 ทาง:

4.1 Direct call ผ่าน API (sync)

วิธีแรกที่ module คุยกันคือเรียกตรงผ่าน public API (sync) — module หนึ่ง inject API ของอีก module แล้วเรียกเหมือน method ปกติ:

java
@Service
@RequiredArgsConstructor  // Lombok — สร้าง constructor จาก final field อัตโนมัติ (ดูบท 2 §Lombok)
class OrderService {
    private final UserApi userApi;        // public API ของ user module

    public Order create(CreateOrderRequest req) {
        UserView user = userApi.find(req.userId());
        // ...
    }
}

✅ ง่าย, ตรงไปตรงมา ❌ tight coupling — order ต้องรู้จัก user

user ปล่อย event — ไม่รู้ใครฟัง order รับฟัง — ไม่ต้องรู้จัก user

java
// user module ปล่อย event
@Service
@RequiredArgsConstructor
class UserService implements UserApi {
    private final ApplicationEventPublisher events;
    private final UserRepository repo;

    @Transactional
    public User register(RegisterRequest req) {
        User u = repo.save(new User(req));  // ตัวอย่างเชิงหลักการเท่านั้น — สมมติว่า User มี constructor รับ RegisterRequest
                                             // ของจริงต้องเขียน User(RegisterRequest req) เอง หรือ map field ทีละตัว เช่น
                                             // new User(req.email(), req.password())
        events.publishEvent(new UserCreatedEvent(u.getId(), u.getEmail()));
        return u;
    }
}
java
// order module ฟัง
@Component
class WelcomeOrderListener {

    @ApplicationModuleListener     // ⭐ Modulith annotation
    void on(UserCreatedEvent event) {
        // สร้าง welcome order เปล่า
    }
}

@ApplicationModuleListener ทำอะไร

ภาษาง่าย:

  1. รอให้บันทึก DB สำเร็จก่อน ค่อยทำงาน (กันรับ event ของ data ที่ rollback)
  2. ถ้า listener error จะไม่กระทบ publisher — แต่ละฝั่งมี transaction ของตัวเอง
  3. ทำงาน background — publisher ไม่รอให้ listener เสร็จ

ไม่ต้องเข้าใจกลไก proxy/AOP ลึกก็อ่านต่อได้ — จำแค่ 3 ข้อข้างบนพอ ส่วนตารางข้างล่างสำหรับคนอยากรู้ว่าจริงๆ แล้วมันทำงานยังไง

ในทางเทคนิค @ApplicationModuleListener รวม 3 annotation เข้าด้วยกัน:

Annotationความหมาย
@Asyncรัน thread แยก — publisher ไม่รอ listener เสร็จ
@Transactional(propagation = REQUIRES_NEW)เปิด transaction ใหม่ทุกครั้ง — error ใน listener ไม่กระทบ publisher
@TransactionalEventListener(phase = AFTER_COMMIT)รันหลัง transaction หลัก commit สำเร็จแล้วเท่านั้น

🔴 ข้อควรระวัง — publish event นอก transaction แล้ว listener จะไม่ทำงานเลย เพราะ default phase คือ AFTER_COMMIT (รอ transaction commit ก่อน) ถ้าเรียก events.publishEvent(...) จาก method ที่ ไม่มี @Transactional (เช่น @Scheduled job ที่ลืมใส่ @Transactional) → ไม่มี transaction ให้ commit → @ApplicationModuleListener จะไม่ถูกเรียกเลย โดยไม่มี error เตือน เป็นบั๊กที่เจอบ่อยเวลา publish event จาก code path ที่ไม่ได้อยู่ใน transaction — เช็คให้แน่ใจว่า method ที่ publish event มี @Transactional ครอบอยู่เสมอ

📌 Spring Modulith 1.1+ enable async executor อัตโนมัติ ไม่ต้องเพิ่ม @EnableAsync เอง แต่ถ้า listener ช้า (เช่น ส่ง email) ควร config TaskExecutor bean แยกเพื่อกัน thread pool exhaustion ใน production:

java
@Bean
ThreadPoolTaskExecutor modulithAsyncExecutor() {
    ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
    exec.setCorePoolSize(4);
    exec.setMaxPoolSize(16);
    exec.setQueueCapacity(200);
    exec.setThreadNamePrefix("modulith-async-");
    return exec;
}

จากนั้นบอกให้ Modulith ใช้ executor นี้ใน application.properties:

properties
spring.modulith.async-executor=modulithAsyncExecutor

⚠️ ถ้าโปรเจกต์มี custom @Bean TaskExecutor หรือ @EnableAsync อยู่แล้ว ระวัง bean นั้นอาจแทน async executor ของ Modulith โดยไม่ตั้งใจ — ให้ตั้งชื่อ bean ให้ชัดเจน (เช่น modulithAsyncExecutor) และ qualify ใน config

🔴 Production requirement — ต้องมี Event Publication Registry เสมอ

ถ้า listener throw exception หลัง publisher commit ไปแล้ว — โดย default exception จะถูก log แต่ไม่ propagate กลับไปยัง publisher event จะหายไปและ data อาจ inconsistent โดยไม่รู้ตัวถ้าไม่มี retry mechanism

ไม่มี spring-modulith-starter-jpa = event หายในกรณี listener crash

ใน production ต้องเพิ่ม spring-modulith-starter-jpa (หรือ -mongodb/-r2dbc-postgresql ตาม datastore) เสมอ เพื่อเปิด Event Publication Registry → event ถูก persist → retry ได้ → at-least-once delivery (ส่งอย่างน้อย 1 ครั้ง — อาจส่งซ้ำได้ถ้า retry แต่ไม่หาย; ดู §4.3 ข้างล่าง)

dependency นี้ถูกเพิ่มไว้แล้วใน Part 2 Setup ด้านบน — อย่าลบออก

4.3 Event Externalization — กัน lost event

ถ้า listener crash หลัง commit ของ publisher → event หาย (in-memory) → inconsistent

Spring Modulith มี Event Publication Registry = persist event ใน DB → retry ได้ (คล้าย Outbox Pattern)

Outbox Pattern คือเทคนิคป้องกัน event หาย โดยบันทึก event ลง DB ใน transaction เดียวกับการบันทึก business data แล้วค่อย relay (ส่งต่อ) ออกทีหลัง — ข้อมูลกับ event จึง "ออกพร้อมกันหรือไม่ออกเลย" ไม่มีเคสที่ข้อมูลบันทึกสำเร็จแต่ event หาย (ดูรายละเอียดเพิ่มเติมใน บทที่ 8 §8.3 Transactional Outbox)

xml
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jpa</artifactId>
</dependency>

Schema ของ Modulith สร้างให้อัตโนมัติ — table event_publication:

text
id | listener_id | event_type | serialized_event | publication_date | completion_date
  • serialized_event = ข้อมูล event ที่แปลงเป็น text (JSON) เก็บไว้ใน DB
  • completion_date = เวลาที่ listener ประมวลผลสำเร็จ (ถ้า null = ยังค้างอยู่ รอ retry)

Flow:

  1. publish event → insert row (state: pending)
  2. listener สำเร็จ → update completion_date
  3. ถ้า app restart → resume incomplete events

Externalize ไป Kafka/RabbitMQ

java
@Externalized("orders::order-created")    // รูปแบบ topic::routing-key
                                           // ส่วนแรก (orders) = ชื่อ Kafka topic
                                           // ส่วนหลัง (order-created) = routing key สำหรับ RabbitMQ
                                           // ถ้าใช้ Kafka อย่างเดียว ไม่จำเป็นต้องมีส่วนหลัง
public record OrderCreatedEvent(Long orderId) {}
xml
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-events-kafka</artifactId>
</dependency>

เพิ่ม dependency แล้ว spring-modulith-events-kafka จะ auto-configure การส่ง event ไป Kafka ให้ แต่ถ้าต้องการควบคุมว่า event ไหนบ้างที่จะส่งออก ให้เพิ่ม bean นี้:

java
@Bean
EventExternalizationConfiguration eventExternalizationConfiguration() {
    return EventExternalizationConfiguration
        .externalizing()
        .select(EventExternalizationConfiguration.annotatedAsExternalized())
        .build();
}

→ event เดียวกัน publish ไป ภายใน (listener ใน app) + ภายนอก (Kafka) → ตอน split ไป microservice ก็ใช้ event เดิม ไม่ต้องเขียนใหม่

🔗 ผูกกับ บท 12 §Outbox — Modulith ทำให้ใช้ Outbox ง่ายขึ้นมาก


Part 5: Testing Module แบบแยก

📌 @MockitoBean vs @MockBean:

  • Spring Boot ≤ 3.3 → ใช้ @MockBean
  • Spring Boot ≥ 3.4 → ใช้ @MockitoBean (@MockBean deprecated แล้ว)

ตรวจ version ของคุณใน pom.xml: <parent><version>3.x.x</version>

java
@ApplicationModuleTest             // ⭐ load เฉพาะ module นี้
                                   // @ApplicationModuleTest เป็น composed annotation ที่โหลด Spring context บางส่วน
                                   // เฉพาะ module ที่ทดสอบ — @MockitoBean ทำงานได้เพราะ Spring Test context
                                   // ถูกโหลดอยู่แล้ว (เหมือนกับที่ทำงานกับ @SpringBootTest)
class OrderModuleTests {

    @Autowired OrderService orderService;
    @MockitoBean UserApi userApi;     // Spring Boot 3.4+ (ถ้าใช้ 3.3 ให้ใช้ @MockBean แทน)

    @Test
    void shouldCreateOrder() {
        when(userApi.find(1L)).thenReturn(new UserView(1L, "anna@x.com"));
        Order o = orderService.create(new CreateOrderRequest(1L, List.of(new OrderItem("SKU-1", 2))));
        assertThat(o.getStatus()).isEqualTo(OrderStatus.PENDING);
    }
}

ไม่โหลด user, payment module → test เร็ว + isolated

Verify module rule

java
@Test
void verifyModuleStructure() {
    ApplicationModules.of(ShopApplication.class).verify();
}

ถ้ามี module เรียก internal ของ module อื่น → test fail พร้อมบอกเส้นทาง

Test event flow

Scenario เป็น parameter พิเศษที่ Spring Modulith inject ให้อัตโนมัติใน @ApplicationModuleTest — ไม่ต้อง configure เพิ่ม:

java
@ApplicationModuleTest             // สมมติว่าอยู่ใน UserModuleTests (คนละคลาสกับ OrderModuleTests ด้านบน)
class UserModuleTests {

    @Autowired UserService userService;   // ต้องประกาศ field นี้ก่อน ไม่งั้น compile ไม่ผ่าน

    @Test
    void publishesEventOnRegister(Scenario scenario) {  // Spring inject ให้อัตโนมัติ
        var req = new RegisterRequest("anna@x.com", "password123"); // ปรับ field ให้ตรงกับ RegisterRequest ของคุณ
        scenario.stimulate(() -> userService.register(req))
            .andWaitForEventOfType(UserCreatedEvent.class)
            .toArrive();
    }
}

Part 6: Documentation อัตโนมัติ

java
@Test
void writeDocumentation() {
    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
    new Documenter(modules)
        .writeModulesAsPlantUml()
        .writeIndividualModulesAsPlantUml()
        .writeModuleCanvases();
}

Output:

  • target/spring-modulith-docs/components.puml — diagram โมดูลทั้งหมด
  • target/spring-modulith-docs/module-user.puml — diagram per-module
  • target/spring-modulith-docs/module-user.adoc — "module canvas" (responsibilities, dependencies, events)

→ ฝัง diagram ใน README + auto-update ทุก build


Part 7: Roadmap จาก Modulith → Microservices

ตอนหนึ่ง module หนึ่ง ต้อง scale แยก / team อื่น own → split

ขั้นตอน:

  1. Externalize events ของ module นั้นไป Kafka (@Externalized) → ตอนนี้ communication ผ่าน broker แล้ว
  2. ย้าย user module ไปเป็น standalone Spring Boot app — code แทบไม่ต้องแก้ (เพราะ internal/api แยกอยู่)
  3. แทน UserApi direct call ด้วย Feign client (library ที่ทำให้เรียก REST API ของ service อื่นเหมือนเรียก method Java ธรรมดา) หรือ REST call (บท 8)
  4. แยก DB — ทำ schema migration ค่อยๆ ย้าย table ของ user ไป DB ใหม่
  5. deploy แยก — ตอนนี้ user-service แยกแล้ว

ผลคือ: microservices ที่ split ได้ตอน "พร้อม" — ไม่ใช่ตอนเริ่ม project

💡 หนังสือแนะนำ: Sam Newman — Monolith to Microservices (อธิบายวิธีค่อยๆ แยก monolith ออกเป็น microservice อย่างปลอดภัย เน้น strangler fig pattern + database decomposition — เหมาะอ่านต่อจากบทนี้)


Part 8: Pitfalls

Pitfallแก้
ทุก module share entity เดียวกัน (User)แยก UserView DTO ใน api package
Listener throw exception → event หายใช้ Event Publication Registry + retry policy
Sync call cascade ลึก (A→B→C)แทนด้วย event (async)
Test ใช้ @SpringBootTest ทั้งหมดใช้ @ApplicationModuleTest แยก
ลืม verify() test → architecture rotเพิ่มใน CI
Externalize event โดยไม่คิด schemaใช้ Avro/Protobuf + schema registry (บท 12)

📌 Avro/Protobuf = รูปแบบ data ที่ compact และ versioned กว่า JSON, schema registry = ที่เก็บและ version schema กลาง — แนะนำเฉพาะตอน externalize ไป Kafka จริงๆ สำหรับ in-process event เพียงอย่างเดียว JSON เพียงพอ


Part 9: เปรียบเทียบกับ alternatives

Modular Monolith (Modulith)Microservices
Deploy unit1 jarN services
Network callin-processHTTP/gRPC
Latencyµsms
Refactor cross-moduleIDE renameAPI contract + version
Distributed transactionlocal @TransactionalSaga / Outbox
Team independenceปานกลางสูง
Ops complexityต่ำสูง
Scale ต่อ moduleไม่ได้ได้
เหมาะกับทีม 2-15 คน, ระบบเริ่มต้น30+ คน, scale เป็นส่วน

Part 10: Checkpoint

🛠️ Checkpoint 17.1 — Split codebase เป็น 3 module จากบท 1-2 ที่มี User, Order, Payment ผสม → แยกเป็น 3 module ใต้ main package

🛠️ Checkpoint 17.2 — Event-driven แทน direct call ตอน register user → publish UserCreatedEventorder module รับ → สร้าง welcome cart

🛠️ Checkpoint 17.3 — Verify เขียน test verify() + ลองให้ module หนึ่งเรียก internal ของ module อื่น → ดูว่า fail

🛠️ Checkpoint 17.4 — Externalize เพิ่ม @Externalized + spring-modulith-events-kafka → ดู event ใน Kafka topic

🛠️ Checkpoint 17.5 — Docs generate PlantUML → ใส่ใน README


Part 11: สรุปบท

Modular Monolith = ทางสายกลางระหว่าง monolith กับ microservices ✅ Spring Modulith บังคับ module boundary ผ่าน package + verify ใน test ✅ @ApplicationModuleListener = async + transactional + after-commit event listener ✅ Event Publication Registry = Outbox built-in กัน lost event ✅ @Externalized = publish event ไป Kafka/RabbitMQ พร้อมๆ กับ in-process ✅ @ApplicationModuleTest = test แยก module ✅ Documenter generate diagram + canvas อัตโนมัติ ✅ split ไป microservice ตอน "พร้อม" ไม่ใช่ตอนเริ่ม project

📚 อ่านต่อ: Spring Modulith Reference · Bootify Modulith course · Vaughn Vernon — Implementing Domain-Driven Design (หนังสือ DDD เล่มหนาที่เน้นการแบ่ง domain ในทางปฏิบัติ — กรอบ DDD ที่เข้ากับ module thinking; อ่านหลังเข้าใจ Modulith แล้ว)


← บทที่ 16: Spring AI | สารบัญ