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

โดเมน​ที่​ไม่รู้​จัก framework และ​ข้อ​ที่​มัน​รับ​ประกัน​ไม่​ได้

บท​ที่​แล้ว​เรา​แยก​โมดูล domain ออก​มา แล้ว​ปล่อย​มัน​ว่าง​ไว้

บท​นี้​เติม​ของ​จริง​ลง​ไป · และ​ตอน​จบ​เรา​จะ​ได้​ข้อ​ที่​สำคัญ​กว่า​ตัว​โดเมน​เอง คือ ข้อ​ที่​โดเมน​เขียน​ดี​แค่​ไหน​ก็​รับ​ประกัน​ไม่​ได้

ถ้า​สร้าง​ของ​ที่​ผิดก​ติ​กา​ขึ้น​มา​ไม่​ได้​เลย ก็​ไม่​ต้อง​มี​ใคร​คอย​ตรวจ​มัน​ทีหลัง

value objectvalue objectของ​ที่​ระบุ​ตัว​ด้วย​ค่า​ของ​มัน​เอง ไม่ใช่​ด้วย id · ใน Kotlin เขียน​ด้วย `data class` ที่​ตรวจ​ความ​ถูก​ต้อง​ใน `init` แล้ว​ไม่มี​ทาง​สร้าง​ตัว​ที่​ผิดก​ติ​กา​ขึ้น​มา​ได้ คือ​ของ​ที่​ระบุ​ตัว​ด้วย​ค่า​ของ​มัน​เอง ไม่ใช่​ด้วย id · ช่วง​เวลา​หนึ่ง​ช่วง​เป็น​ตัวอย่าง​ที่​ตรง​ที่สุด

domain/src/main/kotlin/clinic/domain/Slot.kt
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 เอง

domain/src/main/kotlin/clinic/domain/Ids.kt
package clinic.domain
@JvmInline
value class DoctorId(val value: String) {
init { require(value.isNotBlank()) { "รหัสหมอว่างไม่ได้" } }
}
@JvmInline
value class PatientId(val value: String) {
init { require(value.isNotBlank()) { "รหัสคนไข้ว่างไม่ได้" } }
}

สอง​ชนิด​นี้​ห่อ String ไว้​เฉยๆ แต่​มัน​ทำให้​สลับ​ที่​กัน​ไม่​ได้ · ส่ง PatientId ไป​ใน​ช่อง​ที่​ขอ DoctorId แล้ว​แปล​ไม่​ผ่าน​ทันที

exception บอก​ว่า​มี​บาง​อย่าง​ผิด​ปกติ · แต่​คำขอ​ที่​ถูก​ปฏิเสธ​ไม่ใช่​เรื่อง​ผิด​ปกติ มัน​คือ​ผลลัพธ์​แบบ​หนึ่ง

การ​จอง​ที่​ชน​กับ​นัด​อื่น​ไม่ใช่ bug และ​ไม่ใช่​เหตุ​ฉุกเฉิน · มัน​คือ​คำ​ตอบ​ที่​ระบบ​ต้อง​ตอบ​ได้​ทุก​วัน · เรา​จึง​ทำให้​เหตุผล​ของ​การ​ปฏิเสธ​เป็น​ชนิด​ข้อมูล

domain/src/main/kotlin/clinic/domain/Rejection.kt
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 ทุก​ที่​ที่​ไล่​ชนิด​นี้​จะ​แดง​เอง โดย​ไม่​ต้อง​มี​ใคร​ไป​ไล่​หา

สังเกต​ช่อง​สุดท้าย · รายการ​นัด​ที่​มี​อยู่​ถูก​ส่ง​เข้า​มา ไม่​ได้​ถูก​ไป​หยิบ​มา​เอง

domain/src/main/kotlin/clinic/domain/Booking.kt
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 ไม่ใช่​ประเด็น

หก​บรรทัด · ห้า​บรรทัด​แรก​คือ​สิ่ง​ที่​โดเมน​ทำได้ บรรทัด​ที่​หก​คือ​สิ่ง​ที่​มัน​ทำ​ไม่​ได้

domain/src/main/kotlin/clinic/domain/Demo.kt
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 ที่​ไป​อ่าน​นัด​ของ​หมอ​คน​นั้น​จาก​ฐาน​ข้อมูล​ก่อน · รายการ​ที่​มัน​ได้​มา​จะ​ครบ​หรือ​ไม่ ขึ้น​กับ​ว่า​อ่าน​ตอน​ไหน และ​มี​ใคร​กำลัง​เขียน​อยู่​หรือ​เปล่า

นี่​คือ​เส้น​แบ่ง​ของ​ทั้ง​คอร์ส — กติกา​ที่​ตรวจ​ได้​จาก​ของ​ที่​อยู่​ใน​มือ อยู่​ใน​โดเมน · กติกา​ที่​ต้อง​รู้​ว่า​โลก​ทั้ง​ใบ​เป็น​อย่างไร อยู่​ที่​อื่น

ทาง​เลือก​ที่​ดึง invariant กลับ​เข้า​มา​ใน aggregate และ​ราคา​ของ​มัน

มี​ทาง​แก้​ที่​ตรง​ไป​ตรง​มา คือ​ขยาย aggregate จาก​นัด​หนึ่ง​ใบ เป็นตาราง​ทั้ง​วัน​ของ​หมอ​หนึ่ง​คน · ถ้า​โหลด​ทั้ง​วัน​มา​แล้ว การ​ตรวจ​ว่า​ซ้อน​กัน​หรือ​ไม่​ก็​อยู่​ใน​อาณาเขต​ของ aggregate จริงๆ

ราคา​ของ​มัน​มี​สาม​ข้อ

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

ข้อ​ที่​สาม​คือ​เหตุผล​ที่​บท​ที่ 6 มี​อยู่ · การ​ขยาย aggregate ย้าย​เส้น​แบ่ง แต่​ไม่​ได้​ลบ​มัน​ทิ้ง

dependencies { } ว่าง​เปล่า​จริง แต่ classpath ที่ compiler เห็น​ไม่​ได้​ว่าง​ตาม

นับ jar บน compile classpath ของ​ทั้ง​สอง​โมดูล​ออก​มา​ตรงๆ

โมดูลjar บน compile classpath
domain2
app52

สอง jar ของ​โดเมน​คือ kotlin-stdlib กับ annotations · plugin ของ Kotlin เติม​ให้​เอง​เสมอ · ปิด​ได้​ก็​จริง แต่​ไม่มี​ใคร​ทำ เพราะ​นั่น​แปล​ว่า​ไม่มี List ให้​ใช้

ข้อ​นี้​ไม่​ได้​ลบล้าง​กฎ​ทิศทาง มัน​แค่​ทำให้​ประโยค​แม่น​ขึ้น · โดเมน​ไม่​ได้ ไม่มี​อะไร​เลย มัน​ไม่มีของ​ที่​ผูก​กับ framework หรือ​ฐาน​ข้อมูล

ตัวเลข 52 ไม่ใช่​ตัวเลข​ที่​ควร​จำ · แต่​ส่วน​ต่าง​คือ

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

บรรทัดที่สองกับบรรทัดที่หกของผลลัพธ์ เป็นคำขอเดียวกันแต่ได้คำตอบต่างกัน · อะไรคือข้อสรุปที่ถูกต้อง