ราคาของ model ตัวที่สอง คือห้าสิบเอ็ดบรรทัด และด่านที่ไม่ช่วยคุณ
บทที่แล้วจบด้วยคำถามที่ยังไม่ได้ตอบ · จะยอมให้โดเมนเปลี่ยนรูปเพื่อ JPA หรือจะแยก model ออกเป็นสองชุด
บทนี้สร้างทั้งสองทางขึ้นมาจริง แล้ววัดราคาของแต่ละทางออกมาเป็นตัวเลข · ไม่ใช่เพื่อบอกว่าทางไหนถูก แต่เพื่อให้คุณรู้ว่ากำลังจ่ายอะไร
ทาง A ยอมให้โดเมนติด annotation เอง
หัวข้อที่มีชื่อว่า “ทาง A ยอมให้โดเมนติด annotation เอง”ทางนี้ไม่ต้องเขียนอะไรเพิ่มเลย · เอา annotation ไปแปะบนของที่มีอยู่แล้ว
@Entitydata class Appointment( @Id val id: Long, val doctorId: String, val patientId: String, val startMinute: Int, val endMinute: Int,)โมดูล domain ต้องดึง API ของ persistence เข้ามาด้วย
plugins { kotlin("plugin.jpa")}dependencies { implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")}แปลผ่าน · บรรทัดที่เขียนเพิ่มเพื่อการแปลงข้อมูล คือศูนย์
ด่านของบทที่ 1 ยังเขียวให้ทางนี้
หัวข้อที่มีชื่อว่า “ด่านของบทที่ 1 ยังเขียวให้ทางนี้”นี่คือข้อที่บทนี้มีอยู่เพื่อมัน และมันไม่เข้าข้างสิ่งที่เราสร้างไว้เอง
เอา Leak.kt ของบทที่ 1 กลับมาใส่ในโดเมนที่แก้เป็นทาง A แล้ว · แล้วแปลใหม่
มันยังแดงเหมือนเดิม · Unresolved reference 'springframework'
ด่านนั้นทำงานถูกทุกประการ · มันถามว่า “โดเมนเห็น Spring ไหม” และคำตอบยังเป็นไม่เห็น · แต่คำถามที่เราอยากถามจริงๆ คือ “โดเมนรู้จักเรื่องการเก็บข้อมูลหรือยัง” ซึ่งเป็นคนละคำถาม
นับ jar บน compile classpath ของโดเมนออกมาแล้วเห็นชัด
| ทาง | jar บน classpath ของ domain | มี jakarta.persistence-api ไหม |
|---|---|---|
| ฐานของบทที่ 2 | 2 | ไม่มี |
| ทาง A | 3 | มี |
| ทาง B | 2 | ไม่มี |
flowchart TB P["หลักการ · โดเมนไม่รู้จักเรื่องการเก็บข้อมูล"] G["ด่านของบทที่ 1 · โดเมนต้อง import Spring ไม่ได้"] A["ทาง A · โดเมนมี jakarta.persistence บน classpath"] P -->|"ด่านครอบได้แค่ส่วนหนึ่ง"| G A -->|"ละเมิด"| P A -->|"ผ่าน"| G
คำบรรยายภาพ: วงที่ด่านกวาด เล็กกว่าวงของข้ออ้างที่มันบอกว่าบังคับ
ด่านที่ห้าม 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ที่ปฏิเสธค่าผิดตั้งแต่สร้าง หายไปพร้อมกัน
ทั้งหมดนี้ไม่ปรากฏในตัวเลข “เขียนเพิ่มศูนย์บรรทัด” เลยสักนิด
ทาง B แยกเป็นสองชุด
หัวข้อที่มีชื่อว่า “ทาง B แยกเป็นสองชุด”โดเมนประกาศแต่สิ่งที่มันต้องการ · ของที่รู้จัก JPA อยู่ในโมดูลนอกทั้งหมด
โดเมนได้ interface หนึ่งอัน ซึ่งคือ repositoryrepositoryinterface ที่โดเมนประกาศไว้ว่าต้องการอะไร แล้วชั้นนอกไปหาให้ · โดเมนถือแต่ interface จึงไม่มีบรรทัดไหนใน `domain` ที่พูดถึง Spring Data ในความหมายของ clean architecture
package clinic.domain
interface AppointmentRepository { fun slotsOf(doctorId: DoctorId): List<Slot> fun add(appointment: Appointment)}โมดูลนอกได้ persistence modelpersistence modelชุด class ที่มีไว้คุยกับฐานข้อมูลอย่างเดียว แยกจาก model ของโดเมน · มันทำให้โดเมนไม่ต้องรู้จัก `@Entity` เลย และราคาที่จ่ายนับเป็นจำนวนบรรทัดได้ ของตัวเอง ซึ่งเป็นชนิดที่มีไว้คุยกับตารางอย่างเดียว
package clinic.app.persistence
import jakarta.persistence.Entityimport jakarta.persistence.GeneratedValueimport jakarta.persistence.Id
@Entityclass AppointmentRow( @Id @GeneratedValue val id: Long = 0, val doctorId: String = "", val patientId: String = "", val startMinute: Int = 0, val endMinute: Int = 0,)แล้วต้องมี mappermappercode ที่แปลงระหว่าง model สองชุด · มันคือที่ที่ราคาของการแยกไปกองอยู่ และเป็นที่ที่ข้อผิดพลาดแบบสลับช่องเกิดโดยที่ compiler ไม่เห็น ถ้าสองช่องเป็นชนิดเดียวกัน แปลงระหว่างสองชุดนั้น
package clinic.app.persistence
import clinic.domain.Appointmentimport clinic.domain.DoctorIdimport clinic.domain.PatientIdimport 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
package clinic.app.persistence
import clinic.domain.Appointmentimport clinic.domain.AppointmentRepositoryimport clinic.domain.DoctorIdimport clinic.domain.Slotimport org.springframework.data.jpa.repository.JpaRepositoryimport org.springframework.stereotype.Repository
interface AppointmentRows : JpaRepository<AppointmentRow, Long> { fun findByDoctorId(doctorId: String): List<AppointmentRow>}
@Repositoryclass 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()) }}ห้าสิบเอ็ดบรรทัด กระจายอยู่ใน file สี่ใบ
หัวข้อที่มีชื่อว่า “ห้าสิบเอ็ดบรรทัด กระจายอยู่ใน file สี่ใบ”นับเฉพาะบรรทัดที่มี code จริง ตัดบรรทัดว่างและคอมเมนต์ออกแล้ว
| file | บรรทัด |
|---|---|
AppointmentRepository.kt | 5 |
AppointmentRow.kt | 12 |
AppointmentMapper.kt | 16 |
JpaAppointmentRepository.kt | 18 |
| รวม | 51 |
และ classpath ของโดเมนยังเป็น 2 เท่าเดิม
ดู 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 ถึงยังเขียว