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

JPA ดึง​ตัว​เอง​เข้าไป​หา​โดเมน และ​มัน​ไม่​บอก​คุณ​ตอน build

โดเมน​ของ​บท​ที่​แล้ว​รับ​รายการ​นัด​เข้า​มา​เป็น​ค่า · บท​นี้​เรา​หา​รายการ​นั้น​จาก​ฐาน​ข้อมูล​จริง

และ​สิ่ง​ที่​เจอ​ไม่ใช่​เรื่อง​การ​แปลง​ข้อมูล · มัน​คือ build ที่​เขียว​สาม​ครั้ง​ติด ก่อน​ที่​อะไร​จะ​พัง

📦 ต่อ​ฐาน​ข้อมูล​จริง​ตั้งแต่​บท​นี้

บท​นี้​ต้อง​มี PostgreSQL 18 ใน container · คอร์ส​ใช้​ตัว​ของ​ตัว​เอง ไม่​ใช้​ร่วม​กับ​บทความ​อื่น เพราะ​ตัว​ที่​บทความ​ใช้​ปิด autovacuum ไว้​วัด​ของ​อย่าง​อื่น และ​ไม่ publish port ออก​มา

ถ้า container เคย​หยุด​ไป​แล้ว docker start ไม่​เท่ากับ​สร้าง​ใหม่ · port ยัง​รับ TCP แต่​ส่ง​ต่อ​ไป​ที่ IP เดิม​ซึ่ง​ตาย​แล้ว · JDBC จะ​ตาย​ด้วย EOFException ทั้ง​ที่ docker exec psql ยัง​ทำงาน​ปกติ

ก่อน​จะ​ได้​เห็น​ปัญหา​ของ JPA สัก​ข้อ โปรแกรม​ตาย​ตั้งแต่​สร้าง bean ตัว​แรก

เพิ่ม spring-boot-starter-data-jpa เข้าไป​แล้ว​สั่ง​รัน · ได้​แบบ​นี้

Caused by: java.lang.NoClassDefFoundError: kotlin/reflect/full/KClasses
Caused by: java.lang.ClassNotFoundException: kotlin.reflect.full.KClasses

Spring Boot 4.1.1 กับ Kotlin 2.4.10 ไม่​ได้​ลาก kotlin-reflectkotlin-reflectartifact ที่ Spring Boot 4.1.1 ไม่​ลาก​มา​ให้ แต่​ต้อง​มี · ขาด​แล้ว​โปรแกรม​ตาย​ตั้งแต่​สร้าง bean ด้วย `NoClassDefFoundError: kotlin/reflect/full/KClasses` ก่อน​จะ​ได้​เห็น​ข้อ​ผิด​พลาด​เรื่อง entity สัก​ตัว มา​ให้​เอง · ต้อง​ประกาศ​เอง​หนึ่ง​บรรทัด

implementation(kotlin("reflect"))

ข้อ​นี้​ไม่มี​อะไร​ลึก​ซึ้ง แต่​มัน​มา​ก่อน​ทุก​อย่าง · ถ้า​ไม่​ผ่าน​ข้อ​นี้ คุณ​จะ​ไม่มี​วัน​ได้​เห็น ปัญหา​ของ entity เลย เพราะ​โปรแกรม​ตาย​ก่อน​ถึง​ตรง​นั้น

นี่​คือ​จุด​ที่​คน​ส่วน​ใหญ่​คิด​ว่า​ผ่าน​แล้ว

app/src/main/kotlin/clinic/app/App.kt
@Entity
data class AppointmentRow(
@Id val id: Long,
val doctor: String,
val note: String,
@Version val version: Long = 0,
)
interface AppointmentRepo : JpaRepository<AppointmentRow, Long>

