ข้าม​ไป​ยัง​เนื้อหา

กฎ​ทิศทาง​ที่​เขียน​ไว้​ใน​เอกสาร ไม่​ได้​บังคับ​อะไร​เลย

คอร์ส​นี้​ไม่​ได้​เริ่ม​ที่​ว่า clean architecture คือ​อะไร

มัน​เริ่ม​ที่​คำถาม​ที่​ยาก​กว่า — หลัง​จาก​คุณ​ตกลง​เรื่อง​โครงสร้าง​กัน​เรียบร้อย​แล้ว อะไร​ทำให้​ข้อ​ตกลง​นั้น​ยัง​จริง​อยู่​ใน​อีก​หก​เดือน

📦 clinic — ระบบ​จอง​คิว​คลินิก​ที่​เรา​จะ​สร้าง​ไป​ด้วย​กัน

คอร์ส​นี้​สร้าง​ระบบ​จอง​คิว​คลินิก​หนึ่ง​ระบบ ตั้งแต่​บท​นี้​จนถึง​บท​ที่ 8 · ไม่ใช่ orderctl ของ​สาย​ภาษา backend และ​ไม่ใช่ ledger ของ​คอร์ส payments เพราะ​โดเมน​สอง​อัน​นั้น​มี​กติกา​ที่​บังคับ​ใน aggregate เดียว​ได้

คลินิก​มี​กติกา​ที่​บังคับ​แบบ​นั้น​ไม่​ได้ — หมอ​คน​หนึ่ง​มี​นัด​ซ้อน​เวลา​กัน​ไม่​ได้ · การ​ตรวจ​ข้อ​นี้​ต้อง​ดู​นัด​ของ​หมอ​คน​นั้น​ทั้งหมด ซึ่ง​อยู่​นอก​นัด​ใบ​ที่​กำลัง​จะบัน​ทึก

บท​นี้​ยัง​ไม่​แตะ​ฐาน​ข้อมูล ของ​ที่​รัน​ได้​จริง​คือ build สอง​โมดูล​กับ​การ​แปล​สาม​รอบ · ฐาน​ข้อมูล​มา​ถึง​บท​ที่ 3

กฎ​ที่​ทุก​ทีม​เขียน​ไว้ แล้ว​ละเมิด​โดย​ไม่มี​ใคร​รู้

หัวข้อ​ที่​มีชื่อ​ว่า “กฎ​ที่​ทุก​ทีม​เขียน​ไว้ แล้ว​ละเมิด​โดย​ไม่มี​ใคร​รู้”

กฎ​ทิศทาง​ไม่​ได้​พัง​เพราะ​คน​ไม่​เห็น​ด้วย​กับ​มัน มัน​พัง​เพราะ​ไม่มี​อะไร​ทักท้วง

dependency ruledependency ruleกฎ​ที่​บอก​ว่า dependency ชี้​เข้าหา​โดเมน​ได้​ทาง​เดียว · ถ้า​มัน​อยู่​แค่​ใน​เอกสาร มัน​คือ​ข้อ​ตกลง แต่​ถ้า​แยก​เป็น​โมดูล​แล้ว​ไม่​ประกาศ dependency ย้อน​กลับ compiler จะ​บังคับ​ให้​เอง พูด​ข้อ​เดียว คือ dependency ชี้​เข้าหา​โดเมน​ได้​ทาง​เดียว · โดเมน​ไม่รู้​จัก framework ไม่รู้​จัก​ฐาน​ข้อมูล ไม่รู้​จัก HTTP

ทุก​ทีม​ที่​อ่าน​เรื่อง​นี้​มา​เห็น​ด้วย​กับ​ข้อ​นั้น แล้ว​วาด​วงกลม​สาม​วง​ไว้​ใน​สไลด์ · ผ่าน​ไป​หก​เดือน มี​คน​ต้อง​อ่าน​ค่า​ตั้ง​ค่า​หนึ่ง​ค่า​ใน​โดเมน แล้ว​เติม @Value เข้าไป​หนึ่ง​บรรทัด

บรรทัด​นั้น​ผ่าน​การ​รีวิว เพราะ​มัน​สั้น มี​เหตุผล และ​ไม่มี​อะไร​แดง · สิ่ง​ที่​หาย​ไป​ไม่ใช่​ความ​ตั้งใจ มัน​คือ​ด่าน

