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

ราคา​ของ model ตัว​ที่​สอง คือ​ห้า​สิบ​เอ็ด​บรรทัด และ​ด่าน​ที่​ไม่​ช่วย​คุณ

บท​ที่​แล้ว​จบ​ด้วย​คำถาม​ที่​ยัง​ไม่​ได้​ตอบ · จะ​ยอม​ให้​โดเมน​เปลี่ยน​รูป​เพื่อ JPA หรือ​จะ​แยก model ออก​เป็น​สอง​ชุด

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

ทาง​นี้​ไม่​ต้อง​เขียน​อะไร​เพิ่ม​เลย · เอา annotation ไป​แปะ​บน​ของ​ที่​มี​อยู่​แล้ว

domain/src/main/kotlin/clinic/domain/Booking.kt
@Entity
data class Appointment(
@Id val id: Long,
val doctorId: String,
val patientId: String,
val startMinute: Int,
val endMinute: Int,
)

โมดูล domain ต้อง​ดึง API ของ persistence เข้า​มา​ด้วย

domain/build.gradle.kts
plugins {
kotlin("plugin.jpa")
}
dependencies {
implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")
}

แปล​ผ่าน · บรรทัด​ที่​เขียน​เพิ่ม​เพื่อ​การ​แปลง​ข้อมูล คือ​ศูนย์

นี่​คือ​ข้อ​ที่​บท​นี้​มี​อยู่​เพื่อ​มัน และ​มัน​ไม่​เข้า​ข้าง​สิ่ง​ที่​เรา​สร้าง​ไว้​เอง

เอา Leak.kt ของ​บท​ที่ 1 กลับ​มา​ใส่​ใน​โดเมน​ที่​แก้​เป็น​ทาง A แล้ว · แล้ว​แปล​ใหม่

มัน​ยัง​แดง​เหมือน​เดิม · Unresolved reference 'springframework'

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

นับ jar บน compile classpath ของ​โดเมน​ออก​มา​แล้ว​เห็น​ชัด

ทางjar บน classpath ของ domainมี jakarta.persistence-api ไหม
ฐาน​ของ​บท​ที่ 22ไม่มี
ทาง A3มี
ทาง B2ไม่มี
flowchart TB
  P["หลักการ · โดเมนไม่รู้จักเรื่องการเก็บข้อมูล"]
  G["ด่านของบทที่ 1 · โดเมนต้อง import Spring ไม่ได้"]
  A["ทาง A · โดเมนมี jakarta.persistence บน classpath"]
  P -->|"ด่านครอบได้แค่ส่วนหนึ่ง"| G
  A -->|"ละเมิด"| P
  A -->|"ผ่าน"| G

คำ​บรรยาย​ภาพ: วง​ที่​ด่าน​กวาด เล็ก​กว่า​วง​ของ​ข้อ​อ้าง​ที่​มัน​บอก​ว่า​บังคับ

ด่าน​ที่​ถูก​คือ​ด่าน​ที่​นับ jar ไม่ใช่​ด่าน​ที่​ห้าม​ชื่อ​เดียว

ด่าน​ที่​ห้าม springframework ครอบ​เฉพาะ​ชื่อ​ที่​เรา​คิด​ออก​ตอน​เขียน​มัน · วัน​ที่​มี​คน​เติม jakarta.persistence เข้า​มา ด่าน​ไม่มี​อะไร​จะ​พูด · วัน​ที่​มี​คน​เติม driver ของ Redis เข้า​มา ก็​เหมือน​กัน

ด่าน​ที่​ครอบ​ข้อ​อ้าง​ได้​จริง​คือ​ด่าน​ที่บังคับ​จำนวน jar บน classpath ของ​โดเมน · มัน​ไม่​ต้อง​รู้จัก​ชื่อ​ของ framework สัก​ตัว และ​มัน​แดง​กับ​ทุก​อย่าง​ที่​เพิ่ม​เข้า​มา

domain compile classpath ต้องมี 2 jar เท่านั้น

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

ราคา​ที่ทาง A จ่าย​จริง และ​มัน​ไม่​ได้​อยู่​ใน​จำนวน​บรรทัด

หัวข้อ​ที่​มีชื่อ​ว่า “ราคา​ที่ทาง A จ่าย​จริง และ​มัน​ไม่​ได้​อยู่​ใน​จำนวน​บรรทัด”

เทียบ Appointment ของ​บท​ที่ 2 กับ​ของ​ทาง A ที​ละ​ช่อง

บท​ที่ 2 เขียน​ไว้​แบบ​นี้

data class Appointment(
val doctorId: DoctorId,
val patientId: PatientId,
val slot: Slot,
)