@Entity@Entityการ​ประกาศ​ว่า class นี้​คือ​แถว​หนึ่ง​แถว · วาง​บน `data class` แล้ว build ยัง​เขียว context ยัง​ส​ตาร์ต​ขึ้น แล้ว​มัน​ไป​ตาย​ที่ `save()` ครั้ง​แรก​ด้วย `org.hibernate.InstantiationException` บน data class แปล​ผ่าน · รหัส​ออก 0 · ไม่มี error และ​ไม่มี​คำ​เตือน​สัก​บรรทัด

ช่อง version ยัง​ไม่​ได้​ใช้​ใน​บท​นี้ · บท​ที่ 7 จะ​กลับ​มา​ที่​มัน​ตอน​ที่​มี​สอง​คน​แก้​ของ​ชิ้น​เดียวกัน

โปร​แก​รม​ส​ตาร์ต​ขึ้น และ​คำ​เตือน​เดียว​ที่​มี​อยู่​ใน​ที่​ที่​ไม่มี​ใคร​มอง

หัวข้อ​ที่​มีชื่อ​ว่า “โปร​แก​รม​ส​ตาร์ต​ขึ้น และ​คำ​เตือน​เดียว​ที่​มี​อยู่​ใน​ที่​ที่​ไม่มี​ใคร​มอง”

spec ฉบับ​แรก​ของ​คอร์ส​นี้​เขียน​ว่า​มัน​พัง​ตอน​รัน ซึ่ง​กว้าง​เกิน​ไป​จน​ทำให้​คาด​ผิด

สั่ง​รัน​แล้ว context ส​ตาร์ต​ขึ้น​เรียบร้อย · Hibernate พิมพ์​บรรทัด​นี้​ออก​มา

INFO ... org.hibernate.orm.core : HHH000182: No default (no-argument) constructor
for class [clinic.app.AppointmentRow] (class must be instantiated by Interceptor)

มัน​เป็น​ระดับ INFO ไม่ใช่ WARN และ​ไม่ใช่ ERROR

รัน​ซ้ำ​โดย​ตั้ง​ระดับ log เป็น WARN ซึ่ง​เป็น​ค่าที่​โปร​เจ​กต์​จริง​ส่วน​ใหญ่​ใช้ · บรรทัด​นั้นหาย​ไป​ทั้ง​บรรทัด

ระดับ log ที่​ตั้ง​ไว้เห็น HHH000182 ไหม
INFOเห็น
WARNไม่​เห็น

คำ​เตือน​ที่​มอง​ไม่​เห็น​ใน​สภาพ​จริง มี​ค่า​เท่ากับ​ไม่มี​คำ​เตือน

ไม่ใช่​ตอน​แปล ไม่​ใช่​ตอน​ส​ตาร์ต แต่​ตอน​ที่​มี​คน​เรียก save()

org.hibernate.InstantiationException: No default constructor for entity 'clinic.app.AppointmentRow'
flowchart TB
  A["เพิ่ม starter-data-jpa"] --> B{"รัน"}
  B -->|"ขาด kotlin-reflect"| X1["ตายที่ NoClassDefFoundError"]
  B -->|"ใส่ reflect แล้ว"| C["compile เขียว รหัสออก 0"]
  C --> D["context สตาร์ตขึ้น"]
  D --> E["HHH000182 ที่ระดับ INFO เท่านั้น"]
  E --> F{"save ครั้งแรก"}
  F --> X2["ตายที่ InstantiationException"]

คำ​บรรยาย​ภาพ: ด่าน​สี่​ด่าน​ที่​ผ่าน​ไป​ได้ ก่อน​ถึง​ด่าน​ที่​ไม่​ผ่าน

นี่​คือ​ความ​พัง​ที่​แพง​ที่สุด​แบบ​หนึ่ง — ทุก​ด่าน​อัตโนมัติ​ที่​ทีม​มี​อยู่​บอก​ว่า​ผ่าน · มัน​โผล่​ตอน​มี​คน​กด​ปุ่ม​บันทึก​ครั้ง​แรก​เท่านั้น

javap บอก​ได้​ตรงๆ ว่า Kotlin ให้​อะไร​มา และ JPA ขอ​อะไร

