กฎทิศทางที่เขียนไว้ในเอกสาร ไม่ได้บังคับอะไรเลย
คอร์สนี้ไม่ได้เริ่มที่ว่า clean architecture คืออะไร
มันเริ่มที่คำถามที่ยากกว่า — หลังจากคุณตกลงเรื่องโครงสร้างกันเรียบร้อยแล้ว อะไรทำให้ข้อตกลงนั้นยังจริงอยู่ในอีกหกเดือน
คอร์สนี้สร้างระบบจองคิวคลินิกหนึ่งระบบ ตั้งแต่บทนี้จนถึงบทที่ 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 เห็นเส้นนั้นจริง
rootProject.name = "clinic"include("domain", "app")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 บรรทัดเดียว และความว่างเปล่านั้นคือทั้งหมดที่บทนี้ต้องการ
dependencies { }ส่วนโมดูลนอกประกาศของที่มันต้องใช้ตามปกติ และมันเห็นโดเมน ไม่ใช่ทางกลับกัน
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 ไม่เจอตั้งแต่ต้น การที่โดเมนแปลไม่ผ่านก็ไม่ได้แปลว่าอะไร
สถานะที่หนึ่ง วางของชิ้นนี้ในโมดูลนอก
package clinic.app
import org.springframework.boot.SpringApplication
fun control(): String = SpringApplication::class.java.name./gradlew :app:compileKotlin# รหัสออก 0สถานะที่สอง ย้าย import บรรทัดเดียวกันไปไว้ในโดเมน
package clinic.domain
import org.springframework.boot.SpringApplication
fun leak(): String = SpringApplication::class.java.name./gradlew :domain:compileKotline: 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: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 บรรทัดเดียวกันวางคนละโมดูล แล้วได้ผลคนละอย่าง
กฎนี้บังคับด้วย classpath ไม่ใช่ด้วยวินัย
หัวข้อที่มีชื่อว่า “กฎนี้บังคับด้วย classpath ไม่ใช่ด้วยวินัย”ความต่างระหว่างวงกลมในสไลด์ กับกฎที่ทำงาน อยู่ที่ใครเป็นคนทักท้วง
compiler ไม่ได้รู้จัก clean architecture และไม่ได้อ่านเอกสารออกแบบของเรา
· มันรู้แค่ว่าโมดูล domain ไม่ได้ประกาศ dependency ไปหา Spring
จึงไม่มีชื่อนั้นให้ resolve
ผลที่ได้จึงไม่ขึ้นกับว่าใครรีวิว รีวิวตอนไหน หรือทีมมีคนใหม่มากี่คน · มันแดงตั้งแต่ก่อนที่ใครจะได้เห็น pull request
กฎทิศทางย้ายเข้า build ได้ทั้งข้อ เพราะมันเป็นข้อความเกี่ยวกับว่าใครเห็นใคร ซึ่ง classpath ตอบได้อยู่แล้ว
แต่กติกาของคลินิกที่ว่าหมอคนหนึ่งมีนัดซ้อนกันไม่ได้ ย้ายเข้า compiler ไม่ได้เลย · มันเป็นข้อความเกี่ยวกับข้อมูลที่มีอยู่ตอนรัน ไม่ใช่เกี่ยวกับโครงสร้าง
บทที่ 6 มีอยู่เพื่อกฎประเภทที่สอง และคำตอบของมันไม่ได้อยู่ที่ Kotlin
ตรึง JDK ให้ตรง ไม่งั้นได้ของที่รันไม่ได้
หัวข้อที่มีชื่อว่า “ตรึง JDK ให้ตรง ไม่งั้นได้ของที่รันไม่ได้”
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 ไล่หาตามปกติ · ต้องบอกทางเอง
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ทำไมการวัดของบทนี้ต้องมีสามสถานะ ไม่ใช่แค่สถานะที่โดเมนแปลไม่ผ่าน