Skip to content

บทที่ 1 — First Spring Boot App: REST API ที่อธิบายทุก annotation

← บทที่ 0 | สารบัญ Spring Boot

บทนี้เราจะ:

  1. สร้าง Spring Boot project ผ่าน Spring Initializr
  2. เขียน REST endpoint แรก
  3. อธิบายทุก annotation ที่เจอ — อย่าเป็นเวทมนตร์
  4. ทดสอบ API ด้วย curl + browser
  5. เพิ่ม service + repository + ทำ in-memory CRUD

📋 ก่อนเริ่ม — ต้องผ่านอะไรมาก่อน?

  • ✅ Java พื้นฐาน (Java บทที่ 1–8: class, object, interface, generics, exception)
  • ✅ Spring Boot บทที่ 0 (IoC, DI, Bean) — บทนี้เอาแนวคิดพวกนั้นมาลงมือทำจริง
  • 🔑 ศัพท์ที่จะเจอบ่อยในบทนี้ (อธิบายเพิ่มระหว่างทาง):
    • endpoint (เอนด์-พอยต์) = "ปลายทาง" 1 URL ที่ API เปิดให้เรียก เช่น GET /users
    • REST API (เรสต์ เอ-พี-ไอ) = แบบแผนการออกแบบ API ผ่าน HTTP ที่นิยมที่สุด
    • CRUD (ครัด) = 4 งานพื้นฐานกับข้อมูล: Create/Read/Update/Delete (สร้าง/อ่าน/แก้/ลบ)
    • payload / body = เนื้อข้อมูลที่ส่งไป-กลับใน request/response มักเป็น JSON
    • curl (เคิร์ล) = คำสั่งบน terminal ไว้ยิง HTTP request ทดสอบ API (มีบนทุก OS ที่ใหม่หน่อย — macOS / Linux / Windows 10+ ไม่ต้องลงเพิ่ม) ⚠️ Windows PowerShell: curl ใน PowerShell 5.1 เป็น alias ของ Invoke-WebRequest — ถ้าคำสั่งในหนังสือ error ให้ใช้ curl.exe แทน (หรืออัปเป็น PowerShell 7+ ที่ไม่มี alias นี้)

1. สร้าง project ผ่าน Spring Initializr

ไปที่ https://start.spring.io

💡 ถ้าเปิด start.spring.io ไม่ได้ (เช่น firewall บริษัทกั้น หรือไม่มีอินเตอร์เน็ต): ใช้ IntelliJ แทนได้เลย — File → New → Project → Spring Initializr มีฟีเจอร์เดียวกันทุกอย่างและไม่ต้องการอินเตอร์เน็ต

ตั้งค่า:

ช่องค่า
ProjectMaven
LanguageJava
Spring Bootเวอร์ชันล่าสุด (3.x — ปี 2026 ราว 3.4-3.5)
Project Metadata: Groupcom.example
Artifactfirstapp
Namefirstapp
Package namecom.example.firstapp
PackagingJar
Java21 (LTS — แนะนำสำหรับมือใหม่) หรือ 25 ถ้ามีติดตั้งแล้ว

💡 เลือก Java เวอร์ชันไหนดี? มือใหม่แนะนำ Java 21 — เป็น LTS (Long-Term Support — เวอร์ชันที่ได้รับการดูแล/แก้บั๊กยาวหลายปี ใช้กันแพร่หลายที่สุดในงานจริง) ถ้าเครื่องคุณยังไม่มี Java 25 ก็ไม่ต้องไปลงเพิ่ม ใช้ 21 ได้สบาย โค้ดในหนังสือนี้รันได้ทั้งสองเวอร์ชัน

⚠️ ยังไม่มี Java? ต้องลงก่อน:

  1. โหลด Eclipse Temurin 21 (ฟรี, open-source) จาก adoptium.net → เลือก Temurin 21 (LTS)
  2. ติดตั้งตามปกติ
  3. เปิด terminal/cmd แล้วเช็ค: java -version — ควรเห็น openjdk 21.x.x

ถ้าเห็น java -version แล้วได้เลข version ถูกต้อง = พร้อมไปต่อ

Dependencies (กด ADD DEPENDENCIES):

💡 dependency (ดี-เพน-เดนซี) มี 2 ความหมายตามบริบท:

  • ระดับ project — library พร้อมใช้ที่ Maven จะดึงเข้า project ให้อัตโนมัติ (ความหมายที่ใช้ตรงนี้)
  • ระดับ object — สิ่งที่ class ต้องใช้ เช่น UserRepository คือ dependency ของ UserService (บทที่ 0)

อย่าสับสนกัน §2.2 จะลงรายละเอียดเรื่อง Maven

  • Spring Web — สำหรับ REST API
  • Lombok — ลด boilerplate (บอยเลอร์-เพลต — โค้ดซ้ำ ๆ ที่ต้องเขียนทุกครั้ง เช่น getter/setter) Lombok เขียนให้แทน (optional — บทนี้ยังไม่ได้ใช้ เพราะเราใช้ record แทน จะเริ่มใช้จริงในบทที่ใช้ entity ที่ต้องการ mutable setter)

🔴 ทำเลย (ถ้าเลือก Lombok): Settings → Build → Compiler → Annotation Processors → เปิด Enable annotation processing — ถ้าไม่เปิดจะเจอ error cannot find symbol ที่ไม่รู้สาเหตุ

  • Spring Boot DevTools — auto reload (โหลดโค้ดใหม่อัตโนมัติตอนแก้ไฟล์ — ไม่ต้อง stop+start เอง)

กด GENERATE → ได้ไฟล์ zip → unzip → เปิดใน IntelliJ

💡 ยังไม่มี IntelliJ? — โหลด IntelliJ IDEA Community Edition (ฟรี) จาก jetbrains.com/idea/download เวอร์ชัน Community พอ ไม่ต้อง Ultimate ทางเลือก: VS Code + Extension "Extension Pack for Java" ก็เขียน Spring Boot ได้

💡 ทางลัด IntelliJ: File → New → Project → Spring Initializr — ทำตรงนี้ได้เลย


2. ดู project structure

text
firstapp/
├── pom.xml
├── src/
│   ├── main/
│   │   ├── java/com/example/firstapp/
│   │   │   └── FirstappApplication.java
│   │   └── resources/
│   │       ├── application.properties
│   │       └── static/
│   └── test/
│       └── java/com/example/firstapp/
│           └── FirstappApplicationTests.java
└── target/   (สร้างตอน build)

2.1 ดูเนื้อ FirstappApplication.java

💡 ไฟล์นี้ Spring Initializr สร้างให้แล้ว — เราดูเฉย ๆ ก่อน ไม่ต้องพิมพ์เอง

java
package com.example.firstapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class FirstappApplication {

    public static void main(String[] args) {
        SpringApplication.run(FirstappApplication.class, args);
    }

}

แค่นี้ — มี main + 1 annotation

อธิบาย @SpringBootApplication

