โดเมนที่ไม่รู้จัก framework และข้อที่มันรับประกันไม่ได้
บทที่แล้วเราแยกโมดูล domain ออกมา แล้วปล่อยมันว่างไว้
บทนี้เติมของจริงลงไป · และตอนจบเราจะได้ข้อที่สำคัญกว่าตัวโดเมนเอง คือ ข้อที่โดเมนเขียนดีแค่ไหนก็รับประกันไม่ได้
เริ่มจากของที่เล็กที่สุดที่ผิดไม่ได้
หัวข้อที่มีชื่อว่า “เริ่มจากของที่เล็กที่สุดที่ผิดไม่ได้”ถ้าสร้างของที่ผิดกติกาขึ้นมาไม่ได้เลย ก็ไม่ต้องมีใครคอยตรวจมันทีหลัง
value objectvalue objectของที่ระบุตัวด้วยค่าของมันเอง ไม่ใช่ด้วย id · ใน Kotlin เขียนด้วย `data class` ที่ตรวจความถูกต้องใน `init` แล้วไม่มีทางสร้างตัวที่ผิดกติกาขึ้นมาได้ คือของที่ระบุตัวด้วยค่าของมันเอง ไม่ใช่ด้วย id · ช่วงเวลาหนึ่งช่วงเป็นตัวอย่างที่ตรงที่สุด
package clinic.domain
/** ช่วงเวลานับเป็นนาทีจากเที่ยงคืนของวันนั้น */data class Slot(val startMinute: Int, val endMinute: Int) { init { require(startMinute >= 0) { "เวลาเริ่มติดลบไม่ได้" } require(endMinute > startMinute) { "ช่วงเวลาต้องจบหลังเริ่ม" } }
val lengthMinutes: Int get() = endMinute - startMinute
fun overlaps(other: Slot): Boolean = startMinute < other.endMinute && other.startMinute < endMinute}init ทำงานทุกครั้งที่มีใครสร้าง Slot ขึ้นมา · ไม่มีทางอื่นเข้าถึงชนิดนี้
จึงไม่มี Slot ที่จบก่อนเริ่มอยู่ในระบบเลย นับตั้งแต่บรรทัดนี้เป็นต้นไป
นี่คือ invariantinvariantข้อความจริงที่ต้องจริงตลอดเวลา ไม่ใช่แค่ตอนตรวจ · ข้อที่กว้างกว่าหนึ่ง aggregate จะไม่มีที่อยู่ในโดเมน เพราะไม่มีใครในโดเมนเห็นข้อมูลครบพอจะตรวจมัน ข้อแรกของคอร์ส และมันเป็นข้อที่ง่ายที่สุด เพราะทุกอย่างที่ต้องใช้ตรวจ อยู่ในตัว object เอง
package clinic.domain
@JvmInlinevalue class DoctorId(val value: String) { init { require(value.isNotBlank()) { "รหัสหมอว่างไม่ได้" } }}
@JvmInlinevalue class PatientId(val value: String) { init { require(value.isNotBlank()) { "รหัสคนไข้ว่างไม่ได้" } }}สองชนิดนี้ห่อ String ไว้เฉยๆ แต่มันทำให้สลับที่กันไม่ได้
· ส่ง PatientId ไปในช่องที่ขอ DoctorId แล้วแปลไม่ผ่านทันที
ความล้มเหลวของโดเมนไม่ควรเป็น exception
หัวข้อที่มีชื่อว่า “ความล้มเหลวของโดเมนไม่ควรเป็น exception”exception บอกว่ามีบางอย่างผิดปกติ · แต่คำขอที่ถูกปฏิเสธไม่ใช่เรื่องผิดปกติ มันคือผลลัพธ์แบบหนึ่ง
การจองที่ชนกับนัดอื่นไม่ใช่ bug และไม่ใช่เหตุฉุกเฉิน · มันคือคำตอบที่ระบบต้องตอบได้ทุกวัน · เราจึงทำให้เหตุผลของการปฏิเสธเป็นชนิดข้อมูล
package clinic.domain
sealed interface Rejection { data object OutsideOpeningHours : Rejection data object LongerThanLimit : Rejection data class ClashesWith(val existing: Slot) : Rejection}sealed interfacesealed interfaceชนิดที่ประกาศรายชื่อชนิดลูกไว้ครบตอนแปล · ใช้แทนการโยน exception เพื่อบอกความล้มเหลวของโดเมน แล้ว `when` จะแดงเองเมื่อมีเหตุผลใหม่เพิ่มเข้ามา ประกาศรายชื่อชนิดลูกไว้ครบตั้งแต่ตอนแปล · วันที่มีเหตุผลใหม่เพิ่มเข้ามา
when ทุกที่ที่ไล่ชนิดนี้จะแดงเอง โดยไม่ต้องมีใครไปไล่หา
ฟังก์ชันจองที่ไม่อ่านอะไรเองเลย
หัวข้อที่มีชื่อว่า “ฟังก์ชันจองที่ไม่อ่านอะไรเองเลย”สังเกตช่องสุดท้าย · รายการนัดที่มีอยู่ถูกส่งเข้ามา ไม่ได้ถูกไปหยิบมาเอง
package clinic.domain
sealed interface Booking { data class Confirmed(val appointment: Appointment) : Booking data class Refused(val reason: Rejection) : Booking}
data class Appointment( val doctorId: DoctorId, val patientId: PatientId, val slot: Slot,)
object OpeningHours { val window = Slot(startMinute = 8 * 60, endMinute = 18 * 60) const val MAX_LENGTH_MINUTES = 60}
fun book( doctorId: DoctorId, patientId: PatientId, wanted: Slot, existing: List<Slot>,): Booking { if (wanted.startMinute < OpeningHours.window.startMinute || wanted.endMinute > OpeningHours.window.endMinute ) { return Booking.Refused(Rejection.OutsideOpeningHours) } if (wanted.lengthMinutes > OpeningHours.MAX_LENGTH_MINUTES) { return Booking.Refused(Rejection.LongerThanLimit) } val clash = existing.firstOrNull { it.overlaps(wanted) } if (clash != null) return Booking.Refused(Rejection.ClashesWith(clash)) return Booking.Confirmed(Appointment(doctorId, patientId, wanted))}book ไม่เปิดฐานข้อมูล ไม่เรียก HTTP และไม่อ่านนาฬิกา · มันรับของครบแล้วคืนคำตอบ
· ผลของมันจึงขึ้นกับสิ่งที่ป้อนเข้าไปอย่างเดียว
Appointment คือ aggregateaggregateกลุ่มของ object ที่มีทางเข้าทางเดียว และรับผิดชอบความถูกต้องภายในกลุ่มนั้น · ของที่มันมองไม่เห็น มันรับประกันไม่ได้ ซึ่งเป็นเหตุผลที่บทที่ 6 ต้องมี ของระบบนี้ · ทางเข้าทางเดียวที่สร้างมันขึ้นมาได้
คือผ่าน book และนั่นคือเหตุผลที่เรายอมให้ book เป็นของสาธารณะ ส่วน constructor ไม่ใช่ประเด็น
รันแล้วดูผลจริง
หัวข้อที่มีชื่อว่า “รันแล้วดูผลจริง”หกบรรทัด · ห้าบรรทัดแรกคือสิ่งที่โดเมนทำได้ บรรทัดที่หกคือสิ่งที่มันทำไม่ได้
package clinic.domain
fun main() { val doctor = DoctorId("d-1") val patient = PatientId("p-1") val existing = listOf(Slot(9 * 60, 9 * 60 + 30))
println(book(doctor, patient, Slot(10 * 60, 10 * 60 + 30), existing)) println(book(doctor, patient, Slot(9 * 60 + 15, 9 * 60 + 45), existing)) println(book(doctor, patient, Slot(7 * 60, 7 * 60 + 30), existing)) println(book(doctor, patient, Slot(10 * 60, 12 * 60), existing)) println(runCatching { Slot(10 * 60, 9 * 60) }.exceptionOrNull()?.message) println(book(doctor, patient, Slot(9 * 60 + 15, 9 * 60 + 45), emptyList()))}Confirmed(appointment=Appointment(doctorId=DoctorId(value=d-1), patientId=PatientId(value=p-1), slot=Slot(startMinute=600, endMinute=630)))Refused(reason=ClashesWith(existing=Slot(startMinute=540, endMinute=570)))Refused(reason=OutsideOpeningHours)Refused(reason=LongerThanLimit)ช่วงเวลาต้องจบหลังเริ่มConfirmed(appointment=Appointment(doctorId=DoctorId(value=d-1), patientId=PatientId(value=p-1), slot=Slot(startMinute=555, endMinute=585)))บรรทัดที่สองกับบรรทัดที่หกคือคำขอเดียวกันเป๊ะ · ต่างกันที่รายการนัดที่มีอยู่
ซึ่งบรรทัดที่หกส่ง emptyList() เข้าไป
flowchart TB
IN["คำขอจอง 9:15 ถึง 9:45"] --> D{"book"}
L1["รายการนัดที่มีอยู่<br/>ครบ · มี 9:00 ถึง 9:30"] --> D
L2["รายการนัดที่มีอยู่<br/>ว่าง"] --> D
D -->|"จากรายการที่ครบ"| R["Refused ClashesWith"]
D -->|"จากรายการที่ว่าง"| C["Confirmed"]
คำบรรยายภาพ: ตรรกะเดียวกัน คำขอเดียวกัน แต่คำตอบต่างกันเพราะสิ่งที่ป้อนเข้าไปต่างกัน
ข้อที่โดเมนรับประกันไม่ได้
หัวข้อที่มีชื่อว่า “ข้อที่โดเมนรับประกันไม่ได้”โดเมนตรวจถูกเสมอ ตราบใดที่สิ่งที่คุณป้อนให้มันเป็นความจริง และไม่มีบรรทัดไหนในโดเมนรับประกันข้อนั้นได้
book ทำงานถูกทั้งสองครั้ง · มันไม่มี bug เลย
· สิ่งที่ต่างคือความจริงของข้อมูลที่ผู้เรียกส่งเข้ามา
ในระบบจริง ผู้เรียกคือ use case ที่ไปอ่านนัดของหมอคนนั้นจากฐานข้อมูลก่อน · รายการที่มันได้มาจะครบหรือไม่ ขึ้นกับว่าอ่านตอนไหน และมีใครกำลังเขียนอยู่หรือเปล่า
นี่คือเส้นแบ่งของทั้งคอร์ส — กติกาที่ตรวจได้จากของที่อยู่ในมือ อยู่ในโดเมน · กติกาที่ต้องรู้ว่าโลกทั้งใบเป็นอย่างไร อยู่ที่อื่น
มีทางแก้ที่ตรงไปตรงมา คือขยาย aggregate จากนัดหนึ่งใบ เป็นตารางทั้งวันของหมอหนึ่งคน · ถ้าโหลดทั้งวันมาแล้ว การตรวจว่าซ้อนกันหรือไม่ก็อยู่ในอาณาเขตของ aggregate จริงๆ
ราคาของมันมีสามข้อ
- ทุกการจองต้องอ่านนัดทั้งวันขึ้นมา แม้จะจองแค่ช่องเดียว
- การจองสองช่องคนละเวลาของหมอคนเดียวกัน กลายเป็นการแก้ของชิ้นเดียวกัน
- และข้อสำคัญที่สุด · มันยังไม่รอดอยู่ดี ถ้าสองคนโหลดตารางวันเดียวกันขึ้นมาพร้อมกัน
ข้อที่สามคือเหตุผลที่บทที่ 6 มีอยู่ · การขยาย aggregate ย้ายเส้นแบ่ง แต่ไม่ได้ลบมันทิ้ง
”ไม่มี dependency สักบรรทัด” ไม่จริงที่ระดับ classpath
หัวข้อที่มีชื่อว่า “”ไม่มี dependency สักบรรทัด” ไม่จริงที่ระดับ classpath”
dependencies { }ว่างเปล่าจริง แต่ classpath ที่ compiler เห็นไม่ได้ว่างตาม
นับ jar บน compile classpath ของทั้งสองโมดูลออกมาตรงๆ
| โมดูล | jar บน compile classpath |
|---|---|
domain | 2 |
app | 52 |
สอง jar ของโดเมนคือ kotlin-stdlib กับ annotations · plugin ของ Kotlin เติมให้เองเสมอ
· ปิดได้ก็จริง แต่ไม่มีใครทำ เพราะนั่นแปลว่าไม่มี List ให้ใช้
ข้อนี้ไม่ได้ลบล้างกฎทิศทาง มันแค่ทำให้ประโยคแม่นขึ้น · โดเมนไม่ได้ ไม่มีอะไรเลย มันไม่มีของที่ผูกกับ framework หรือฐานข้อมูล
52 มาจาก spring-boot-starter-data-jpa รุ่นที่คอร์สนี้ตรึงไว้ · เปลี่ยนรุ่นแล้วตัวเลขก็เปลี่ยน
สิ่งที่ไม่เปลี่ยนคือ ของสองกองนี้ไม่ได้อยู่ในลำดับเดียวกัน · และวันที่มีใครเผลอเติม dependency เข้าโดเมน ส่วนต่างนี้คือสิ่งที่จะขยับ
สิ่งที่บทนี้ยังไม่ทำ
หัวข้อที่มีชื่อว่า “สิ่งที่บทนี้ยังไม่ทำ”สามอย่างที่ยังไม่มี และแต่ละอย่างมาถึงเมื่อมีเหตุผลให้ต้องมี
- ยังไม่มีเทสต์ ทั้งที่โดเมนแบบนี้ทดสอบง่ายที่สุดในโปรเจกต์ · บทที่ 8 กลับมาที่ข้อนี้ พร้อมตัวเลขที่บอกว่ามันง่ายกว่าแค่ไหน
- ยังไม่มีที่เก็บ
existingยังมาจากlistOfที่เขียนมือ · บทที่ 3 คือจุดที่ JPA เข้ามา แล้วเริ่มขอให้โดเมนเปลี่ยนรูปเพื่อมัน - ยังไม่มีใครเรียก
bookนอกจากmainที่เขียนไว้ลองเอง · ชั้นที่เรียกมันจริง คือ use case ของบทที่ 5
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”โดเมนเสร็จแล้ว และเรารู้ขอบเขตของมันแล้วด้วย ซึ่งสำคัญกว่า
คุณมีโดเมนที่ปฏิเสธค่าผิดตั้งแต่ตอนสร้าง · บอกเหตุผลของการปฏิเสธเป็นชนิดข้อมูล · และทำงานได้โดยมี jar อยู่บน classpath แค่สองใบ
บทหน้าเราต่อมันเข้ากับฐานข้อมูล — และสิ่งแรกที่เจอไม่ใช่เรื่องการแปลงข้อมูล · มันคือ build ที่เขียว โปรแกรมที่สตาร์ตขึ้น แล้วตายที่การเขียนครั้งแรก
ผลลัพธ์ทั้งหกบรรทัดและตัวเลข jar ทั้งสองช่อง มาจากการรันจริง:
- วัดเมื่อ 2026-08-30 บน macOS arm64 · Gradle 9.7.1 · Kotlin 2.4.10 · JDK 25
- รันซ้ำเองได้ด้วย
npm run verify:clinic-clean-arch· จำนวน jar อ่านจากcompileClasspathของแต่ละโมดูลตรงๆ ไม่ได้นับจาก build file - Kotlin — Sealed classes and interfaces
(เข้าถึง 2026-08-30) — ข้อที่
whenครอบชนิดลูกครบแล้วไม่ต้องมีelse - Kotlin — Inline value classes
(เข้าถึง 2026-08-30) —
@JvmInlineและข้อจำกัดของมัน
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3บรรทัดที่สองกับบรรทัดที่หกของผลลัพธ์ เป็นคำขอเดียวกันแต่ได้คำตอบต่างกัน · อะไรคือข้อสรุปที่ถูกต้อง