โหมดมืด
บทที่ 17 — Spring Modulith — Modular Monolith ("monolith ที่แบ่งโมดูล") ก่อนกระโดด Microservices
บทที่ 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 ในโปรเจกต์
บทนี้สอน:
- ปัญหาของ "monolith ที่ไม่มี structure"
- Spring Modulith setup
- Module boundary ผ่าน package
- Application Events + Externalization
- Testing module
- Documentation & verification
- ทาง 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- เปลี่ยน
Userentity → กระทบ 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-privateOK — หมายถึง 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 33.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.user— public API (interface, event, DTO)com.example.app.user.internal— internal (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จะสามารถเรียกได้เฉพาะ moduleuserเท่านั้น — ถ้าเรียก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
4.2 Event-driven (async, recommended)
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 ทำอะไร
ภาษาง่าย:
- รอให้บันทึก DB สำเร็จก่อน ค่อยทำงาน (กันรับ event ของ data ที่ rollback)
- ถ้า listener error จะไม่กระทบ publisher — แต่ละฝั่งมี transaction ของตัวเอง
- ทำงาน 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(เช่น@Scheduledjob ที่ลืมใส่@Transactional) → ไม่มี transaction ให้ commit →@ApplicationModuleListenerจะไม่ถูกเรียกเลย โดยไม่มี error เตือน เป็นบั๊กที่เจอบ่อยเวลา publish event จาก code path ที่ไม่ได้อยู่ใน transaction — เช็คให้แน่ใจว่า method ที่ publish event มี@Transactionalครอบอยู่เสมอ📌 Spring Modulith 1.1+ enable async executor อัตโนมัติ ไม่ต้องเพิ่ม
@EnableAsyncเอง แต่ถ้า listener ช้า (เช่น ส่ง email) ควร configTaskExecutorbean แยกเพื่อกัน 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:propertiesspring.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_dateserialized_event= ข้อมูล event ที่แปลงเป็น text (JSON) เก็บไว้ใน DBcompletion_date= เวลาที่ listener ประมวลผลสำเร็จ (ถ้าnull= ยังค้างอยู่ รอ retry)
Flow:
- publish event → insert row (state: pending)
- listener สำเร็จ → update completion_date
- ถ้า 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(@MockBeandeprecated แล้ว)ตรวจ 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-moduletarget/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
ขั้นตอน:
- Externalize events ของ module นั้นไป Kafka (
@Externalized) → ตอนนี้ communication ผ่าน broker แล้ว - ย้าย
usermodule ไปเป็น standalone Spring Boot app — code แทบไม่ต้องแก้ (เพราะ internal/api แยกอยู่) - แทน
UserApidirect call ด้วย Feign client (library ที่ทำให้เรียก REST API ของ service อื่นเหมือนเรียก method Java ธรรมดา) หรือ REST call (บท 8) - แยก DB — ทำ schema migration ค่อยๆ ย้าย table ของ user ไป DB ใหม่
- 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 unit | 1 jar | N services |
| Network call | in-process | HTTP/gRPC |
| Latency | µs | ms |
| Refactor cross-module | IDE rename | API contract + version |
| Distributed transaction | local @Transactional | Saga / 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 UserCreatedEvent → order 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 แล้ว)