นี่คือ annotation รวม 3 ตัว:

  1. @SpringBootConfiguration — บอกว่า class นี้เก็บการตั้งค่า (configuration) ของ Spring (คือ specialization ของ @Configuration ที่ Spring Boot ใช้สำหรับ main class โดยเฉพาะ — ในการสร้าง config class ทั่วไปให้ใช้ @Configuration ปกติ ไม่ใช่ @SpringBootConfiguration)
  2. @EnableAutoConfiguration — เปิด auto-configure (การตั้งค่าอัตโนมัติ — Spring detect (ตรวจหา) dependency ใน classpath (รายการ library ที่ project มี) แล้ว configure (ตั้งค่า) ให้โดยอัตโนมัติ)
  3. @ComponentScan — scan annotation (@Component, @Service ฯลฯ) ใน package นี้ + ลูก

SpringApplication.run(class, args) ทำอะไร

  1. สร้าง Spring ApplicationContext (container)
  2. Scan + load bean ทั้งหมด
  3. Start embedded server (Tomcat) — default port 8080
  4. รอ HTTP request

2.2 ดู pom.xml

pom.xml = ไฟล์ที่ Maven (เครื่องมือ build ของ Java) ใช้บอกว่า project นี้ใช้ library อะไรบ้าง ทุก dependency เขียนในแท็ก <dependency> มี <groupId> (เจ้าของ library — มักเป็น domain name กลับด้าน เช่น บริษัท example.com → groupId คือ com.example) + <artifactId> (ชื่อ library)

xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    ...
</dependencies>

spring-boot-starter-web = "ของรวม" — ดึง Tomcat + Jackson + Spring MVC + ฯลฯ

2.3 ลอง run ครั้งแรก

ใน IntelliJ:

  • คลิกขวาที่ FirstappApplicationRun

หรือใน terminal:

bash
# macOS / Linux
./mvnw spring-boot:run

# Windows PowerShell  (ต้องมี .\ นำหน้า ไม่งั้น error "not recognized")
.\mvnw.cmd spring-boot:run

# Windows cmd (Command Prompt)
mvnw.cmd spring-boot:run

(mvnw = Maven wrapper — ถูกสร้างมาพร้อม project โดย Initializr ไม่ต้องติดตั้ง Maven แยก ใช้ Maven version ที่ project กำหนดไว้ (pin — "ตรึง" เวอร์ชัน))

💡 macOS/Linux: ถ้าได้ error Permission denied ให้รัน chmod +x mvnw ก่อน แล้วค่อยรัน ./mvnw spring-boot:run อีกครั้ง

ดู log:

text
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.x.x)   ← เลข version จะต่างกันตาม version ที่เลือกใน Initializr
 
... Started FirstappApplication in 1.234 seconds ...
... Tomcat started on port 8080 (http) ...

เปิด browser → http://localhost:8080

🔤 localhost = เครื่องตัวเอง · port 8080 = ช่องสื่อสารที่ Spring Boot ใช้เป็น default (เปลี่ยนได้ใน §10 ผ่าน application.properties)

💡 ถ้าเจอ error "Port 8080 already in use": มีโปรแกรมอื่นใช้ port 8080 อยู่ — ปิดโปรแกรมนั้นก่อน หรือแก้ application.properties เพิ่มบรรทัด server.port=8090 แล้ว restart

ผลลัพธ์ — ไม่ต้องตกใจ แปลว่า server ทำงานแล้ว เพียงแต่ยังไม่มี endpoint ที่ /:

text
Whitelabel Error Page
This application has no explicit mapping for /, so you are seeing this as a fallback.

หน้านี้เรียกว่า Whitelabel Error Page (หน้า error สำเร็จรูปของ Spring ที่แสดงเป็นค่า default เมื่อไม่มีหน้าจัดการ error ของเราเอง)

ข้อความแปลว่า: app ของคุณยังไม่ได้ "จับคู่" path / กับ method ใด ๆ เลย Spring เลยแสดงหน้านี้แทน — server ทำงานแล้ว แค่ยังไม่มี endpoint (ปลายทาง URL) ใด ๆ เดี๋ยวเราจะเพิ่ม


3. เพิ่ม Controller แรก

สร้างไฟล์ใหม่ src/main/java/com/example/firstapp/HelloController.java:

java
package com.example.firstapp;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello, Spring Boot!";
    }
}

อธิบายทีละ annotation

@RestController

  • รวม @Controller + @ResponseBody
  • @Controller — บอก Spring ว่า class นี้รับ HTTP request
  • @ResponseBody — return ค่าของ method = response body (ไม่ใช่ template name)

ผลคือ: ทุก method return → Spring convert เป็น JSON (หรือ string ตรง ๆ) ส่งกลับ

@GetMapping("/hello")

  • map HTTP GET /hello → method นี้
  • annotation อื่น: @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping
  • ก่อนหน้านี้: @RequestMapping(value="/hello", method=GET) — verbose มาก

⚠️ กับดักที่พลาดกันบ่อย: ถ้าเขียน @RequestMapping("/hello") เฉย ๆ โดยไม่ระบุ method= จะรับได้ ทุก HTTP verb (GET/POST/PUT/DELETE ฯลฯ) พร้อมกัน — ไม่ได้จำกัดแค่ GET แบบที่คนมักเข้าใจผิด เพราะแบบนี้ endpoint ที่ควรรับแค่ GET อาจดันไปรับ POST/DELETE โดยไม่ตั้งใจ ให้ใช้ @GetMapping/@PostMapping ที่เจาะจง verb เสมอ จะได้ชัดเจนและปลอดภัยกว่า

ลอง

restart app (หรือถ้ามี DevTools — แค่ save ไฟล์แล้ว IntelliJ จะ rebuild + restart context (= สร้าง Spring container ใหม่ทั้งหมด) ให้อัตโนมัติ ใช้เวลา 1-2 วินาที)

💡 DevTools ทำอะไรบ้าง — แยก classloader (กลไกที่ JVM ใช้โหลดไฟล์ .class เพื่อเอาไปรัน — เหมือนตัวเปิดกล่องโค้ดก่อนใช้งาน) ออกเป็น 2 ชั้น:

  • ชั้น base = library ใน ~/.m2 ไม่ reload ซ้ำ
  • ชั้น restart = โค้ดเราใน target/classes reload ใหม่ทุกครั้งที่ไฟล์เปลี่ยน

→ restart เร็วกว่าหยุด-เริ่ม JVM ทั้งหมด 5-10 เท่า

ใน IntelliJ ต้องเปิด: Settings → Build → Compiler → "Build project automatically" + Advanced Settings → "Allow auto-make to start..." = ON

เปิด: http://localhost:8080/hello

ผลลัพธ์:

text
Hello, Spring Boot!

🎉 endpoint แรกใช้งานได้!


4. Return JSON

เพิ่ม method นี้เข้าไปใน HelloController เดิม และวาง record User ไว้ในไฟล์เดียวกัน (นอก class HelloController ก็ได้):