อ่าน class file ของ entity ทั้ง​ก่อน​และ​หลัง​เปิด plugin แล้ว​เทียบ​สอง​ช่อง

ไม่มี pluginเปิด plugin แล้ว
หัว classpublic final class clinic.app.AppointmentRowpublic class clinic.app.AppointmentRow
มี AppointmentRow() ไหมไม่มีมี
จำนวน constructor23

Hibernate ต้อง​สืบทอด class เพื่อ​ทำ proxy · final ปิด​ทาง​นั้น · และ​มัน​ต้อง​สร้าง instance เปล่า​ขึ้น​มา​ก่อน​เติม​ค่า ซึ่ง​ต้อง​มี no-arg constructorno-arg constructorconstructor ที่​ไม่​รับ​อะไร​เลย ซึ่ง Hibernate ต้อง​ใช้​สร้าง entity ขึ้น​มา​ก่อน​เติม​ค่า · `data class` ที่​รับ​ค่า​ครบ​ใน constructor ไม่มี​ตัว​นี้​ให้ และ Kotlin ไม่​สร้าง​ให้​เอง

constructor ตัว​ที่​สอง​มี​อยู่​แล้ว​ตั้งแต่​แรก และ​มัน​ช่วย​อะไร​ไม่​ได้

ตาราง​ข้าง​บน​บอก​ว่า​ก่อน​เปิด plugin มี constructor อยู่ 2 ตัว ไม่ใช่​ตัว​เดียว

AppointmentRow(long, java.lang.String, java.lang.String, long)
AppointmentRow(long, java.lang.String, java.lang.String, long, int, kotlin.jvm.internal.DefaultConstructorMarker)

ตัว​ที่​สอง​เกิด​ขึ้น​เพราะ version มี​ค่า​ตั้ง​ต้น · Kotlin จึง​สร้าง​ตัว​ที่​รับ mask ของ argument ที่​ถูก​ข้าม​เพิ่ม​มา​ให้

แต่​มัน​ยัง​รับ argument อยู่ดี · สิ่ง​ที่ Hibernate ต้องการ​คือ​ตัว​ที่​ไม่​รับ​อะไร​เลย · การ​เห็น​เลข 2 แล้ว​สรุป​ว่า “มี​หลาย​ตัว​แล้ว​น่า​จะ​พอ” คือ​การ​อ่าน​ผิด​ที่​เกิด​ง่าย​ที่สุด​ตรง​นี้

หนึ่ง​บรรทัด​ใน plugins แก้​ทั้ง​เรื่อง final และ​เรื่อง constructor

app/build.gradle.kts
plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}

plugin.jpakotlin("plugin.jpa")plugin ที่​แก้​สอง​เรื่อง​พร้อม​กัน​ตอน​แปล · มัน​เติม constructor ที่​ไม่​รับ​อะไร​เลย และ​เปิด class กับ method ให้​สืบทอด​ได้ · ยืนยัน​ได้​จาก bytecode ไม่ใช่​จาก​เอกสาร รวม​สอง​อย่าง​ไว้​ด้วย​กัน · ครึ่ง​แรก​คือ all-openall-openครึ่ง​หนึ่ง​ของ​สิ่ง​ที่ `plugin.jpa` ทำ · Kotlin ปิด class เป็น​ค่า​ตั้ง​ต้น แต่ Hibernate ต้อง​สืบทอด​เพื่อ​ทำ proxy จึง​ต้อง​มี​ใคร​สัก​คน​เปิด​ให้​ใน​ขั้น​แปล ซึ่ง​ถอด final ออก​จาก class ที่​ติด annotation ของ JPA · ครึ่ง​หลัง​เติม constructor เปล่า​ให้

ทั้ง​สอง​อย่าง​เกิด​ขึ้นตอน​แปล ไม่ใช่​ตอน​รัน · source ที่​คุณ​อ่าน​จึง​ยัง​เป็น data class ธรรมดา ส่วน​ของ​ที่​รัน​จริง​ไม่ใช่

