body ที่ตอบกลับไป ไม่มีใครเอาไปทำอะไรต่อได้
คอร์สก่อนหน้าจบด้วยระบบที่ถูกต้องข้างใน · โดเมนตรวจถูก ขอบ transaction อยู่ถูกที่ ฐานข้อมูลกันจองซ้อนได้จริง
แล้วคำถามที่ยังไม่มีใครถามคือ — ผู้เรียกที่อยู่ปลายสาย HTTP ได้อะไรกลับไปบ้าง
โปรเจกต์เดียวกับ คอร์ส Kotlin + Spring Boot + Clean Architecture · โดเมนกับ repository อยู่ครบ · สิ่งที่คอร์สนี้เพิ่มคือชั้นที่คุยกับโลกข้างนอก
ทั้งคอร์สไม่มีหน่วยเวลาสักตัว หน่วยที่ใช้คือรหัส HTTP · content-type · ชื่อและจำนวนคีย์ใน body · ชนิดของ exception · SQLSTATE · จำนวนแถว
ฐานข้อมูลของคอร์สนี้เป็นคนละ container กับคอร์สก่อน สั่งด้วย
scripts/clinic-http-boundary/up.sh · เหตุผลอยู่ในบทที่ 4 ตอนที่ constraint เข้ามา
ขอบคือที่ที่ความรู้ของเราหยุดเดินทาง
หัวข้อที่มีชื่อว่า “ขอบคือที่ที่ความรู้ของเราหยุดเดินทาง”ทุกอย่างที่ข้ามออกไป เหลือแค่รหัส หัวข้อความ และ body เท่านั้น
boundaryboundaryเส้นที่ระบบของเราจบ และผู้เรียกที่เราควบคุมไม่ได้เริ่ม · ทุกอย่างที่ข้ามเส้นนี้ออกไป มีแค่รหัส หัวข้อความ และ body เท่านั้น · สิ่งที่ถูกต้องอยู่ข้างในแต่ไม่ได้ข้ามออกมา ผู้เรียกไม่มีทางรู้ ไม่ใช่ชั้นในสถาปัตยกรรม · มันคือเส้นที่ระบบของเราจบ และคนที่เราควบคุมไม่ได้เริ่ม
ข้างในเส้นนี้เรามีทุกอย่าง · ชนิดของ exception ข้อความของมัน ค่าที่ทำให้มันเกิด · ข้างนอกเส้นนี้ผู้เรียกมีสามอย่าง คือตัวเลขหนึ่งตัว หัวข้อความไม่กี่บรรทัด และ body ก้อนหนึ่ง
ของที่ถูกต้องอยู่ข้างในแต่ไม่ได้ข้ามออกมา ผู้เรียกไม่มีทางรู้ว่ามันมีอยู่
ปลายทางที่เราจะยิงใส่
หัวข้อที่มีชื่อว่า “ปลายทางที่เราจะยิงใส่”สองปลายทาง · ตัวหนึ่งรับคำขอจริง อีกตัวมีไว้พังอย่างเดียว
package clinic.http
import jakarta.validation.constraints.Minimport jakarta.validation.constraints.NotBlank
data class BookingRequest( @field:NotBlank val doctorId: String, @field:Min(0) val startMinute: Int, @field:Min(1) val endMinute: Int,)สามช่อง สามกติกา · @Valid@Validการสั่งให้ตรวจ body ก่อนเข้าสู่ตัวจัดการ · ตรวจแล้วไม่ผ่านจะโยน `MethodArgumentNotValidException` ซึ่งรู้ครบว่าช่องไหนผิดเพราะอะไร แต่ความรู้นั้นไม่ได้ออกไปถึงผู้เรียกเอง จะตรวจให้ก่อนที่ตัวจัดการจะได้เห็นค่า
package clinic.http
import jakarta.validation.Validimport org.springframework.web.bind.annotation.GetMappingimport org.springframework.web.bind.annotation.PostMappingimport org.springframework.web.bind.annotation.RequestBodyimport org.springframework.web.bind.annotation.RestController
@RestControllerclass BookingController(private val rows: AppointmentRows) {
@PostMapping("/bookings") fun book(@Valid @RequestBody req: BookingRequest): Map<String, Any> { val saved = rows.saveAndFlush( AppointmentRow(req.doctorId, req.startMinute, req.endMinute), ) return mapOf("id" to saved.id, "saved" to true) }
@GetMapping("/boom") fun boom(): Nothing = throw IllegalStateException("อะไรบางอย่างพังข้างใน")}/boom ไม่ใช่ของจริงในระบบ · มันมีไว้ให้เราวัดสิ่งที่ระบบตอบเมื่อมีอะไรพังจริง
โดยไม่ต้องรอให้มันพังเอง
ทั้งคู่คือคู่ entity กับ repository ที่คอร์สก่อนสร้างไว้ ชี้มาที่ตารางของคอร์สนี้ · บทนี้ยังไม่แตะมัน เพราะเรื่องของบทนี้จบที่ปากทางออก ยังไม่ถึงฐานข้อมูล
ตารางกับ constraint ของมันจะเป็นเรื่องหลักในบทที่ 4 · ตอนนั้นจะเห็นทั้งสอง source เต็มๆ
ช่องควบคุมก่อน — คำขอที่ถูกต้องต้องผ่าน
หัวข้อที่มีชื่อว่า “ช่องควบคุมก่อน — คำขอที่ถูกต้องต้องผ่าน”ถ้าคำขอที่ถูกต้องยังไม่ผ่าน ทุกอย่างที่วัดต่อจากนี้ไม่มีความหมาย
{"doctorId":"d-http-1","startMinute":600,"endMinute":630}ได้กลับมา 200 พร้อม body สองคีย์คือ id กับ saved
ข้อนี้ไม่น่าตื่นเต้น แต่มันเป็นข้อที่ต้องวัดก่อนเสมอ · เพราะถ้าเราวัดระบบที่พังอยู่ ทุกตัวเลขที่ได้มาหลังจากนั้นอธิบายอะไรผิดไปหมด
คำขอที่ผิดสามช่องพร้อมกัน
หัวข้อที่มีชื่อว่า “คำขอที่ผิดสามช่องพร้อมกัน”สิ่งที่ระบบรู้ กับสิ่งที่ระบบบอก ไม่ใช่ของอันเดียวกัน
{"doctorId":"","startMinute":-5,"endMinute":0}doctorId ว่าง · startMinute ติดลบ · endMinute เป็นศูนย์ทั้งที่ต้องมากกว่านั้น
ระบบรู้ครบทั้งสามข้อ เพราะ @Valid ตรวจเจอทั้งสามข้อจริง แล้วรวบไว้ในที่เดียวกัน
· สิ่งที่ออกไปคือ
{"error":"Bad Request","path":"/bookings","status":400,"timestamp":"..."}สี่คีย์ · ไม่มีคีย์ไหนพูดถึง doctorId เลย ไม่มีคีย์ไหนพูดถึง startMinute
· ความรู้ทั้งสามข้อหยุดอยู่ที่ขอบ ไม่ได้ข้ามออกมา
ผู้เรียกที่ได้ของแบบนี้ทำอะไรต่อได้บ้าง · ลองแก้ทีละช่องแล้วยิงใหม่ไปเรื่อยจนกว่าจะผ่าน · นั่นคือการหาคำตอบด้วยการเดา ทั้งที่คำตอบมีอยู่แล้วในเครื่องเรา
แล้วตอนที่เราพังจริง หน้าตาเป็นอย่างไร
หัวข้อที่มีชื่อว่า “แล้วตอนที่เราพังจริง หน้าตาเป็นอย่างไร”นี่คือข้อที่ทำให้บทนี้มีอยู่
ยิง GET /boom ซึ่งโยน IllegalStateException ออกมาแบบไม่มีใครดัก
{"error":"Internal Server Error","path":"/boom","status":500,"timestamp":"..."}เอาสอง body มาวางข้างกัน แล้วเทียบชื่อคีย์ทีละตัว
| คำขอที่ผิด | ระบบพังจริง | |
|---|---|---|
| รหัส | 400 | 500 |
| content-type | application/json | application/json |
| ชื่อคีย์ | error path status timestamp | error path status timestamp |
| จำนวนคีย์ | 4 | 4 |
| บอกไหมว่าช่องไหนผิด | ไม่ | ไม่ |
รูปเดียวกันเป๊ะ ต่างกันแค่ตัวเลข
ตัวเลขนั้นสำคัญมาก · 400 Bad Request400 Bad Requestรหัสที่แปลว่าคำขอของคุณผิด · ผู้เรียกแก้เองได้ถ้ารู้ว่าต้องแก้อะไร · ค่าตั้งต้นให้แค่ตัวเลขนี้ ไม่ได้ให้ข้อมูลที่จะแก้ แปลว่าคุณส่งมาผิด · 500 Internal Server Error500 Internal Server Errorรหัสที่แปลว่าเราพัง ไม่ใช่คุณผิด · เป็นคำเชิญให้ลองใหม่โดยปริยาย · body ที่มากับมันมีรูปเดียวกับตอน 400 ทุกคีย์ ต่างกันแค่ตัวเลข แปลว่าเราพัง ไม่ใช่คุณผิด
แต่ body ที่มากับทั้งสองอย่างไม่ได้ช่วยแยกอะไรเลย · ผู้เรียกที่เขียนตัวจัดการฝั่งตัวเอง จึงเหลือทางเดียวคืออ่านตัวเลข แล้วเดาเอาว่าเกิดอะไรขึ้น
ตัวเรียก API ส่วนใหญ่ถูกเขียนให้ลองใหม่เมื่อเจอ 5xx เพราะรหัสกลุ่มนั้นแปลว่า ปัญหาอยู่ฝั่งเรา และมักจะหายไปเอง
บทที่ 4 จะแสดงให้เห็นว่าข้อนี้ทำให้เกิดอะไร เมื่อสิ่งที่ตอบ 500 ออกไป เป็นคำขอที่ลองอีกกี่ครั้งก็ชนเหมือนเดิม
ปลายทางเดียวกัน แต่ browser ได้ของคนละอย่าง
หัวข้อที่มีชื่อว่า “ปลายทางเดียวกัน แต่ browser ได้ของคนละอย่าง”ข้อนี้ทำให้การทดสอบด้วยมือหลอกเราได้
ยิง /boom อีกครั้ง คราวนี้ใส่หัวข้อความ Accept: text/html เข้าไป
ผลที่ได้ไม่ใช่ JSON อีกต่อไป · มันคือหน้า HTML ที่ Spring Boot เตรียมไว้
ซึ่งรู้จักกันในชื่อ Whitelabel Error PageWhitelabel Error Pageหน้าเปล่าที่ Spring Boot ตอบให้ผู้เรียกที่ขอด้วย `Accept: text/html` · ปลายทางเดียวกันจึงให้ของคนละอย่างกับ browser และกับตัวเรียก API และ content-type เป็น text/html
| หัวข้อความ Accept | content-type ที่ได้ | รูปของ body |
|---|---|---|
application/json | application/json | สี่คีย์ |
text/html | text/html | หน้า HTML |
ปลายทางเดียว รหัสเดียวกัน แต่สิ่งที่ออกไปเป็นคนละอย่าง · กลไกนี้เรียกว่า
content negotiationcontent negotiationการที่ปลายทางเดียวกันตอบคนละรูปตามหัวข้อความ `Accept` ที่ผู้เรียกส่งมา · เป็นเหตุผลที่การทดสอบด้วย browser กับด้วย `curl` ให้คำตอบไม่เหมือนกัน และมันเป็นเหตุผลที่การเปิดดูด้วย browser
กับการยิงด้วย curl ให้ภาพไม่ตรงกัน
เวลาวัดขอบ ต้องระบุหัวข้อความ Accept เสมอ ไม่งั้นเรากำลังวัดของที่ไม่รู้ว่าตัวไหน
สรุปสิ่งที่วัดได้ในบทนี้
หัวข้อที่มีชื่อว่า “สรุปสิ่งที่วัดได้ในบทนี้”สี่ข้อ · ทุกข้อมาจากการยิงคำขอจริงเข้าไปที่ server ที่สตาร์ตขึ้นมาจริง
- คำขอที่ถูกต้องได้ 200 — ช่องควบคุมผ่าน สิ่งที่วัดต่อจากนั้นจึงเชื่อได้
- คำขอที่ผิดสามช่องได้ 400 พร้อมสี่คีย์ที่ไม่บอกว่าช่องไหนผิด
- exception ที่หลุดออกมาได้ 500 พร้อม ชื่อคีย์ชุดเดียวกันเป๊ะ
- ขอด้วย
text/htmlแล้วได้text/htmlจากปลายทางเดิม
error bodyerror bodyเนื้อความที่ตอบกลับเมื่อคำขอไม่สำเร็จ · ค่าตั้งต้นของ Spring Boot ให้สี่คีย์คือ `error` `path` `status` `timestamp` ซึ่งไม่มีคีย์ไหนบอกว่าช่องไหนผิด ที่เราส่งออกไปตอนนี้ ทำให้ผู้เรียกทำอะไรต่อไม่ได้เลยนอกจากเดา
บทที่ 2 จะเปิดของที่หลายคนเชื่อว่าแก้ปัญหานี้ แล้ววัดว่ามันแก้อะไรจริง และไม่แก้อะไรบ้าง
- ยิงจากในเทสต์ที่สตาร์ต context ขึ้นเองด้วย
RANDOM_PORT· ไม่ใช้ port คงที่ เพราะ server ที่ตอบ ไม่จำเป็นต้องเป็น server ที่เพิ่งสร้าง - Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 · PostgreSQL 18 · pg driver 42.7.13
- รันซ้ำเองได้ด้วย
npm run verify:clinic-httpซึ่งสร้างโปรเจกต์ทั้งใบขึ้นใหม่ แล้วเทียบผลทีละช่อง · มันอยู่นอก CI เพราะต้องใช้ Docker - Spring Boot — Error Handling (เข้าถึง 2026-09-02) — ตัวจัดการค่าตั้งต้นและหน้า Whitelabel
- RFC 9110 §15 (เข้าถึง 2026-09-02) — ความหมายของกลุ่ม 4xx กับ 5xx
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3ทำไมการวัดของบทนี้ต้องเริ่มด้วยคำขอที่ถูกต้องเสมอ