💡 วาง record User ไว้ที่ไหนในไฟล์? Java อนุญาตให้ไฟล์มีหลาย type ได้ถ้าไม่ใช่ public — วาง record User(...) ต่อจาก } ปิด class HelloController ตัวอย่างโครงสร้างไฟล์:

java
@RestController
public class HelloController {
    // ... methods ...
}

// วางนอก class HelloController แต่ยังอยู่ในไฟล์เดียวกัน
record User(String name, int age, String email) {}
java
@GetMapping("/user")
public User getUser() {
    return new User("Anna", 25, "anna@example.com");
}

record User(String name, int age, String email) {}

🔤 record (Java 14+) = วิธีประกาศ class แบบ immutable data สั้น ๆ — ได้ field + constructor + getter + equals/hashCode/toString ฟรี ไม่ต้องเขียนเอง ในบทนี้เราจะใช้ record เป็นหลักเพราะสั้นและเหมาะกับ DTO/JSON

💡 หมายเหตุ: User record ตอนนี้มี 3 field (name, age, email) — พอถึง §7.3 จะเพิ่ม field id เป็น 4 field เพื่อเก็บใน repository ให้ลบ/แทนที่ record นี้ตอนนั้นได้เลย

GET http://localhost:8080/user

json
{
  "name": "Anna",
  "age": 25,
  "email": "anna@example.com"
}

Spring (ผ่าน Jackson) convert object → JSON อัตโนมัติ — ไม่ต้องเขียนเอง


5. รับ parameter

endpoint ส่วนใหญ่ต้องรับ input จาก client — Spring มี annotation ให้ดึงค่าจากหลายที่: @PathVariable (ค่าใน path เช่น /users/{id}), @RequestParam (query string ?key=value), และ @RequestBody (JSON body) เลือกใช้ให้ตรงกับว่า client ส่งค่ามาทางไหน:

5.1 Path variable

java
@GetMapping("/users/{id}")
public String getUserById(@PathVariable String id) {
    return "Getting user " + id;
}

URL: /users/123 → method ได้ id = "123"

5.2 Query parameter

java
@GetMapping("/search")
public String search(@RequestParam String q, 
                     @RequestParam(defaultValue = "10") int limit) {
    return "Search: " + q + ", limit: " + limit;
}

URL: /search?q=spring&limit=20q="spring", limit=20URL: /search?q=springq="spring", limit=10 (default)

5.3 Request body (POST)

java
@PostMapping("/users")
public User createUser(@RequestBody User user) {
    System.out.println("Creating: " + user);
    return user;   // ส่งค่ากลับไปเหมือนเดิม (echo back)
}

curl:

bash
curl -X POST http://localhost:8080/users \
     -H "Content-Type: application/json" \
     -d '{"name":"Anna","age":25,"email":"anna@example.com"}'

