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

ขอบ transaction อยู่​ที่ use case ไม่ใช่​ที่ repository

บท​ที่​แล้ว​ให้ interface ที่​โดเมน​ประกาศ​ไว้ · บท​นี้​เขียน​ชั้น​ที่​เรียก​มัน

และ​คำถาม​ที่​บท​นี้​ตอบ​ด้วย​ตัวเลข​คือ งาน​หนึ่ง​ชิ้น​ควร​เป็น transaction กี่​อัน

use case คือ​ชั้น​ที่​รู้​ว่า​งาน​ทั้ง​งาน​จบ​ตรง​ไหน

หัวข้อ​ที่​มีชื่อ​ว่า “use case คือ​ชั้น​ที่​รู้​ว่า​งาน​ทั้ง​งาน​จบ​ตรง​ไหน”

repository รู้​แค่​ว่า​จะ​อ่าน​หรือ​จะ​เขียน · มัน​ไม่รู้​ว่า​อ่าน​ไป​ทำไม

การ​จอง​หนึ่ง​ครั้ง​มี​สาม​ขั้น​ตอน คือ อ่าน​นัด​ของ​หมอ​คน​นั้น · ถาม​โดเมน​ว่า​จอง​ได้​ไหม · แล้ว​เขียน​ถ้า​จอง​ได้

app/src/main/kotlin/clinic/app/BookAppointment.kt
package clinic.app
import clinic.domain.AppointmentRepository
import clinic.domain.Booking
import clinic.domain.DoctorId
import clinic.domain.PatientId
import clinic.domain.Slot
import clinic.domain.book
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class 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 ที่​กำลัง​ทำงาน​อยู่ · เรียก​สอง​ครั้ง​ใน​งาน​เดียว แล้ว​นับ​จำนวน​คำ​ตอบ​ที่​ต่าง​กัน

app/src/main/kotlin/clinic/app/persistence/TxProbe.kt
package clinic.app.persistence
import org.springframework.data.jpa.repository.JpaRepository
import 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 ที่เหมือน​กัน​ทุก​ตัว​อักษร ต่าง​กัน​แค่​บรรทัด​เดียว

app/src/main/kotlin/clinic/app/BookingService.kt
@Service
class 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 case1
เรียก​จาก​ตัว​เอง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 ที่​ถูก​ที่​เป็น​ของ​จำเป็น แต่​ไม่ใช่​ของ​ที่​พอ

แถว​ที่​สาม​ของ​ตาราง​คือ​กับ​ดัก​ที่​คน​เล่า​ต่อ​กัน​มา และ​ตอน​นี้​มัน​เป็น​ตัวเลข

viaSelfCall เรียก withBoundary ตรงๆ ใน​ชนิด​เดียวกัน · ผล​ที่​ได้​คือ 2 ซึ่ง​เท่ากับ​ไม่มี​ขอบ​เลย ทั้ง​ที่ method ที่​ถูก​เรียก​ติด @Transactional อยู่​ชัดๆ

Spring ทำ ขอบ​ของ transaction ด้วย proxy ที่​ห่อ bean ไว้​อีก​ชั้น · การ​เรียก​จาก​ภายนอก​วิ่ง​ผ่าน proxy ส่วน​การ​เรียก​จาก​ตัว​เอง​ไม่​วิ่ง​ผ่าน มัน​กระโดด​ตรง​ไป​ที่ method

วิธี​จับ​ข้อ​นี้ คือ​วัด ไม่ใช่​การ​อ่าน code

ความ​พัง​แบบ​นี้​ไม่มี​อะไร​ฟ้อง · 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