ทาง A บังคับ​ให้​มัน​แบน​ลง​เป็น​ชนิด​พื้น​ฐาน · value class สาม​ตัว​หาย​ไป​จาก​ชนิด​นี้

  • DoctorId กับ PatientId กลาย​เป็น String สอง​ตัว​ที่​สลับ​กัน​ได้​โดย compiler ไม่​ทัก
  • Slot กลาย​เป็น Int สอง​ตัว · กติกา​ที่​ว่า​ต้อง​จบ​หลัง​เริ่ม ไม่มี​ที่​อยู่​แล้ว
  • init ที่​ปฏิเสธ​ค่า​ผิด​ตั้งแต่​สร้าง หาย​ไป​พร้อม​กัน

ทั้งหมด​นี้​ไม่​ปรากฏ​ใน​ตัวเลข “เขียน​เพิ่ม​ศูนย์​บรรทัด” เลย​สัก​นิด

โดเมน​ประกาศ​แต่​สิ่ง​ที่​มัน​ต้องการ · ของ​ที่​รู้จัก JPA อยู่​ใน​โมดูล​นอก​ทั้งหมด

โดเมน​ได้ interface หนึ่ง​อัน ซึ่ง​คือ repositoryrepositoryinterface ที่​โดเมน​ประกาศ​ไว้​ว่า​ต้องการ​อะไร แล้ว​ชั้น​นอก​ไป​หา​ให้ · โดเมน​ถือ​แต่ interface จึง​ไม่มี​บรรทัด​ไหน​ใน `domain` ที่​พูด​ถึง Spring Data ใน​ความ​หมาย​ของ clean architecture

domain/src/main/kotlin/clinic/domain/AppointmentRepository.kt
package clinic.domain
interface AppointmentRepository {
fun slotsOf(doctorId: DoctorId): List<Slot>
fun add(appointment: Appointment)
}

โมดูล​นอก​ได้ persistence modelpersistence modelชุด class ที่​มี​ไว้​คุย​กับ​ฐาน​ข้อมูล​อย่าง​เดียว แยก​จาก model ของ​โดเมน · มัน​ทำให้​โดเมน​ไม่​ต้อง​รู้จัก `@Entity` เลย และ​ราคา​ที่​จ่าย​นับ​เป็น​จำนวน​บรรทัด​ได้ ของ​ตัว​เอง ซึ่ง​เป็น​ชนิด​ที่​มี​ไว้​คุย​กับ​ตาราง​อย่าง​เดียว

app/src/main/kotlin/clinic/app/persistence/AppointmentRow.kt
package clinic.app.persistence
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
@Entity
class AppointmentRow(
@Id @GeneratedValue val id: Long = 0,
val doctorId: String = "",
val patientId: String = "",
val startMinute: Int = 0,
val endMinute: Int = 0,
)

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

app/src/main/kotlin/clinic/app/persistence/AppointmentMapper.kt
package clinic.app.persistence
import clinic.domain.Appointment
import clinic.domain.DoctorId
import clinic.domain.PatientId
import clinic.domain.Slot
fun Appointment.toRow(): AppointmentRow = AppointmentRow(
doctorId = doctorId.value,
patientId = patientId.value,
startMinute = slot.startMinute,
endMinute = slot.endMinute,
)
fun AppointmentRow.toDomain(): Appointment = Appointment(
doctorId = DoctorId(doctorId),
patientId = PatientId(patientId),
slot = Slot(startMinute, endMinute),
)

และ​ตัว​ที่​ต่อ interface ของ​โดเมน​เข้า​กับ Spring Data

app/src/main/kotlin/clinic/app/persistence/JpaAppointmentRepository.kt
package clinic.app.persistence
import clinic.domain.Appointment
import clinic.domain.AppointmentRepository
import clinic.domain.DoctorId
import clinic.domain.Slot
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
interface AppointmentRows : JpaRepository<AppointmentRow, Long> {
fun findByDoctorId(doctorId: String): List<AppointmentRow>
}
@Repository
class JpaAppointmentRepository(private val rows: AppointmentRows) : AppointmentRepository {
override fun slotsOf(doctorId: DoctorId): List<Slot> =
rows.findByDoctorId(doctorId.value).map { Slot(it.startMinute, it.endMinute) }
override fun add(appointment: Appointment) {
rows.save(appointment.toRow())
}
}

นับ​เฉพาะ​บรรทัด​ที่​มี code จริง ตัด​บรรทัด​ว่าง​และ​คอม​เมน​ต์​ออก​แล้ว

fileบรรทัด
AppointmentRepository.kt5
AppointmentRow.kt12
AppointmentMapper.kt16
JpaAppointmentRepository.kt18
รวม51

และ classpath ของ​โดเมน​ยัง​เป็น 2 เท่า​เดิม

mapper คือ​ที่​ที่ compiler เลิก​ช่วย​คุณ

ดู toRow อีก​รอบ · doctorId กับ patientId เป็น String ทั้ง​คู่​ใน​ฝั่ง​ตาราง

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

16 บรรทัด​ของ mapper คือ​ที่​ที่​ต้อง​มี​เทสต์​จริง ไม่ใช่​ที่​ที่​ควร​เขียน​เร็วๆ ให้​จบ · บท​ที่ 8 กลับ​มา​ที่​ข้อ​นี้