💡 Windows PowerShell: ตัว \ ท้ายบรรทัด (line continuation — การต่อบรรทัด เป็น syntax ของ bash) ใช้ไม่ได้ใน PowerShell — ใช้ backtick ` แทน หรือเขียนเป็นบรรทัดเดียว:

powershell
# Windows PowerShell — ใช้ backtick ` แทน backslash \
curl.exe -X POST http://localhost:8080/users `
     -H "Content-Type: application/json" `
     -d '{"name":"Anna","age":25,"email":"anna@example.com"}'

ทางเลือกที่สะดวกกว่า: Postman หรือ Insomnia (GUI tool ทดสอบ API ไม่ต้องสนใจ syntax)

Spring convert JSON → User object อัตโนมัติ


6. Status code + ResponseEntity

⚠️ ก่อนพิมพ์โค้ดใน §6: endpoint GET /users/{id} ใน section นี้ใช้ path เดียวกัน กับที่เขียนใน §5.1 — ถ้าเก็บทั้งสองไว้พร้อมกัน Spring จะ start ไม่ขึ้น (IllegalStateException: Ambiguous mapping) ให้ลบหรือ comment getUserById ใน §5.1 ออกก่อน แล้วค่อยเพิ่มตัวอย่างใน §6 แทน

🔤 HTTP response มี 3 ส่วน: status code (เลข 200/404/500 ที่บอก "สำเร็จ/ไม่เจอ/พัง") + header (ข้อมูลเสริม เช่น Content-Type: application/json) + body (เนื้อหา เช่น JSON ของ user) — ResponseEntity ให้เราคุมทั้ง 3 ส่วน

java
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable String id) {
    if (id.equals("404")) {
        return ResponseEntity.notFound().build();
    }
    User user = new User("Anna", 25, "anna@example.com");
    return ResponseEntity.ok(user);
}

ResponseEntity ให้ control เต็มที่ — status, header, body

shortcut

java
return ResponseEntity.ok(user);                    // 200 สำเร็จ
return ResponseEntity.status(HttpStatus.CREATED).body(user);  // กำหนด status เอง (ใช้ enum ไม่ใช่ magic number 201)
return ResponseEntity.notFound().build();          // 404 ไม่เจอ
return ResponseEntity.badRequest().build();        // 400 request ผิด
return ResponseEntity.created(uri).body(user);     // 201 + ใส่ Location header (ที่อยู่ของ resource ที่เพิ่งสร้าง)

HTTP Status code semantics — ใช้ตัวไหนเมื่อไหร่?

มือใหม่ส่ง 200 ทุกอย่าง — กลายเป็น "200 OK ที่ body บอกว่า error" → client ต้อง parse body ทุก response

กฎตามมาตรฐาน HTTP (RFC 9110):

Statusความหมายใช้ใน REST
200 OKสำเร็จ มี bodyGET ที่เจอ, PUT/POST ที่ return resource
201 Createdสร้าง resource ใหม่สำเร็จPOST /users → 201 + Location: /users/123
202 Acceptedรับเข้า queue แล้ว ยังไม่เสร็จงานที่ทำเบื้องหลัง (async job) — POST /reports
204 No Contentสำเร็จ ไม่มี bodyDELETE, PUT ที่ไม่ต้อง return
400 Bad Requestrequest format ผิด / validation failmissing field, JSON malformed
401 Unauthorizedไม่มี/ผิด credentials (ชื่อชวนงง)token หาย, token หมดอายุ (expired)
403 Forbiddenมี credential แต่ไม่มีสิทธิ์user พยายามลบ resource ของคนอื่น
404 Not Foundไม่เจอ resourceGET /users/999
409 Conflictstate ขัด — เช่น duplicate, optimistic lock failPOST email ซ้ำ, @Version mismatch
410 Goneเคยมี ตอนนี้ลบถาวรแล้วresource เคยอยู่
422 Unprocessable Entityrequest format ถูก แต่ semantic ผิด"transfer -100" — JSON valid แต่ business invalid
429 Too Many Requestsrate limitใส่ Retry-After header
500 Internal Server Errorserver bugexception ที่ไม่คาด
502/503/504upstream / overloaded / timeoutproxy/gateway/circuit-break

Location header — สำคัญสำหรับ 201 Created

📖 ตัวอย่างด้านล่างเป็นภาพรวมให้ดูเฉย ๆ ยังไม่ต้องพิมพ์ตาม — โค้ดนี้ต้องการ userService และ User record 4-field ที่จะสร้างใน §7 ถ้าพิมพ์ตามตอนนี้จะ compile ไม่ผ่าน ดูตัวอย่างใช้งานจริงได้ที่ §7.4

java
// ⚠️ ตัวอย่างนี้ compile ไม่ผ่านจนกว่าจะสร้าง UserService ใน §7 — ดูเป็นภาพรวมก่อน
@PostMapping("/users")
public ResponseEntity<User> create(@RequestBody User user) {  // @Valid ละไว้ก่อน — ดูตัวอย่าง validation ที่ครบถ้วนใน §7.4
    User saved = userService.save(user);

    // สร้าง URI ที่ชี้ไปยัง resource ที่เพิ่งสร้าง
    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
        .path("/{id}")
        .buildAndExpand(saved.id())     // record ใช้ saved.id() ไม่ใช่ saved.getId()
        .toUri();
    // → http://host/users/123

    return ResponseEntity.created(location).body(saved);    // 201 + ใส่ Location header
}

ทำไม Location สำคัญ:

  • Client follow link ต่อได้ทันที — ไม่ต้องประกอบ URL เอง
  • HTTP spec แนะนำว่า 201 Created ควรมี Location header
  • บาง framework ขั้นสูงบังคับใช้ (HATEOAS/Hypermedia/OData — แบบแผน API ขั้นสูงที่ฝัง link ไว้ใน response — มือใหม่ข้ามได้ กางศัพท์ด้านล่าง)
🔤 กางศัพท์ขั้นสูง (อ่านเพิ่ม — ข้ามได้)
  • HATEOAS (hate-ee-os — ย่อจาก Hypermedia As The Engine Of Application State) = แนวคิดให้ API ส่ง "ลิงก์ไปทำต่อ" มากับ response เพื่อให้ client เดินตามลิงก์ได้เลย
  • Hypermedia (ไฮเปอร์-มี-เดีย) = การฝังลิงก์/action ลงไปในข้อมูล (แบบเดียวกับลิงก์ในหน้าเว็บ)
  • OData (โอ-ดาต้า) = มาตรฐาน query API ของ Microsoft ที่ใช้ลิงก์แบบนี้

มือใหม่ยังไม่ต้องสนใจ 3 ตัวนี้ตอนนี้ — แค่รู้ว่า Location header เป็นมารยาทมาตรฐานของ 201 Created ก็พอ

204 No Content — DELETE / PUT (idempotent)

🔤 idempotent (ไอ-เดม-โพ-เทนต์) = "เรียกซ้ำกี่ครั้งผลก็เหมือนเดิม"

  • DELETE (idempotent): ลบ user id 5 ซ้ำ 3 ครั้ง ผลสุดท้ายก็คือ user 5 หายไปเหมือนเดิม (ไม่พังเพิ่ม)
  • POST (ไม่ idempotent): เรียก POST /users ซ้ำ 10 ครั้ง ได้ user ใหม่ 10 คน — ผลลัพธ์เปลี่ยนไปทุกครั้งที่เรียก
  • PUT (idempotent): เรียก PUT /users/5 ด้วยข้อมูลเดิมซ้ำ 10 ครั้ง ผลก็ยังเป็น user 5 ที่มีข้อมูลเดิม ไม่ต่างจากเรียกครั้งเดียว

📖 ตัวอย่างด้านล่างเป็นภาพรวมให้ดูเฉย ๆ — ต้องมี userService จาก §7 ก่อนจึงจะใช้ได้จริง

java
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {   // ใช้ String ให้สอดคล้องกับ User record
    userService.delete(id);
    return ResponseEntity.noContent().build();              // 204
}

ไม่ส่ง body กลับ → client ไม่ต้อง parse


7. ระดับ Service + Repository — เริ่มเห็น DI

ตอนนี้ controller ทำทุกอย่างเอง — ไม่ดี ในจริง separate:

7.1 Repository — เก็บข้อมูล

ใน repository/UserRepository.java:

java
package com.example.firstapp.repository;

import com.example.firstapp.model.User;
import org.springframework.stereotype.Repository;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class UserRepository {
    // ConcurrentHashMap — thread-safe เพราะ bean เป็น singleton ต้องรองรับ request พร้อมกันหลายตัว
    private final Map<String, User> store = new ConcurrentHashMap<>();
    
    public User save(User user) {
        store.put(user.id(), user);
        return user;
    }
    
    public Optional<User> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }
    
    public List<User> findAll() {
        return new ArrayList<>(store.values());
    }
    
    public void deleteById(String id) {
        store.remove(id);
    }
}

@Repository = @Component ที่บอกว่า "นี่เก็บข้อมูล"

7.2 Service — business logic

ใน service/UserService.java:

java
package com.example.firstapp.service;

import com.example.firstapp.model.User;
import com.example.firstapp.repository.UserRepository;
import org.springframework.stereotype.Service;

import java.util.*;

@Service
public class UserService {
    private final UserRepository repo;
    
    // constructor injection — Spring ส่ง UserRepository ให้
    public UserService(UserRepository repo) {
        this.repo = repo;
    }
    
    public User createUser(String name, int age, String email) {
        String id = UUID.randomUUID().toString();
        User user = new User(id, name, age, email);
        return repo.save(user);
    }
    
    public User getUser(String id) {
        return repo.findById(id)
            .orElseThrow(() -> new RuntimeException("User not found: " + id));
    }
    
    public List<User> listAll() {
        return repo.findAll();
    }
    
    public void deleteUser(String id) {
        repo.deleteById(id);
    }

    public User replaceUser(String id, String name, int age, String email) {
        User existing = getUser(id);     // throw ถ้าไม่เจอ
        User replaced = new User(existing.id(), name, age, email);
        repo.save(replaced);
        return replaced;
    }

    // ⚠️ pattern นี้ใช้ Map<String, Object> + cast ตรงๆ เพื่อความสั้น — ในงานจริงควรใช้ typed DTO ที่มี Optional/nullable field แทน
    // ⚠️ ระวัง ClassCastException: ถ้า caller ส่ง value ผิดชนิด (เช่น Integer แทน String ใน "name") จะ throw ClassCastException ตอน runtime — pattern นี้เป็นเพียงตัวอย่าง ไม่ใช้ใน production
    // หมายเหตุ: intValue() truncate ทศนิยม เช่น age=25.5 จะกลายเป็น 25 โดยไม่มี error
    public User patchUser(String id, Map<String, Object> partial) {
        User existing = getUser(id);
        String name = (String) partial.getOrDefault("name", existing.name());
        int age = ((Number) partial.getOrDefault("age", existing.age())).intValue();
        String email = (String) partial.getOrDefault("email", existing.email());
        return replaceUser(id, name, age, email);
    }
}

@Service = @Component ที่บอกว่า "นี่ทำ business logic"

หมายเหตุ: replaceUser กับ patchUser เพิ่มไว้รอ Controller ใน §7.4 ใช้ — User เป็น record → field access ใช้ existing.id() ไม่ใช่ existing.getId()

💡 ใน in-memory HashMap บทนี้ยังไม่เห็นปัญหา แต่เมื่อใช้กับ database จริงในบทที่ 2 operation ที่ read-then-write (เช่น replaceUser, patchUser) ต้องใส่ @Transactional เพื่อป้องกัน race condition

⚠️ anti-pattern (แอนตี้-แพตเทิร์น — วิธีเขียนโค้ดที่ดูเหมือนใช้ได้ แต่จริง ๆ ก่อปัญหาในระยะยาว): getUser ตอนนี้ throw new RuntimeException(...) แบบดิบ ๆ — ทำให้ controller คืน HTTP 500 แทน 404 อย่าใช้แบบนี้ในงานจริง §9 จะแทนด้วย UserNotFoundException (custom exception) + global handler ที่คืน 404 ถูกต้อง

7.3 Model

model/User.java:

java
package com.example.firstapp.model;

public record User(String id, String name, int age, String email) {}

💡 เพิ่ม id แล้วUser ใน §4 มี 3 field (name, age, email) แต่พอจะเก็บใน repository ต้องมี id เพื่อ lookup ตั้งแต่ §7 เป็นต้นไปใช้ version 4-field นี้ ลบ/แทนที่ record เดิมใน §4 ได้เลย

7.4 Controller ใช้ Service

Checklist ก่อน copy โค้ด UserController นี้:

  1. ลบโค้ด §5–§6 ออกก่อนUserController มี @RequestMapping("/users") ซึ่งชนกับ endpoint ใน §5 (/users, /users/{id}) และ §6 — ถ้าไม่ลบออก app จะ start ไม่ขึ้น (IllegalStateException: Ambiguous mapping)
  2. เพิ่ม spring-boot-starter-validation ใน pom.xml — ถ้าไม่มี @Valid จะกลายเป็น no-op เงียบ ๆ (ไม่ throw, ไม่ validate):
    xml
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    (ถ้าเพิ่ม dependency นี้ไว้ก่อนหน้านี้แล้ว ให้เช็คใน pom.xml ก่อน — ถ้ามีอยู่แล้วข้ามขั้นตอนนี้ได้)
  3. Reload Maven — กดปุ่ม 🔄 Load Maven Changes ที่เด้งขึ้นมาใน IntelliJ
  4. Restart app

รายละเอียดเต็มของ @Valid / @NotBlank / @Email อยู่ใน §11 — ตอนนี้แค่รู้ว่ามันเช็ค field ให้อัตโนมัติ

java
package com.example.firstapp;

import com.example.firstapp.model.User;
import com.example.firstapp.service.UserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import java.net.URI;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/users")
public class UserController {
    private final UserService service;

    public UserController(UserService service) {
        this.service = service;
    }
    
    @GetMapping
    public List<User> listAll() {
        return service.listAll();
    }
    
    @GetMapping("/{id}")
    public User getOne(@PathVariable String id) {
        return service.getUser(id);
    }
    
    @PostMapping
    public ResponseEntity<User> create(@Valid @RequestBody CreateUserRequest req) {
        User created = service.createUser(req.name(), req.age(), req.email());
        // ใส่ Location header ตาม HTTP spec — ชี้ไปยัง resource ที่เพิ่งสร้าง (เหมือนตัวอย่างใน §6)
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(created.id())
            .toUri();
        return ResponseEntity.created(location).body(created);   // 201 + Location header
    }

    @PutMapping("/{id}")
    public ResponseEntity<User> replace(@PathVariable String id,
                                        @Valid @RequestBody UpdateUserRequest req) {
        // PUT = idempotent — replace ทั้ง resource ผู้เรียกส่ง state เต็มมา
        User updated = service.replaceUser(id, req.name(), req.age(), req.email());
        return ResponseEntity.ok(updated);
    }

    @PatchMapping("/{id}")
    public ResponseEntity<User> patch(@PathVariable String id,
                                      @RequestBody Map<String, Object> partial) {
        // PATCH = partial update — ผู้เรียกส่งเฉพาะ field ที่จะแก้
        User updated = service.patchUser(id, partial);
        return ResponseEntity.ok(updated);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable String id) {
        service.deleteUser(id);
        return ResponseEntity.noContent().build();
    }

    // DTO (Data Transfer Object) — ในงานจริงควรย้าย record เหล่านี้ออกเป็นไฟล์แยกใน dto/ หรือ model/
    // เพื่อ reusability (ใช้ซ้ำจาก class อื่น) และ testability — วางไว้ใน controller แบบนี้เพื่อความสั้นในตัวอย่าง
    record CreateUserRequest(@NotBlank String name, @Min(0) @Max(150) int age, @Email String email) {}
    record UpdateUserRequest(@NotBlank String name, @Min(0) @Max(150) int age, @Email String email) {}
}

✅ Controller ข้างบนเรียก replaceUser กับ patchUser ของ UserService — เราเพิ่ม 2 method นี้ไว้ใน §7.2 แล้ว ไม่ต้องไปเพิ่มซ้ำ

7.5 ภาพรวม flow

text
HTTP Request

UserController     ← @RestController
    ↓ uses
UserService        ← @Service
    ↓ uses
UserRepository     ← @Repository

HashMap (in-memory storage)

Spring เชื่อมให้อัตโนมัติ (auto wire — "ต่อสายให้") — เห็น UserController(UserService) → หา bean ของ UserService → ส่งให้


8. ทดสอบ

หลังเขียน endpoint เสร็จ ลองยิงทดสอบด้วย curl เพื่อเห็นว่าทำงานจริง — ครบทุก operation ของ CRUD (create/read/update/delete) วิธีนี้เร็วกว่าเปิด browser และเห็น request/response ดิบ ๆ ชัดเจน:

restart app

Create

bash
curl -X POST http://localhost:8080/users \
     -H "Content-Type: application/json" \
     -d '{"name":"Anna","age":25,"email":"anna@example.com"}'

Response:

json
{"id":"abc-123-...","name":"Anna","age":25,"email":"anna@example.com"}

List

bash
curl http://localhost:8080/users

Get one

bash
curl http://localhost:8080/users/abc-123-...

Delete

bash
curl -X DELETE http://localhost:8080/users/abc-123-...

9. Exception handling

ตอนนี้ถ้าหา user ไม่เจอ — return 500 error message ที่เปิดเผยข้อมูลภายใน ไม่ดี

แก้: เพิ่ม global exception handler

Custom exception

exception/UserNotFoundException.java:

java
package com.example.firstapp.exception;

public class UserNotFoundException extends RuntimeException {
    private final String userId;

    public UserNotFoundException(String id) {
        super("User not found: " + id);
        this.userId = id;
    }

    public String getUserId() {
        return userId;
    }
}

แก้ service:

java
public User getUser(String id) {
    return repo.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));
}

Exception handler

exception/GlobalExceptionHandler.java:

ในบทนี้จะใช้ @RestControllerAdvice (= @ControllerAdvice + @ResponseBody รวมในตัวเดียว) แทน @ControllerAdvice ปกติ เพราะ REST API ของเราต้องคืน JSON เสมอ — ถ้าใช้ @ControllerAdvice เปล่า ๆ ต้องประกาศ @ResponseBody ทุก method เอง

java
package com.example.firstapp.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.Map;

@RestControllerAdvice    // = @ControllerAdvice + @ResponseBody (เหมาะกับ REST API ที่คืน JSON)
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleNotFound(UserNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)    // ใช้ enum ไม่ใช่ magic number
            .body(Map.of("error", e.getMessage()));
    }
}
  • @RestControllerAdvice — เหมือน @ControllerAdvice แต่รวม @ResponseBody อัตโนมัติ → ทุก response คืน JSON โดยไม่ต้องประกาศซ้ำ เป็น default สำหรับ REST API ปี 2026
  • @ExceptionHandler — บอกว่าจัดการ exception ชนิดไหน
  • ใช้ HttpStatus.NOT_FOUND (enum) แทน 404 (magic number) เพื่อให้อ่านง่ายและ refactor-safe

ทดสอบ:

bash
curl http://localhost:8080/users/nonexistent
# {"error":"User not found: nonexistent"}
# HTTP 404

9.1 อัพเกรด — ใช้ Problem Details (RFC 9457) แทน Map

Map<String, String> แบบข้างบนง่ายดี แต่ ไม่เป็นมาตรฐานfrontend (เว็บ/แอปฝั่งหน้าจอที่เรียก API ของเรา) หรือ partner (ทีม/บริษัทอื่นที่ใช้ API ของเรา) ต้องเดาเองว่า field ชื่ออะไร

RFC 9457 (เดิมคือ RFC 7807 — ชื่อเก่ายังเจอบ่อยใน blog และเอกสาร) = มาตรฐาน HTTP API error format ที่ Spring 6 / Spring Boot 3+ รองรับ built-in ผ่าน class ProblemDetail

โครงสร้างมาตรฐาน:

json
{
  "type": "https://example.com/errors/user-not-found",
  "title": "User Not Found",
  "status": 404,
  "detail": "User not found: abc-123",
  "instance": "/users/abc-123"
}

เขียนใหม่:

java
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.*;

import java.net.URI;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleNotFound(UserNotFoundException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, "User with the given ID was not found");   // ข้อความที่ client อ่านได้ — ไม่ leak internal ID ลง detail
        pd.setType(URI.create("https://example.com/errors/user-not-found"));
        pd.setTitle("User Not Found");
        pd.setProperty("userId", e.getUserId());   // ใส่ id ในคอลัมน์แยกแทน — พิจารณาตาม security requirement ของระบบ
        return pd;
    }
}

Spring จะ set Content-Type: application/problem+json ให้อัตโนมัติ — frontend จัดการ error เป็นมาตรฐานทั่วโลก

📚 อ้างอิง: RFC 9457 — Problem Details for HTTP APIs (ชื่อเก่า RFC 7807) · Spring 6 ProblemDetail JavaDoc

จุดผิดพลาดที่พบบ่อย (common pitfall — แปลตรงตัวว่า "หลุมพราง"): ระวังอย่าใส่ stack trace หรือ SQL error ลง detail — leak ข้อมูล internal เป็นช่องโหว่ security ใช้ logger เก็บ stack trace ฝั่ง server เท่านั้น


10. Configuration ผ่าน application.properties

ใน src/main/resources/application.properties:

properties
# Server port
server.port=8081

# Application name
spring.application.name=firstapp

# Log level
logging.level.com.example.firstapp=DEBUG

restart → server run ที่ port 8081 แล้ว

⚠️ ทดลองเสร็จแล้ว ให้เปลี่ยน server.port กลับเป็น 8080 หรือลบบรรทัดนั้นออก — ตัวอย่าง curl ที่เหลือในบทนี้ (§11, Checkpoint) ใช้ port 8080 เป็น default ถ้าไม่เปลี่ยนกลับจะ connection refused

อ่าน config ใน code

java
@Service
public class UserService {
    // @Value field injection เป็นที่ยอมรับสำหรับ config value (ต่างจาก @Autowired ที่ควรหลีกเลี่ยง)
    // ถ้าต้องการ immutability/testability สูงสุด ให้ inject ผ่าน constructor แทน หรือใช้ @ConfigurationProperties (ด้านล่าง)
    @Value("${app.max-users:100}")  // :100 = ค่า default ถ้าไม่ได้ตั้ง app.max-users ไว้
    private int maxUsers;
    
    // ...
}

ใน properties:

properties
app.max-users=500

หรือใช้ @ConfigurationProperties แบบ type-safe (ปลอดภัยจากชนิดข้อมูลผิด — เช่น compile error ทันทีถ้าใส่ String ในช่องที่ควรเป็น int) ดีกว่า:

java
import org.springframework.boot.context.properties.ConfigurationProperties;

// ต้องเพิ่ม @EnableConfigurationProperties(AppConfig.class) บน main class ด้วย (ดูตัวอย่างถัดไป)
@ConfigurationProperties(prefix = "app")
public record AppConfig(int maxUsers, String defaultRole) {}

💡 @ConfigurationProperties สร้าง bean ของ AppConfig จาก properties (app.max-users, app.default-role) ให้อัตโนมัติ — ต้องบอก Spring ผ่าน @EnableConfigurationProperties(AppConfig.class) บน main class (ดูข้างล่าง) เวอร์ชัน record ใช้ canonical constructor binding ของ Spring Boot 3 ได้เลย ไม่ต้องใส่ @ConstructorBinding (ถ้าเป็น class ปกติที่มีหลาย constructor ค่อยใส่)

java
@SpringBootApplication
@EnableConfigurationProperties(AppConfig.class)
public class FirstappApplication { ... }
java
// ⚠️ snippet นี้เป็นตัวอย่างวิธีใช้ AppConfig เท่านั้น
// UserService จริงต้องมีทั้ง UserRepository และ AppConfig ใน constructor:
// public UserService(UserRepository repo, AppConfig config) { ... }
@Service
public class UserService {
    private final AppConfig config;
    
    public UserService(AppConfig config) {
        this.config = config;
    }
}

💡 AppConfig ใน application.properties — ต้องตั้งค่าครบทั้ง 2 ค่า (ค่าที่ไม่ได้ตั้งจะกลายเป็น null หรือ 0):

properties
app.max-users=500
app.default-role=USER

11. Validation

เพิ่ม dependency (ถ้าเพิ่มไว้ใน §7.4 แล้ว ข้ามขั้นตอนนี้ได้ — เช็คใน pom.xml ว่ามี spring-boot-starter-validation อยู่แล้วหรือเปล่า):

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

ใน request:

java
import jakarta.validation.constraints.*;

record CreateUserRequest(
    @NotBlank String name,
    @Min(0) @Max(150) int age,
    @Email String email
) {}

ใน controller:

java
@PostMapping
public ResponseEntity<User> create(@Valid @RequestBody CreateUserRequest req) { ... }

@Valid → Spring เช็ค constraint (ข้อจำกัด/เงื่อนไข เช่น @NotBlank, @Email) อัตโนมัติ — ถ้าผิด Spring throw MethodArgumentNotValidException ออกมา เราจับด้วย @ExceptionHandler ใน global handler (เหมือน §9)

Handle ใน global handler — MethodArgumentNotValidException (= exception ที่ Spring โยนออกมาเมื่อ argument ที่ method รับมาไม่ผ่าน validation) ใช้ ProblemDetail เหมือน §9.1 เพื่อให้สอดคล้องกัน:

java
import java.util.HashMap;
import java.util.Map;

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException e) {
    Map<String, String> errors = new HashMap<>();
    e.getBindingResult().getFieldErrors().forEach(err -> 
        errors.put(err.getField(), err.getDefaultMessage()));
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("Validation Failed");
    pd.setProperty("errors", errors);   // ใส่ field errors ใน property แทนการคืน Map ตรงๆ
    return pd;
}

ทดสอบ:

bash
curl -X POST http://localhost:8080/users \
     -H "Content-Type: application/json" \
     -d '{"name":"","age":-1,"email":"notanemail"}'

Response (RFC 9457 ProblemDetail envelope — Spring ตั้งค่า Content-Type เป็น application/problem+json ให้อัตโนมัติ):

json
{
  "type": "about:blank",
  "title": "Validation Failed",
  "status": 400,
  "errors": {
    "name": "must not be blank",
    "age": "must be greater than or equal to 0",
    "email": "must be a well-formed email address"
  }
}

Bean Validation — annotation ที่ใช้บ่อย

Annotationใช้กับทำอะไร
@NotNullanyห้าม null
@NotBlankStringห้าม null/ว่าง/มีแต่ whitespace
@NotEmptyString/Collection/Mapห้ามว่าง (length=0)
@Size(min, max)String/Collectionขนาดในช่วง
@Min(n) / @Max(n)Numberในช่วง
@Positive / @NegativeNumber> 0 / < 0
@DecimalMin("0.0")BigDecimalสำหรับ decimal
@EmailStringformat email
@Pattern(regexp="...")Stringmatch regex
@Past / @FutureDate/timeวันที่ในอดีต/อนาคต
@URLString(hibernate-validator — มาพร้อม spring-boot-starter-validation อยู่แล้ว) validate format URL เท่านั้น ไม่ได้เช็คว่า URL เข้าถึงได้จริง
@Validnested objectcascade validate ลึก

Custom Validator — เมื่อ built-in ไม่พอ (ขั้นสูง — ข้ามได้)

ตัวอย่าง: validate ว่า username ไม่ใช่คำที่ห้าม

💡 ส่วน public @interface คือ วิธีสร้าง annotation ใหม่ของ Java@interface ในที่นี้ไม่ใช่ interface ธรรมดา แต่เป็น syntax พิเศษที่ Java ใช้เฉพาะสำหรับสร้าง annotation เท่านั้น ไม่ต้องเข้าใจลึกตอนนี้ก็ได้ — ถ้าเพิ่งเริ่มเรียน Java annotations ข้าม section นี้ได้ก่อน

java
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Set;

// 1. annotation
@Target({ FIELD, PARAMETER })           // ใช้วาง annotation นี้บน field และ parameter ได้
@Retention(RUNTIME)                     // annotation ยังอยู่ตอน runtime (จำเป็นสำหรับ validation)
@Constraint(validatedBy = NotBannedUsernameValidator.class)  // ชี้ไปยัง class ที่ทำ validation จริง
public @interface NotBannedUsername {
    String message() default "username นี้ใช้ไม่ได้";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 2. validator
public class NotBannedUsernameValidator
        implements ConstraintValidator<NotBannedUsername, String> {

    private static final Set<String> BANNED =
        Set.of("admin", "root", "test", "system");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null) return true;             // null → ใช้ @NotNull แยก
        return !BANNED.contains(value.toLowerCase());
    }
}

// 3. ใช้
record CreateUserRequest(
    @NotBlank @NotBannedUsername String username,
    @Email String email
) {}

Cross-field validation (ตรวจหลาย field พร้อมกัน) — เช่น password ต้องตรงกับ confirm

java
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.Objects;

@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = PasswordsMatchValidator.class)
public @interface PasswordsMatch {
    String message() default "password กับ confirm ไม่ตรงกัน";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// หมายเหตุ: validator นี้ผูกกับ RegisterRequest โดยตรง — ถ้าต้องการ reuse กับ request class อื่น ควรสร้าง interface กลาง เช่น PasswordConfirmable
public class PasswordsMatchValidator
        implements ConstraintValidator<PasswordsMatch, RegisterRequest> {
    @Override
    public boolean isValid(RegisterRequest r, ConstraintValidatorContext ctx) {
        if (!Objects.equals(r.password(), r.confirmPassword())) {
            ctx.disableDefaultConstraintViolation();
            ctx.buildConstraintViolationWithTemplate(
                    "password กับ confirm ไม่ตรงกัน")
               .addPropertyNode("confirmPassword")
               .addConstraintViolation();
            return false;
        }
        return true;
    }
}

@PasswordsMatch
record RegisterRequest(@NotBlank String username,
                       @Size(min=8) String password,
                       String confirmPassword) {}

Validation groups — validate ต่าง context (ขั้นสูง — ข้ามได้)

java
interface OnCreate {}
interface OnUpdate {}

record User(
    @Null(groups = OnCreate.class)
    @NotNull(groups = OnUpdate.class) Long id,           // create=ห้ามมี, update=ต้องมี
    @NotBlank String name
) {}

// controller  (X = return type ใดก็ได้ เช่น ResponseEntity<User> — ตัวอย่างย่อ)
@PostMapping public ResponseEntity<User> create(@Validated(OnCreate.class) @RequestBody User u) { ... }
@PutMapping  public ResponseEntity<User> update(@Validated(OnUpdate.class) @RequestBody User u) { ... }

💡 ใช้ @Validated (Spring) แทน @Valid (Jakarta) เพื่อรองรับ group


12. สรุปภาพรวม annotation ที่ได้เรียน

Spring ทำงานด้วย annotation เป็นหลัก บทนี้ผ่านมาหลายตัวจนอาจสับสน — ตารางนี้รวมไว้ที่เดียวว่าแต่ละตัวใช้ทำอะไร ใช้เป็น cheat sheet ทบทวนก่อนไปบทถัดไป:

Annotationสำหรับ
@SpringBootApplicationmain class
@Component / @Service / @Repositorybean type
@RestControllerREST API class
@RequestMapping("/path")base path
@GetMapping / @PostMapping / ฯลฯHTTP method + path
@PathVariableURL parameter
@RequestParamquery string
@RequestBodybody → object
@Validtrigger validation
@RestControllerAdvice / @ControllerAdviceglobal exception handler (REST ใช้ @RestControllerAdvice)
@ExceptionHandler(X.class)handler ของ exception X
@Value("${prop}")inject property
@ConfigurationPropertiestype-safe config
(Validation) @NotBlank, @Email, @Min, @Maxconstraint

13. Checkpoint ใหญ่

🛠️ Final Checkpoint — Todo API

สร้าง REST API:

  • POST /todos — สร้าง todo
  • GET /todos — list ทั้งหมด
  • GET /todos/{id} — get เดียว
  • PUT /todos/{id} — update (title, done)
  • DELETE /todos/{id} — ลบ
  • เก็บใน Map<String, Todo> (in-memory)
  • validate input
  • 404 ถ้าหาไม่เจอ
  • 400 ถ้า validation ไม่ผ่าน

ใช้ structure: Controller → Service → Repository

📖 เฉลย (โครงสร้าง)
java
// model/Todo.java
public record Todo(String id, String title, boolean done) {}

// model/CreateTodoRequest.java
import jakarta.validation.constraints.NotBlank;
public record CreateTodoRequest(@NotBlank String title) {}

// model/UpdateTodoRequest.java
import jakarta.validation.constraints.*;  // ครอบ NotBlank และ NotNull ทั้งคู่
public record UpdateTodoRequest(@NotBlank String title, @NotNull Boolean done) {}

// repository/TodoRepository.java
@Repository
public class TodoRepository {
    private final Map<String, Todo> store = new ConcurrentHashMap<>();  // thread-safe singleton
    
    public Todo save(Todo t) { store.put(t.id(), t); return t; }
    public Optional<Todo> findById(String id) { return Optional.ofNullable(store.get(id)); }
    public List<Todo> findAll() { return new ArrayList<>(store.values()); }
    public void deleteById(String id) { store.remove(id); }
}

// service/TodoService.java
@Service
public class TodoService {
    private final TodoRepository repo;
    public TodoService(TodoRepository repo) { this.repo = repo; }
    
    public Todo create(String title) {
        Todo t = new Todo(UUID.randomUUID().toString(), title, false);
        return repo.save(t);
    }
    public Todo get(String id) {
        return repo.findById(id).orElseThrow(() -> new TodoNotFoundException(id));
    }
    public List<Todo> listAll() { return repo.findAll(); }
    public Todo update(String id, String title, boolean done) {
        get(id);    // เช็คก่อนว่ามีอยู่จริง (ไม่เจอจะ throw)
        return repo.save(new Todo(id, title, done));
    }
    public void delete(String id) { repo.deleteById(id); }
}

// controller/TodoController.java
// import org.springframework.http.HttpStatus; (จำเป็นสำหรับ HttpStatus.CREATED)
// import org.springframework.http.ResponseEntity;
// import org.springframework.web.bind.annotation.*;
// import jakarta.validation.Valid;
// import java.util.List;
@RestController
@RequestMapping("/todos")
public class TodoController {
    private final TodoService service;
    public TodoController(TodoService service) { this.service = service; }
    
    @GetMapping
    public List<Todo> listAll() { return service.listAll(); }
    
    @GetMapping("/{id}")
    public Todo getOne(@PathVariable String id) { return service.get(id); }
    
    @PostMapping
    public ResponseEntity<Todo> create(@Valid @RequestBody CreateTodoRequest req) {
        // หมายเหตุ: ตัวอย่างนี้ละ Location header ไว้เพื่อความสั้น — ในงานจริงควรใส่ด้วยเหมือน §6 (ใช้ ServletUriComponentsBuilder)
        return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req.title()));
    }
    
    @PutMapping("/{id}")
    public Todo update(@PathVariable String id, @Valid @RequestBody UpdateTodoRequest req) {
        return service.update(id, req.title(), req.done());
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable String id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

// exception/TodoNotFoundException.java — เหมือน UserNotFoundException แต่เปลี่ยน User → Todo:
// public class TodoNotFoundException extends RuntimeException {
//     private final String todoId;
//     public TodoNotFoundException(String id) { super("Todo not found: " + id); this.todoId = id; }
//     public String getTodoId() { return todoId; }
// }
// GlobalExceptionHandler — เพิ่ม @ExceptionHandler(TodoNotFoundException.class) เหมือน UserNotFoundException

ทดสอบ:

bash
# create
curl -X POST http://localhost:8080/todos \
     -H "Content-Type: application/json" \
     -d '{"title":"Learn Spring Boot"}'

# list
curl http://localhost:8080/todos

# update
curl -X PUT http://localhost:8080/todos/<id> \
     -H "Content-Type: application/json" \
     -d '{"title":"Learn Spring Boot","done":true}'

14. ต่อจากนี้ไป

หลังจบบทนี้คุณมี skill base ของ Spring Boot แล้ว — REST endpoint + DI + exception + validation ครบ พอจะเริ่มเขียนแอปจริงได้

บทถัดไป — บทที่ 2: Database + JPA จะแทน HashMap ในบทนี้ด้วย PostgreSQL จริง ผ่าน Spring Data JPA — entity, transaction, relation, N+1 problem

แผนที่บทรวม (ดู README สำหรับลำดับเต็ม):

บทสอนอะไร
02 — Database + JPAPostgreSQL, Entity, Repository, query methods, transaction
03 — Security + JWTlogin, role, JWT token, CORS
04 — TestingJUnit + Mockito + MockMvc + Testcontainers
05 — Production ReadinessActuator, logging, Docker, observability
06+ — AdvancedAOP, OAuth2, Microservices, Reactive, Caching, Messaging, etc.

ฝึกให้คล่องบทนี้ก่อนค่อยขึ้นบทถัดไป — โดยเฉพาะ Checkpoint Todo API ข้างบน ถ้าทำได้คล่อง = พื้นฐาน Spring Boot แน่นพอจะรับ database ได้


15. สรุปบท

✅ สร้าง Spring Boot project จาก Spring Initializr ✅ @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan@RestController + @GetMapping/@PostMapping ฯลฯ — REST endpoint ✅ @PathVariable, @RequestParam, @RequestBody — รับ input ✅ Spring auto convert object ↔ JSON (Jackson) ✅ @Service, @Repository — แยก layer ✅ Constructor injection — Spring auto wire ✅ @RestControllerAdvice + @ExceptionHandler — global exception handling ✅ application.properties + @Value / @ConfigurationProperties — config ✅ @Valid + Jakarta Validation — validate input

← บทที่ 0: Spring คืออะไร | กลับสารบัญ | → บทที่ 2: Database + JPA