Gradle มอง​ไม่​เห็น directory ที่​คน​ตั้ง​ชื่อ​ไว้​สวยงาม มัน​เห็น​แต่​โมดูล​ที่​ประกาศ​ไว้​จริง

src/main/kotlin/domain/ กับ src/main/kotlin/infrastructure/ ใน​โปร​เจ​กต์​เดียว ไม่ใช่ module boundarymodule boundaryเส้น​ที่ Gradle มอง​เห็น ไม่ใช่​เส้น​ที่​คน​ตกลง​กัน · `domain` ที่​ไม่​ประกาศ `implementation` ไปหา Spring จะ​ตอบ `Unresolved reference 'springframework'` ทันที​ที่​มี​ใคร​เผลอ import · มัน​คือ​สอง directory ที่​อยู่​ใน classpath เดียวกัน และ​ของ​ทุก​อย่าง​ใน​นั้น​เห็น​กัน​หมด

เรา​จึง​เริ่ม​ด้วย​การ​ทำให้ Gradle เห็น​เส้น​นั้น​จริง

settings.gradle.kts
rootProject.name = "clinic"
include("domain", "app")
build.gradle.kts
plugins {
kotlin("jvm") version "2.4.10" apply false
kotlin("plugin.spring") version "2.4.10" apply false
id("org.springframework.boot") version "4.1.1" apply false
id("io.spring.dependency-management") version "1.1.7" apply false
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
repositories { mavenCentral() }
extensions.configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> {
jvmToolchain(25)
}
}

โมดูล​โดเมน​คือ file บรรทัด​เดียว และ​ความ​ว่าง​เปล่า​นั้น​คือ​ทั้งหมด​ที่​บท​นี้​ต้องการ

domain/build.gradle.kts
dependencies { }

ส่วน​โมดูล​นอก​ประกาศ​ของ​ที่​มัน​ต้อง​ใช้​ตาม​ปกติ และ​มัน​เห็น​โดเมน ไม่ใช่​ทาง​กลับ​กัน