คำ​ตอบ​ไม่​เหมือน​กัน​ทุก​โปร​เจ​กต์ และ​คอร์ส​นี้​จะ​ไม่​แกล้ง​ทำ​เป็น​ว่า​มัน​เหมือน

ทาง A คุ้ม​เมื่อ โดเมน​ของ​คุณ​แทบ​ไม่มี​กติกา · ถ้า Appointment เป็น​แค่​ที่​เก็บ​ค่า​ห้า​ช่อง การ​จ่าย 51 บรรทัด​เพื่อ​แยก​มัน​ออก​มา คือ​การ​จ่าย​ค่า​โครงสร้าง​ที่​ไม่มี​อะไร​ให้​ปกป้อง

ทาง B คุ้ม​เมื่อ​โดเมน​มี​กติกา​ที่​ต้อง​รักษา · ซึ่ง​เป็น​กรณี​ของ​คอร์ส​นี้ · Slot ที่​สร้าง​ผิด​ไม่​ได้ และ DoctorId ที่​สลับ​กับ PatientId ไม่​ได้ คือ​ของ​ที่​เรา​ไม่​ยอม​ทิ้ง

คอร์ส​นี้​เดิน​ทาง B ต่อ และ​เหตุผล​ไม่ใช่​เพราะ​มัน​สวย​กว่า · แต่​เพราะ​บท​ที่ 6 กับ 7 ต้องการ​โดเมน​ที่​ยัง​ทดสอบ​ได้​โดย​ไม่มี Spring

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

  • ยัง​ไม่มี​ใคร​เรียก repository นี้ ตัว​ที่​เรียก​คือ use case ของ​บท​ที่ 5
  • ยัง​ไม่มี​ขอบ transaction add กับ slotsOf ยัง​เป็น​สอง​คำ​สั่ง​ที่​ไม่รู้​จัก​กัน
  • ยัง​ไม่​ได้​เขียน​ด่าน​ที่​นับ jar ที่ callout ข้าง​บน​เสนอ​ไว้ · บท​ที่ 8 เป็น​บท​ที่​วาง​ด่าน ของ​โปร​เจ​กต์​นี้​ทั้ง​ชุด

บท​นี้​ให้​ตัวเลข​สอง​ชุด และ​ให้​ข้อ​ที่​ไม่มี​ตัวเลข​หนึ่ง​ข้อ

ตัวเลข​คือ 51 บรรทัด​ใน file สี่​ใบ สำหรับ​ทาง​ที่​แยก และ jar เพิ่ม​หนึ่ง​ใบ บน classpath ของ​โดเมน สำหรับ​ทาง​ที่​ยอม

ข้อ​ที่​ไม่มี​ตัวเลข​คือ ด่าน​ที่​คุณ​เขียน​ไว้​ใน​บท​ที่ 1 ไม่​ได้​บังคับ​หลัก​การ​ทั้ง​ข้อ · มัน​บังคับ​ได้​เท่า​ชื่อ​ที่​มัน​รู้จัก

บท​หน้า​เรา​เรียก repository นี้​จาก​ที่​ที่​ควร​เรียก และ​วาง​ขอบ transaction ให้​ครอบ​งาน​ทั้ง​งาน — ที่ use case ไม่ใช่​ที่ repository


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

ทั้ง​สอง​ทาง​ถูก​สร้าง​ขึ้น​จริง​แล้ว​แปล​ผ่าน​ทั้ง​คู่ ก่อน​ที่​ตัวเลข​จะ​ถูก​จด:

  • วัด​เมื่อ 2026-09-01 บน macOS arm64 · Gradle 9.7.1 · Kotlin 2.4.10 · JDK 25
  • รัน​ซ้ำ​เอง​ได้​ด้วย npm run verify:clinic-clean-arch ซึ่ง​เขียน​ทั้ง​สอง​ทาง​ลง​โปร​เจ​กต์​จริง แปล​ทั้ง​คู่ นับ jar จาก compileClasspath และ​นับ​บรรทัด​จาก file จริง
  • จำนวน​บรรทัด​ตัด​บรรทัด​ว่าง​และ​บรรทัด​คอม​เมน​ต์​ล้วน​ออก · ตัว​นับ​อยู่​ใน probe เดียวกัน
  • Jakarta Persistence 3.2 (เข้า​ถึง 2026-09-01) — พิกัด​ของ jakarta.persistence-api ที่ทาง A ต้อง​ดึง​เข้า​มา
  • Spring Data JPA — Query methods (เข้า​ถึง 2026-09-01) — findByDoctorId ที่ Spring Data สร้าง query ให้​จาก​ชื่อ

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

ข้อ 1 / 3

ทาง A ทำให้โดเมนมี jakarta.persistence-api อยู่บน classpath · แล้วทำไมด่านของบทที่ 1 ถึงยังเขียว