ขอบ transaction อยู่ที่ use case ไม่ใช่ที่ repository
บทที่แล้วให้ interface ที่โดเมนประกาศไว้ · บทนี้เขียนชั้นที่เรียกมัน
และคำถามที่บทนี้ตอบด้วยตัวเลขคือ งานหนึ่งชิ้นควรเป็น transaction กี่อัน
use case คือชั้นที่รู้ว่างานทั้งงานจบตรงไหน
หัวข้อที่มีชื่อว่า “use case คือชั้นที่รู้ว่างานทั้งงานจบตรงไหน”repository รู้แค่ว่าจะอ่านหรือจะเขียน · มันไม่รู้ว่าอ่านไปทำไม
การจองหนึ่งครั้งมีสามขั้นตอน คือ อ่านนัดของหมอคนนั้น · ถามโดเมนว่าจองได้ไหม · แล้วเขียนถ้าจองได้
package clinic.app
import clinic.domain.AppointmentRepositoryimport clinic.domain.Bookingimport clinic.domain.DoctorIdimport clinic.domain.PatientIdimport clinic.domain.Slotimport clinic.domain.bookimport org.springframework.stereotype.Serviceimport org.springframework.transaction.annotation.Transactional
@Serviceclass BookAppointment(private val appointments: AppointmentRepository) {
@Transactional fun handle(doctorId: DoctorId, patientId: PatientId, wanted: Slot): Booking { val existing = appointments.slotsOf(doctorId) val result = book(doctorId, patientId, wanted, existing) if (result is Booking.Confirmed) appointments.add(result.appointment) return result }}use caseuse caseหนึ่งสิ่งที่ผู้ใช้ขอให้ระบบทำ เขียนเป็นหนึ่ง class ที่มีทางเข้าเดียว · มันเป็นที่ที่ขอบของ transaction ควรอยู่ เพราะมันรู้ว่างานทั้งงานจบตรงไหน ตัวนี้ไม่รู้จัก JPA และไม่รู้จักตาราง · มันรู้จักแต่ interface ของโดเมน
กับ book ที่เป็นฟังก์ชันธรรมดา
บรรทัดเดียวที่ผูกกับ Spring คือ @Transactional@Transactionalการประกาศขอบของ transaction ด้วยคำเดียว · วางที่ use case ไม่ใช่ที่ repository เพราะขอบที่แคบเกินไปทำให้งานหนึ่งชิ้นกลายเป็นหลาย transaction ที่ล้มแยกกันได้ ซึ่งเป็นบรรทัดที่บทนี้มีอยู่เพื่อมัน
วัดด้วยคำตอบของฐานข้อมูล ไม่ใช่คำตอบของ Spring
หัวข้อที่มีชื่อว่า “วัดด้วยคำตอบของฐานข้อมูล ไม่ใช่คำตอบของ Spring”ถาม PostgreSQL ตรงๆ ว่าตอนนี้อยู่ใน transaction ไหน แล้วนับว่าได้คำตอบต่างกันกี่แบบ
pg_current_xact_id() คืนหมายเลขของ transaction ที่กำลังทำงานอยู่
· เรียกสองครั้งในงานเดียว แล้วนับจำนวนคำตอบที่ต่างกัน
package clinic.app.persistence
import org.springframework.data.jpa.repository.JpaRepositoryimport org.springframework.data.jpa.repository.Query
interface TxProbe : JpaRepository<AppointmentRow, Long> { @Query(value = "SELECT pg_current_xact_id()::text", nativeQuery = true) fun txId(): String}แล้วเขียนสอง method ที่เหมือนกันทุกตัวอักษร ต่างกันแค่บรรทัดเดียว
@Serviceclass BookingService(private val probe: TxProbe) {
/** ไม่มีขอบ · การแตะฐานข้อมูลแต่ละครั้งเปิด transaction ของตัวเอง */ fun withoutBoundary(): Int = setOf(probe.txId(), probe.txId()).size
/** มีขอบที่ use case · การแตะฐานข้อมูลทุกครั้งอยู่ใน transaction เดียวกัน */ @Transactional fun withBoundary(): Int = setOf(probe.txId(), probe.txId()).size
/** เรียก method ที่มีขอบจากตัวเอง · ไม่ผ่าน proxy ขอบจึงไม่ทำงาน */ fun viaSelfCall(): Int = withBoundary()}| ทรง | transaction id ที่ต่างกัน |
|---|---|
| ไม่มีขอบ | 2 |
| มีขอบที่ use case | 1 |
| เรียกจากตัวเอง | 2 |
แถวแรกไม่ใช่แค่ผลลัพธ์ · มันคือช่องควบคุมของการวัดทั้งชุด · ถ้ามันได้ 1 ด้วย แปลว่าตัววัดพัง ไม่ใช่ว่าขอบทำงาน
flowchart TB
U["use case · จองหนึ่งครั้ง"]
R1["อ่านนัดของหมอ"]
R2["เขียนนัดใหม่"]
U --> R1 --> R2
subgraph NB["ไม่มีขอบ"]
T1["transaction ที่ 1"]
T2["transaction ที่ 2"]
end
subgraph WB["มีขอบที่ use case"]
T3["transaction เดียว ครอบทั้งสองขั้น"]
end
R1 -.-> T1
R2 -.-> T2
R1 -.-> T3
R2 -.-> T3
คำบรรยายภาพ: งานชิ้นเดียวกัน แต่ฐานข้อมูลเห็นเป็นหนึ่งหรือสองรายการ แล้วแต่ว่าขอบอยู่ตรงไหน
ทำไมสองถึงเป็นปัญหา ไม่ใช่แค่ตัวเลขที่ใหญ่กว่า
หัวข้อที่มีชื่อว่า “ทำไมสองถึงเป็นปัญหา ไม่ใช่แค่ตัวเลขที่ใหญ่กว่า”ระหว่างสอง transaction นั้น มีช่องว่างที่คนอื่นเขียนแทรกได้
ในทรงที่ไม่มีขอบ การอ่านจบไปแล้วก่อนที่การเขียนจะเริ่ม · รายการนัดที่โดเมนใช้ตัดสินใจ จึงเป็นภาพของอดีต ไม่ใช่ภาพของตอนเขียน
นี่คือข้อเดียวกับที่บทที่ 2 บอกไว้ — โดเมนตรวจถูกเสมอ ตราบใดที่สิ่งที่ป้อนให้มัน เป็นความจริง · ขอบที่กว้างขึ้นทำให้ช่องว่างนั้นแคบลง
ถ้าคุณอ่านถึงตรงนี้แล้วคิดว่าปัญหาจบแล้ว บทที่ 6 จะทำให้ผิดหวัง
@Transactional ที่ระดับปริยายของ PostgreSQL คือ READ COMMITTED
· สอง transaction ที่รันพร้อมกัน ต่างคนต่างอ่านไม่เห็นแถวของอีกฝ่าย
เพราะยังไม่มีใคร commit
ทั้งคู่จึงผ่านด่านโดเมน แล้วเขียนแถวที่ซ้อนกันลงไปทั้งคู่ · บทที่ 6 ทำให้เหตุการณ์นี้เกิดขึ้นให้ดู แล้วบอกว่าใครต้องเป็นคนกัน
ขอบ transaction ที่ถูกที่เป็นของจำเป็น แต่ไม่ใช่ของที่พอ
@Transactional ที่ถูกเรียกจากตัวเอง ไม่ทำงาน
หัวข้อที่มีชื่อว่า “@Transactional ที่ถูกเรียกจากตัวเอง ไม่ทำงาน”แถวที่สามของตารางคือกับดักที่คนเล่าต่อกันมา และตอนนี้มันเป็นตัวเลข
viaSelfCall เรียก withBoundary ตรงๆ ในชนิดเดียวกัน · ผลที่ได้คือ 2
ซึ่งเท่ากับไม่มีขอบเลย ทั้งที่ method ที่ถูกเรียกติด @Transactional อยู่ชัดๆ
Spring ทำ ขอบของ transaction ด้วย proxy ที่ห่อ bean ไว้อีกชั้น · การเรียกจากภายนอกวิ่งผ่าน proxy ส่วนการเรียกจากตัวเองไม่วิ่งผ่าน มันกระโดดตรงไปที่ method
ความพังแบบนี้ไม่มีอะไรฟ้อง · build เขียว โปรแกรมรันได้ ผลลัพธ์ทางธุรกิจถูกเกือบทุกครั้ง
การอ่าน code ก็ไม่ช่วย เพราะสิ่งที่คุณเห็นคือ @Transactional ที่วางอยู่ถูกที่แล้ว
· สิ่งที่มองไม่เห็นคือทางที่การเรียกวิ่งไป
ถามฐานข้อมูลว่าตอนนี้อยู่ใน transaction ไหน แล้วนับ · เป็นคำถามที่ตอบได้ในสองบรรทัด และมันตอบข้อที่การอ่าน code ตอบไม่ได้
สิ่งที่บทนี้ยังไม่ทำ
หัวข้อที่มีชื่อว่า “สิ่งที่บทนี้ยังไม่ทำ”สามอย่างที่ยังไม่มี และแต่ละอย่างมาถึงเมื่อมีเหตุผลให้ต้องมี
- ยังไม่มีใครกันจองซ้อน ขอบที่ถูกไม่ได้ทำให้สองคนจองทับกันไม่ได้ · บทที่ 6 คือบทที่พิสูจน์ข้อนี้ด้วยสอง session จริง
- ยังไม่ได้แตะ isolation level คอร์สนี้อยู่บนค่าปริยายของ PostgreSQL ทั้งเล่ม · เรื่องนั้นมีคอร์สของตัวเองอยู่แล้ว
- ยังไม่มีเทสต์ของ use case ชั้นนี้ทดสอบได้โดยไม่ต้องมีฐานข้อมูล ถ้าใส่ของปลอม แทน repository · บทที่ 8 นับให้ดูว่ามันถูกกว่าแค่ไหน
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ย้ายขอบขึ้นไปหนึ่งชั้น และวัดผลด้วยหน่วยที่ Spring โกหกไม่ได้
คุณมี use case ที่รู้ว่างานทั้งงานจบตรงไหน · มีขอบที่ครอบทั้งการอ่านและการเขียน · และมีตัวเลขที่บอกว่าขอบนั้นทำงานจริง ไม่ใช่แค่ annotation ที่วางไว้สวยๆ
บทหน้าเป็นบทที่คอร์สนี้เดินมาทั้งหมดเพื่อมัน — สอง session ที่ผ่านด่านโดเมนทั้งคู่ แล้วสร้างแถวที่ซ้อนกันจริง · และคำตอบไม่ได้อยู่ใน Kotlin
ตัวเลขทั้งสามแถวมาจากการรันจริงบนฐานข้อมูลจริง:
- วัดเมื่อ 2026-09-01 · Spring Boot 4.1.1 · Kotlin 2.4.10 · PostgreSQL 18.6
- รันซ้ำเองได้ด้วย
npm run verify:clinic-clean-archซึ่งสตาร์ตโปรแกรมจริง แล้วเรียกทั้งสาม method ตามลำดับ · ค่าที่ได้มาจากpg_current_xact_id()ไม่ใช่จาก Spring - PostgreSQL — Transaction ID functions
(เข้าถึง 2026-09-01) —
pg_current_xact_id()และข้อที่มันกำหนดหมายเลขให้ทันทีที่ถูกเรียก - Spring Framework — Declarative transaction management (เข้าถึง 2026-09-01) — กลไก proxy และเหตุที่การเรียกจากตัวเองไม่ผ่านมัน
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทำไมแถว 'ไม่มีขอบ' ที่ได้ค่า 2 ถึงสำคัญพอๆ กับแถวที่ได้ค่า 1