app/build.gradle.kts
plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
kotlin("plugin.spring")
}
dependencies {
implementation(project(":domain"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

บรรทัด implementation(project(":domain")) คือ​กฎ​ทิศทาง​ทั้ง​ข้อ เขียน​เป็น Gradle · ไม่มี​บรรทัด​คู่​ของ​มัน​ใน​ทาง​กลับ​กัน และ​นั่น​ไม่ใช่​การ​ลืม

ด่าน​ที่​แดง​อย่าง​เดียว พิสูจน์​ไม่​ได้​ว่า​มัน​แดง​เพราะ​เรื่อง​ที่​เรา​ตั้งใจ

ก่อน​จะ​พิสูจน์​ว่า​โดเมน​ถูก​ห้าม ต้อง​พิสูจน์​ก่อน​ว่ามี​อะไร​ให้​ห้าม · ถ้า​โปร​เจ​กต์​ทั้ง​ใบ​หา Spring ไม่​เจอ​ตั้งแต่​ต้น การ​ที่​โดเมน​แปล​ไม่​ผ่าน​ก็​ไม่​ได้​แปล​ว่า​อะไร

สถานะ​ที่​หนึ่ง วาง​ของ​ชิ้น​นี้​ใน​โมดูล​นอก

app/src/main/kotlin/clinic/app/Control.kt
package clinic.app
import org.springframework.boot.SpringApplication
fun control(): String = SpringApplication::class.java.name
Terminal window
./gradlew :app:compileKotlin
# รหัสออก 0

สถานะ​ที่​สอง ย้าย import บรรทัด​เดียวกัน​ไป​ไว้​ใน​โดเมน

domain/src/main/kotlin/clinic/domain/Leak.kt
package clinic.domain
import org.springframework.boot.SpringApplication
fun leak(): String = SpringApplication::class.java.name
Terminal window
./gradlew :domain:compileKotlin
e: Leak.kt:3:12 Unresolved reference 'springframework'.
e: Leak.kt:5:22 Unresolved reference 'SpringApplication'.
e: Leak.kt:5:47 Cannot infer type for type parameter 'T'. Specify it explicitly.
e: Leak.kt:5:47 Unresolved reference. None of the following candidates is applicable
because of a receiver type mismatch:
val <T> KClass<T>.java: Class<T>

ของ​จริง​ยาว​กว่า​นี้ เพราะ​แต่ละ​บรรทัด​ขึ้น​ต้น​ด้วย​ที่​อยู่​เต็ม​ของ file · สี่​บรรทัด​นี้​มา​จาก​เหตุ​เดียวกัน​ทั้งหมด บรรทัด​แรก​คือ​เหตุ ที่​เหลือ​คือ​ผล · พอ compiler หา​ชื่อ SpringApplication ไม่​เจอ มัน​ก็​อนุมาน​ชนิด​ที่​ตาม​มา​ไม่​ได้​ด้วย

สถานะ​ที่​สาม ลบ file นั้น​ทิ้ง แล้ว​แปล​ใหม่ · รหัส​ออก​กลับ​เป็น 0 ทันที

สาม​สถานะ​นี้​ต่าง​กัน​ที่​ตัวแปร​เดียว คือ file ที่ import Spring อยู่​โมดูล​ไหน · ทุก​อย่าง​ที่​เหลือ​เหมือน​กัน​หมด

ลำดับ​ของ​สาม​สถานะ​สำคัญ · แปล app แล้ว​มัน​แปล domain ให้​ก่อน​เสมอ

:app:compileKotlin มี :domain เป็น​ของ​ที่​ต้อง​มี​ก่อน Gradle จึง​แปล​โดเมน​ให้​ก่อน​ทุก​ครั้ง

แปล​ว่า​ถ้า​คุณ​ย้อน​กลับ​ไป​รัน​สถานะ​ที่​หนึ่ง​ใหม่ ใน​ขณะ​ที่ Leak.kt ยัง​อยู่ โมดูล​นอก​จะ​แดง​ไป​ด้วย ทั้ง​ที่​ตัว​มัน​เอง​ไม่มี​อะไร​ผิด

ความ​แดง​นั้น​อ่าน​เหมือน​ช่อง​ควบคุม​พัง แต่​ที่​จริง​คือ​ความ​แดง​ของ​สถานะ​ที่​สอง​รั่ว​มา · รัน​ตาม​ลำดับ​หนึ่ง สอง สาม แล้ว​จะ​ไม่​เจอ​ปัญหา​นี้

flowchart LR
  subgraph M2["โมดูล app"]
    C["Control.kt<br/>import SpringApplication"]
  end
  subgraph M1["โมดูล domain"]
    L["Leak.kt<br/>import ตัวเดียวกัน"]
  end
  S["spring-boot-starter-data-jpa"]
  C -->|"เห็น"| S
  L -.->|"ไม่มีเส้นนี้ให้เดิน"| S
  M2 -->|"implementation(project)"| M1
  C --- OK["รหัสออก 0"]
  L --- NO["Unresolved reference"]

คำ​บรรยาย​ภาพ: import บรรทัด​เดียวกัน​วาง​คนละ​โมดูล แล้ว​ได้​ผล​คนละ​อย่าง

ความ​ต่าง​ระหว่าง​วงกลม​ใน​สไลด์ กับ​กฎ​ที่​ทำงาน อยู่​ที่​ใคร​เป็น​คน​ทักท้วง

compiler ไม่​ได้​รู้จัก clean architecture และ​ไม่​ได้​อ่าน​เอกสาร​ออกแบบ​ของ​เรา · มัน​รู้​แค่​ว่า​โมดูล domain ไม่​ได้​ประกาศ dependency ไปหา Spring จึง​ไม่มี​ชื่อ​นั้น​ให้ resolve

ผล​ที่​ได้​จึง​ไม่​ขึ้น​กับ​ว่า​ใคร​รีวิว รีวิว​ตอน​ไหน หรือ​ทีม​มี​คน​ใหม่​มา​กี่​คน · มัน​แดง​ตั้งแต่​ก่อน​ที่​ใคร​จะ​ได้​เห็น pull request

กฎ​ที่​ย้าย​เข้า​เครื่อง​มือ​ได้ กับ​กฎ​ที่​ย้าย​ไม่​ได้

กฎ​ทิศทาง​ย้าย​เข้า build ได้​ทั้ง​ข้อ เพราะ​มัน​เป็น​ข้อความ​เกี่ยว​กับว่า​ใคร​เห็น​ใคร ซึ่ง classpath ตอบ​ได้​อยู่​แล้ว

แต่​กติกา​ของ​คลินิก​ที่​ว่า​หมอ​คน​หนึ่ง​มี​นัด​ซ้อน​กัน​ไม่​ได้ ย้าย​เข้า compiler ไม่​ได้​เลย · มัน​เป็น​ข้อความ​เกี่ยว​กับข้อมูล​ที่​มี​อยู่​ตอน​รัน ไม่ใช่​เกี่ยว​กับ​โครงสร้าง

บท​ที่ 6 มี​อยู่​เพื่อ​กฎ​ประเภท​ที่​สอง และ​คำ​ตอบ​ของ​มัน​ไม่​ได้​อยู่​ที่ Kotlin

jvmToolchain(25) ใน​ตัวอย่าง​ข้าง​บน​ไม่ใช่​ของ​ประดับ ถอด​ออก​แล้ว build เดียวกัน​ให้​ผล​คนละ​อย่าง​คนละ​เครื่อง

jvmToolchainjvmToolchainการ​ตรึง​รุ่น​ของ JDK ที่​ใช้​แปล ไม่​ให้​ตามใจ JDK ที่ Gradle เอง​รัน​อยู่ · ตรึง​ที่ 25 แล้ว class file ที่​ออก​มา​มี​เลข​รุ่น 69 ทุก​โมดูล ไม่​ว่า​จะ​รัน​จาก​เครื่อง​ไหน ตรึง​รุ่น​ของ JDK ที่​ใช้​แปล ไม่​ให้​ตามใจ JDK ที่ Gradle เอง​รัน​อยู่

ตอน​วัด​จริง Gradle รัน​อยู่​บน JDK 26 · build ที่​ไม่​ตรึง​อะไร​ไว้​จึง​คาย class file ที่​มี​เลข​รุ่น 70 ออก​มา แล้ว​เอา​ไป​รัน​บน JDK 25 ไม่​ได้

UnsupportedClassVersionError: class file version 70.0,
this version of the Java Runtime only recognizes class file versions up to 69.0

ตรึง​ด้วย jvmToolchain(25) แล้ว class file ของ​โมดูล​โดเมน​ได้​เลข​รุ่น 69 ทุก​ครั้ง · ตรวจ​เอง​ได้​ด้วย javap -v แล้ว​อ่าน​บรรทัด major version

ข้อความ​ข้าง​บน​พูด​ถึง​เลข​สอง​ตัว​ที่​ต่าง​กัน​หนึ่ง จึง​อ่าน​เหมือน​เรื่อง​เล็ก · แต่​มัน​แปล​ว่าเครื่อง​ของ​คุณ​กับ CI กำลัง​สร้าง​ของ​คนละ​อย่าง จาก source ชุด​เดียวกัน

ตรึง​แล้ว​ยัง​ไม่​พอ · Gradle ต้องหา JDK รุ่น​นั้น​เจอ​ด้วย และ​บน Homebrew มัน​หาไม่​เจอ

พอ​ตรึง jvmToolchain(25) เข้าไป build แรก​ตอบ​แบบ​นี้​แทน

Cannot find a Java installation on your machine matching: {languageVersion=25}

JDK ของ Homebrew เป็น keg-only มัน​จึง​ไม่​ได้​อยู่​ใน​ที่​ที่ Gradle ไล่​หา​ตาม​ปกติ · ต้อง​บอก​ทาง​เอง

gradle.properties
org.gradle.java.installations.paths=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home
ชี้​ผิด​ชั้น​หนึ่ง​ชั้น แล้ว​ได้​ข้อความ​ที่​หา​สาเหตุ​ยาก​กว่า​เดิม

/opt/homebrew/opt/openjdk@25 ไม่ใช่ JDK home มัน​เป็น symlink ไป​ที่ Cellar ส่วน home จริง​อยู่​ลึก​ลง​ไป​อีก​สาม​ชั้น

ชี้​ที่​ชั้น​บน​แล้ว​จะ​ได้​ข้อความ​นี้​แทน ซึ่ง​เป็น​คนละ​ข้อความ​กับ​ข้าง​บน

No class roots are found in the JDK path

มัน​ไม่​ได้​บอก​ว่า “หาไม่​เจอ” แต่​บอก​ว่า “เจอ​แล้ว แต่​ข้าง​ใน​ไม่มี​ของ” · คน​ที่​เพิ่ง​แก้​ข้อความ​ก่อน​หน้า​ไป​มัก​อ่าน​มัน​เป็น​ปัญหา​คนละ​เรื่อง แล้วไป​หา​ผิด​ที่

สี่​อย่าง​ที่​ยัง​ไม่มี และ​แต่ละ​อย่าง​มา​ถึง​เมื่อ​มี​เหตุผล​ให้​ต้อง​มี

  • ยัง​ไม่มี​โดเมน​จริง พอลบ Leak.kt ทิ้ง โมดูล domain ก็​ว่าง​เปล่า​จริงๆ · มัน​มี​แต่ build.gradle.kts บรรทัด​เดียว · บท​ที่ 2 จะ​เติม​ของ​จริง​ลง​ไป
  • ยัง​ไม่มี​ฐาน​ข้อมูล และ​ยัง​ไม่มี @Entity สัก​ตัว · บท​ที่ 3 คือ​จุด​ที่ JPA เริ่ม​ดึง​ตัว​เอง​เข้าไป​หา​โดเมน
  • ยัง​ไม่มี kotlin("plugin.jpa") และ​ยัง​ไม่มี kotlin("reflect") สอง​อย่าง​นี้ ขาด​ไม่​ได้​ทั้ง​คู่ แต่​เหตุผล​ของ​มัน​เข้าใจ​ได้​ต่อ​เมื่อ​เห็น​ความ​พัง​ที่​มัน​แก้​ก่อน
  • ยัง​ไม่มี use case ชั้น​ที่​วาง​ขอบ transaction เป็น​เรื่อง​ของ​บท​ที่ 5

บท​นี้​ย้าย​กฎ​หนึ่ง​ข้อ​จาก​เอกสาร​เข้าไป​อยู่​ใน build และ​วัดผล​ครบ​สาม​สถานะ

คุณ​แยก​โปร​เจ​กต์​เป็น​สอง​โมดูล​แล้ว · พิสูจน์​แล้ว​ว่า​โมดูล​นอก​เห็น Spring ได้​จริง · พิสูจน์​แล้ว​ว่า​โดเมน​แปล​ไม่​ผ่าน​ทันที​ที่​มี​ใคร​เผลอ import และ​กลับ​มา​เขียว​เมื่อ​เอา​ออก

บท​หน้า​เรา​เขียน​โดเมน​คลินิก​จริง​ลง​ใน​โมดูล​ที่​ว่าง​อยู่​ใบ​นั้น — และ​ข้อ​ที่​น่า​สนใจ​คือ โดเมน​ที่​ไม่มี dependency สัก​บรรทัด ยัง​ตรวจ​ความ​ถูก​ต้อง​ของ​ตัว​เอง​ได้​เกือบ​ทั้งหมด · คำ​ว่า “เกือบ” ใน​ประโยค​นั้น​คือ​ทั้งหมด​ที่​บท​ที่ 6 พูด​ถึง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

ตัวเลข​และ​ข้อความ​ข้อ​ผิด​พลาด​ทุก​ตัว​ใน​บท​นี้​มา​จาก​การ​รัน​จริง ไม่​ได้​คัด​มา​จาก​เอกสาร:

  • วัด​เมื่อ 2026-08-30 บน macOS arm64 · Gradle 9.7.1 · kotlinc-jvm 2.4.10 · JDK สำหรับ​รัน OpenJDK 25.0.4.1
  • รัน​ซ้ำ​เอง​ได้​ด้วย npm run verify:clinic-clean-arch ซึ่ง​สร้าง​โปร​เจ​กต์​ทั้ง​ใบ​ขึ้น​ใหม่ แล้ว​เทียบ​ผล​ที​ละ​ช่อง · มัน​อยู่​นอก CI เพราะ​ต้อง​ใช้ Docker
  • Gradle — Toolchains for JVM projects (เข้า​ถึง 2026-08-30) — jvmToolchain และ org.gradle.java.installations.paths
  • Spring Boot 4.1.1 (เข้า​ถึง 2026-08-30) — รุ่น stable ที่​คอร์ส​นี้​ตรึง​ไว้ · <latest> ของ maven-metadata ชี้​ไป​ที่ milestone จึง​ใช้​ตาม​ไม่​ได้

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

ทำไมการวัดของบทนี้ต้องมีสามสถานะ ไม่ใช่แค่สถานะที่โดเมนแปลไม่ผ่าน