ราคา​ที่​จ่าย​ไป และ​มัน​คือ​เหตุผล​ที่​บท​ที่ 4 มี​อยู่

พอ​เปิด plugin แล้ว entity ของ​คุณ ไม่ final อีก​ต่อ​ไป และ​มี constructor ที่​สร้าง object ที่​ยัง​ไม่มี​ค่า​อะไร​เลย​ได้

นั่น​แปล​ว่า​กติกา​ที่​บท​ที่ 2 สร้าง​ไว้ — สร้าง​ของ​ที่​ผิด​ขึ้น​มา​ไม่​ได้​เลย — ใช้​กับ class ตัว​นี้​ไม่​ได้​แล้ว · AppointmentRow() สร้าง object ที่​ทุก​ช่อง​ยัง​ว่าง​ได้​ตาม​สบาย

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

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

  • ยัง​ไม่​ได้​แยก model AppointmentRow อยู่​ใน​โมดูล app ก็​จริง แต่​โดเมน​ยัง​ไม่มี ทาง​คุย​กับ​มัน · บท​ที่ 4 เป็น​บท​ที่​ตัดสิน​ใจ​เรื่อง​นี้
  • ยัง​ไม่มี​ขอบ transaction ทุก​อย่าง​ใน​บท​นี้​เป็นการ​เขียน​ครั้ง​เดียว​จบ · บท​ที่ 5 วาง​ขอบ​ให้​ครอบ​งาน​ทั้ง​งาน
  • ยัง​ไม่มี​ใคร​กัน​จอง​ซ้อน ตาราง​ยัง​รับ​แถว​ที่​ทับ​เวลา​กัน​ได้​ทุก​แถว · บท​ที่ 6 คือ​บท​ที่​ทำให้​มัน​เกิด​ขึ้น​ให้​ดู แล้ว​ค่อย​แก้

บท​นี้​ให้​ลำดับ​ความ​พัง ไม่ใช่​แค่​ข้อ​สรุป​ว่า​พัง

คุณ​เห็น​แล้ว​ว่า @Entity บน data class ผ่าน​ทุก​ด่าน​อัตโนมัติ แล้ว​ตาย​ที่ save() ครั้ง​แรก · เห็น​แล้ว​ว่า​คำ​เตือน​เดียว​ที่​มี​อยู่​ใน​ระดับ​ที่​โปร​เจ​กต์​จริง​ปิด​ไว้ · และ​เห็น​แล้ว​ว่า​หลัก​ฐาน​ที่​แน่​ที่สุด​อยู่​ใน bytecode

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


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

ข้อความ​ข้อ​ผิด​พลาด ระดับ log และ​ตัวเลข​จาก bytecode ทุก​ช่อง มา​จาก​การ​รัน​จริง:

  • วัด​เมื่อ 2026-09-01 บน macOS arm64 · Spring Boot 4.1.1 · Kotlin 2.4.10 · Hibernate ที่มา​กับ starter รุ่น​นั้น · PostgreSQL 18.6
  • รัน​ซ้ำ​เอง​ได้​ด้วย npm run verify:clinic-clean-arch ซึ่ง​รัน bootRun สอง​ระดับ log แล้ว​อ่าน​หัว class กับ​ราย​ชื่อ constructor ด้วย javap ทั้ง​สอง​ทรง
  • Kotlin — Compiler plugins (เข้า​ถึง 2026-09-01) — all-open กับ no-arg และ​รูป​รวม​ที่​ชื่อ kotlin-jpa
  • Jakarta Persistence 3.2 specification (เข้า​ถึง 2026-09-01) — ข้อ​กำหนด​ว่า entity ต้อง​มี constructor ที่​ไม่​รับ​อะไร​เลย และ​ต้อง​ไม่​เป็น final

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

ข้อ 1 / 3

อุปสรรคแรกที่เจอจริงเมื่อเพิ่ม starter-data-jpa เข้ามา คืออะไร