JPA ดึงตัวเองเข้าไปหาโดเมน และมันไม่บอกคุณตอน build
โดเมนของบทที่แล้วรับรายการนัดเข้ามาเป็นค่า · บทนี้เราหารายการนั้นจากฐานข้อมูลจริง
และสิ่งที่เจอไม่ใช่เรื่องการแปลงข้อมูล · มันคือ build ที่เขียวสามครั้งติด ก่อนที่อะไรจะพัง
บทนี้ต้องมี PostgreSQL 18 ใน container · คอร์สใช้ตัวของตัวเอง ไม่ใช้ร่วมกับบทความอื่น
เพราะตัวที่บทความใช้ปิด autovacuum ไว้วัดของอย่างอื่น และไม่ publish port ออกมา
ถ้า container เคยหยุดไปแล้ว docker start ไม่เท่ากับสร้างใหม่
· port ยังรับ TCP แต่ส่งต่อไปที่ IP เดิมซึ่งตายแล้ว · JDBC จะตายด้วย EOFException
ทั้งที่ docker exec psql ยังทำงานปกติ
อุปสรรคแรกไม่ใช่เรื่อง entity เลย
หัวข้อที่มีชื่อว่า “อุปสรรคแรกไม่ใช่เรื่อง entity เลย”ก่อนจะได้เห็นปัญหาของ JPA สักข้อ โปรแกรมตายตั้งแต่สร้าง bean ตัวแรก
เพิ่ม spring-boot-starter-data-jpa เข้าไปแล้วสั่งรัน · ได้แบบนี้
Caused by: java.lang.NoClassDefFoundError: kotlin/reflect/full/KClassesCaused by: java.lang.ClassNotFoundException: kotlin.reflect.full.KClassesSpring 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 เลย เพราะโปรแกรมตายก่อนถึงตรงนั้น
ใส่ @Entity แล้ว build ยังเขียว
หัวข้อที่มีชื่อว่า “ใส่ @Entity แล้ว build ยังเขียว”นี่คือจุดที่คนส่วนใหญ่คิดว่าผ่านแล้ว
@Entitydata 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"]
คำบรรยายภาพ: ด่านสี่ด่านที่ผ่านไปได้ ก่อนถึงด่านที่ไม่ผ่าน
นี่คือความพังที่แพงที่สุดแบบหนึ่ง — ทุกด่านอัตโนมัติที่ทีมมีอยู่บอกว่าผ่าน · มันโผล่ตอนมีคนกดปุ่มบันทึกครั้งแรกเท่านั้น
หลักฐานอยู่ใน bytecode ไม่ใช่ใน log
หัวข้อที่มีชื่อว่า “หลักฐานอยู่ใน bytecode ไม่ใช่ใน log”
javapบอกได้ตรงๆ ว่า Kotlin ให้อะไรมา และ JPA ขออะไร
อ่าน class file ของ entity ทั้งก่อนและหลังเปิด plugin แล้วเทียบสองช่อง
| ไม่มี plugin | เปิด plugin แล้ว | |
|---|---|---|
| หัว class | public final class clinic.app.AppointmentRow | public class clinic.app.AppointmentRow |
มี AppointmentRow() ไหม | ไม่มี | มี |
| จำนวน constructor | 2 | 3 |
Hibernate ต้องสืบทอด class เพื่อทำ proxy · final ปิดทางนั้น
· และมันต้องสร้าง instance เปล่าขึ้นมาก่อนเติมค่า ซึ่งต้องมี no-arg constructorno-arg constructorconstructor ที่ไม่รับอะไรเลย ซึ่ง Hibernate ต้องใช้สร้าง entity ขึ้นมาก่อนเติมค่า · `data class` ที่รับค่าครบใน constructor ไม่มีตัวนี้ให้ และ Kotlin ไม่สร้างให้เอง
ตารางข้างบนบอกว่าก่อนเปิด 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 แล้วสรุปว่า “มีหลายตัวแล้วน่าจะพอ” คือการอ่านผิดที่เกิดง่ายที่สุดตรงนี้
plugin.jpa แก้สองอย่างพร้อมกัน
หัวข้อที่มีชื่อว่า “plugin.jpa แก้สองอย่างพร้อมกัน”หนึ่งบรรทัดใน
pluginsแก้ทั้งเรื่องfinalและเรื่อง constructor
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
ธรรมดา ส่วนของที่รันจริงไม่ใช่
พอเปิด 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 เข้ามา คืออะไร