Skip to content

บทที่ 3 — Versioning และ Error Handling

← บทที่ 2 | สารบัญ | บทที่ 4 →

หลังจบบท คุณจะ:

  • ตัดสินใจ versioning strategy ที่เหมาะกับทีม
  • ออกแบบ error response ที่ developer ใช้งานง่าย
  • ใช้ Problem Details (RFC 9457 — supersedes RFC 7807)
  • จัดการ breaking change + deprecation
  • รู้ว่าเมื่อไหร่ควร break version

RFC = เอกสารมาตรฐานสากลของอินเทอร์เน็ต เลขคือหมายเลขเอกสาร ไม่ต้องจำ (เช่น RFC 9457 = มาตรฐานหน้าตาของ error response ฉบับใหม่ที่มาแทน RFC 7807)


Part 1: Versioning

1. ทำไมต้อง Versioning

เริ่มมี API → 6 เดือนผ่านไป → ต้องเปลี่ยน response format

Before:
GET /users/1 → { "name": "Anna", "age": 25 }

After:
GET /users/1 → { "firstName": "Anna", "lastName": "B.", "age": 25 }

ทุก client เก่า → break 😱

Version = "v1 อยู่ตามเดิม + v2 มี format ใหม่"


2. Breaking vs Non-breaking Change

✅ Non-breaking (safe)

  • เพิ่ม field ใน response
  • เพิ่ม endpoint ใหม่
  • เพิ่ม optional query parameter
  • เพิ่ม optional field ใน request body
  • ขยาย enum (เพิ่ม value)
  • ลด restriction (เพิ่มความยาว max ของ field)

❌ Breaking (need version)

  • ลบ / rename field
  • เปลี่ยน type ของ field
  • ลบ endpoint
  • ลบ enum value
  • เปลี่ยน status code semantics
  • เปลี่ยน auth method
  • เพิ่ม required field ใน request

กฎทอง — Robustness Principle

"Be conservative in what you send, be liberal in what you accept" — Jon Postel

แปลไทย: "ตอนส่งให้ระมัดระวัง ตอนรับให้ใจกว้าง" — ส่งออกไปให้เป๊ะตามมาตรฐาน แต่ตอนรับเข้ามาให้ยืดหยุ่น เผื่อ data มีส่วนเกินที่เราไม่รู้จัก

📝 หมายเหตุภาษา: ใน context นี้ "conservative" = "ระวัง/strict" (ส่งให้เป๊ะ ไม่เกิน spec) และ "liberal" = "ใจกว้าง/lenient" (รับได้แม้มี field เกิน) — ไม่ใช่ความหมายทางการเมือง

  • Server: เพิ่มอย่างเดียว ลบช้า ๆ
  • Client: ignore field ที่ไม่รู้จัก

3. Versioning Strategies

A. URI Versioning ⭐ (ส่วนใหญ่ใช้)

/api/v1/users
/api/v2/users

ข้อดี:

  • ✅ ชัดเจน + เห็นใน URL
  • ✅ Cache-friendly
  • ✅ Easy to route (nginx route v1 → server A, v2 → server B)

ข้อเสีย:

  • ❌ "violation of REST" (URI ควรเป็น resource — ไม่ใช่ version)
  • ❌ Client ต้อง update URL ทุกที่

ในงานจริง — URI versioning ง่ายและชัดที่สุด — REST puritan แพ้

B. Header Versioning

http
GET /api/users
Accept: application/vnd.myapp.v2+json

vnd = "vendor" — ส่วนของ media type ที่บริษัทเอง (vendor) ตั้งชื่อได้ตามมาตรฐาน IANA (เช่น vnd.myapp, vnd.github) ตามด้วย +json เพื่อบอกว่า encode เป็น JSON

ข้อดี:

  • URL clean
  • ✅ Same resource — different representation

ข้อเสีย:

  • ❌ Hidden — debug ยาก (เปิด browser ดูไม่เห็น version)
  • ❌ Cache + routing ลำบาก
  • ❌ Test ผ่าน curl ยุ่งขึ้น

C. Query Parameter

/api/users?version=2

ข้อดี:

  • ✅ Easy

ข้อเสีย:

  • ❌ Semantic ไม่ถูก (param ควรเป็น filter/sort)
  • ❌ Cache key เปลี่ยน

D. Content Negotiation (Media Type)

http
GET /users
Accept: application/json; version=2

GitHub ใช้ pattern คล้าย ๆ:

Accept: application/vnd.github.v3+json

เลือกแบบไหน?

ทำ public API + ทีมใหม่ → URI versioning (v1, v2)
ทำ enterprise/strict REST → Header (Accept)
ทำ internal API → URI versioning

หนังสือนี้ → ใช้ URI versioning เพราะ easy + popular


4. Version Number Convention

Major Only

/v1, /v2, /v3

→ เปลี่ยนเมื่อ breaking change

Major.Minor

/v1.0, /v1.1, /v2.0

→ overengineering สำหรับ REST API

→ ใช้ major only + เพิ่ม field ได้ใน version เดิม

Date-based

?version=2026-05-18

Stripe ใช้ pattern คล้าย ๆ — client lock version ตอนเริ่ม → API breaking ก็ไม่กระทบ

📝 หมายเหตุ Stripe จริง: Stripe ใช้ header Stripe-Version: 2024-04-10 (ไม่ใช่ query parameter ตามตัวอย่างข้างบน) ตัวอย่าง query เป็น syntax ทั่วไปที่อ่านง่าย แต่ของจริงควรใช้ header เพื่อไม่ปน semantic กับ filter/sort


5. Deprecation Strategy

ไม่มีใครจะใช้ v1 ตลอดไป — ต้อง deprecate + remove

Step 1: Announce + Sunset header

http
GET /api/v1/users

Response:
HTTP/1.1 200 OK
Deprecation: @1735689599
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: </api/v2/users>; rel="successor-version"

Deprecation (อ่าน เด็ป-รี-เค-ชัน = "ประกาศเลิกใช้") + Sunset (อ่าน ซัน-เซ็ต = "พระอาทิตย์ตก" — metaphor หมายถึง "วันสุดท้ายของบริการ" เหมือนพระอาทิตย์ที่กำลังจะลับฟ้า)

มาตรฐาน:

  • Sunset header ตาม RFC 8594 — รับเฉพาะ HTTP-date format (RFC 7231 / 5322) เช่น Wed, 31 Dec 2026 23:59:59 GMT ไม่ใช่ ISO 8601 (2026-12-31T23:59:59Z)
  • Deprecation header ตาม RFC 9745 (มี.ค. 2025) — รับเป็น HTTP-date หรือ Unix timestamp นำหน้าด้วย @ (เช่น @1735689599) — ค่า true แบบเก่าเป็น draft ที่ deprecate ไปแล้ว

Step 2: Documentation

  • Mark v1 = deprecated ใน OpenAPI
  • Email partners
  • Blog post
  • Migration guide

Step 3: Monitoring

- Log ทุก request ไป v1 → identify clients
- Alert ถ้า v1 traffic ไม่ลด ก่อน sunset date

Step 4: Sunset

  • Return 410 Gone + redirect doc
  • หรือ 301 Moved Permanently to v2 (ถ้า compatible)

Timeline

Day 0:    Launch v2
Month 1:  Add deprecation header on v1
Month 6:  Email all v1 users
Month 12: Sunset v1

ปรับตาม risk + customer base


6. Implementation — Spring Boot

มาดูว่า URL versioning ทำจริงใน Spring Boot ยังไง — แยก controller ต่อ version (/api/v1/..., /api/v2/...) ที่ map response ต่าง shape โดยใช้ service เดียวกันด้านล่าง ทำให้เพิ่ม v2 ได้โดยไม่กระทบ v1 ที่ client เดิมยังใช้อยู่

📝 คำที่ใช้ในตัวอย่าง (รายละเอียดเต็มดู Spring Boot book):

  • record = Java 16+ feature สำหรับสร้าง immutable DTO สั้น ๆ (auto-generate constructor/getter/equals)
  • @RequiredArgsConstructor = Lombok annotation ที่ generate constructor รับ final field ทั้งหมด (ใช้คู่กับ dependency injection)
  • orElseThrow(EntityNotFoundException::new) = method reference — ถ้า Optional ว่างให้ throw EntityNotFoundException (สั้นกว่า lambda () -> new EntityNotFoundException())
java
// Service ที่ V1 และ V2 ใช้ร่วมกัน — domain logic อยู่จุดเดียว
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepo;

    public User getById(Long id) {
        return userRepo.findById(id).orElseThrow(EntityNotFoundException::new);
    }
}

// DTO mapper แยกตาม version — สร้างจาก domain User
public record UserResponseV1(Long id, String name, String email) {
    public static UserResponseV1 from(User u) {
        return new UserResponseV1(u.getId(), u.getName(), u.getEmail());
    }
}

public record UserResponseV2(Long id, String fullName, String email, Instant createdAt) {
    public static UserResponseV2 from(User u) {
        return new UserResponseV2(u.getId(), u.getName(), u.getEmail(), u.getCreatedAt());
    }
}
java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserControllerV1 {
    private final UserService userService;

    @GetMapping("/{id}")
    public UserResponseV1 get(@PathVariable Long id) {
        return UserResponseV1.from(userService.getById(id));
    }
}

@RestController
@RequestMapping("/api/v2/users")
@RequiredArgsConstructor
public class UserControllerV2 {
    private final UserService userService;

    @GetMapping("/{id}")
    public UserResponseV2 get(@PathVariable Long id) {
        return UserResponseV2.from(userService.getById(id));
    }
}

Pattern: service ทำงานกับ domain entity (User) ตัวเดียวกันทั้งคู่ — controller แต่ละ version map เป็น UserResponseV* ด้วย static factory from(User) ทำให้เพิ่ม version ใหม่ได้โดยไม่ต้องแตะ domain logic


Part 2: Error Handling

7. กฎทอง Error Response

✅ ต้องมี:

  • HTTP status code ที่ถูก (400/401/403/404/409/422/500)
  • Error code (string) — machine-readable
  • Message — human-readable
  • Detail — แสดงรายละเอียดที่ผิด
  • Trace ID — สำหรับ debug

❌ อย่า:

  • Return 200 + { "error": ... } — status code คือ contract
  • Stack trace ใน production response
  • Generic "Internal Error" สำหรับ validation
  • Error message ที่ help attacker (e.g. "SQL syntax error...")

8. Error Format — Examples

A. Simple

json
{
    "error": "Email already exists"
}

⚠️ Pros: simple
⚠️ Cons: ไม่มี code, ไม่ extensible

B. Standard Envelope

json
{
    "error": {
        "code": "EMAIL_EXISTS",
        "message": "Email already in use",
        "timestamp": "2026-05-18T10:00:00Z",
        "traceId": "abc-123"
    }
}

C. With Field Details (Validation)

json
{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Validation failed",
        "details": [
            { "field": "email", "code": "INVALID_FORMAT", "message": "must be a valid email" },
            { "field": "age", "code": "OUT_OF_RANGE", "message": "must be >= 18" }
        ],
        "timestamp": "2026-05-18T10:00:00Z",
        "traceId": "abc-123"
    }
}

D. Problem Details (RFC 9457 — Updated ปี 2024) ⭐ Standard

ข้อมูลใหม่ปี 2026: RFC 7807 (2016) ถูก obsoleted โดย RFC 9457 (กรกฎาคม 2024) — เพิ่ม clarifications + i18n extensions. แต่ format หลักยังเหมือนเดิม.

json
{
    "type": "https://example.com/errors/email-exists",
    "title": "Email Already Exists",
    "status": 409,
    "detail": "Email 'anna@example.com' is already registered",
    "instance": "/api/v1/users",
    "traceId": "abc-123"
}
http
Content-Type: application/problem+json

Content-Type: application/problem+jsonproblem = error format ตาม RFC 9457, +json = ส่งเป็น JSON (ถ้าเป็น XML จะเป็น application/problem+xml)

📝 เกี่ยวกับ type URI: spec แนะนำให้ type resolve ไปหน้า documentation ที่อ่านได้จริง — ถ้ายังไม่มี doc ให้ใช้ค่า default "about:blank" (อย่าทิ้ง placeholder URL ที่เปิดไม่ขึ้นใน production)

Field Definitions

Fieldคำอธิบาย
typeURI ของ error type (ลิงค์ไป doc)
titleShort human-readable summary
statusHTTP status code (เหมือนใน response)
detailSpecific human-readable detail
instanceURI ของ specific occurrence
Customเพิ่ม field อะไรก็ได้ (เช่น traceId, errors)

Adoption ปี 2026

✅ Spring Boot 3+ built-in (ProblemDetail class)
✅ ASP.NET Core
✅ FastAPI (Python)
✅ NestJS (with library)

⚠️ ต้องการ source — ตรวจครั้งสุดท้าย 2026-06: บางแหล่งระบุว่า Stripe/GitHub กำลังปรับมาใช้ Problem Details — ก่อนอ้างให้ตรวจ changelog/blog ของ vendor เอง (ปัจจุบัน format error ของ Stripe ยังเป็น envelope ของตัวเอง)

ใช้กันแพร่หลายในปี 2026 — แนะนำสำหรับ API ใหม่ทุกตัว


9. Problem Details Implementation (RFC 9457 / 7807)

Problem Details คือ format error มาตรฐานที่เรียนไปก่อนหน้า — Spring Boot 3+ รองรับในตัวผ่าน ProblemDetail (class นี้รองรับทั้งสเปคเก่า RFC 7807 และเวอร์ชันใหม่ RFC 9457 เพราะ field หลักไม่ต่างกัน) ใช้ @RestControllerAdvice จับ exception แต่ละชนิดแล้วแปลงเป็น ProblemDetail ที่มี status/title/detail + custom property (เช่น traceId) รวม error handling ไว้ที่เดียว:

Spring Boot 3+ — built-in

java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    
    @ExceptionHandler(EntityNotFoundException.class)
    public ProblemDetail handleNotFound(EntityNotFoundException e) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, e.getMessage()
        );
        problem.setType(URI.create("https://api.myapp.com/errors/not-found"));
        problem.setTitle("Resource Not Found");
        problem.setProperty("traceId", MDC.get("traceId"));  // MDC (Mapped Diagnostic Context) = ที่เก็บค่าแนบ log ต่อ 1 request (SLF4J)
        return problem;
    }
    
    @ExceptionHandler(EmailExistsException.class)
    public ProblemDetail handleEmailExists(EmailExistsException e) {
        ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
        p.setType(URI.create("https://api.myapp.com/errors/email-exists"));
        p.setTitle("Email Already Exists");
        return p;
    }
    
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex,
        HttpHeaders headers, HttpStatusCode status, WebRequest request
    ) {
        List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of(
                "field", fe.getField(),
                "code", fe.getCode(),
                "message", fe.getDefaultMessage()
            ))
            .toList();
        
        ProblemDetail p = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        p.setTitle("Validation Failed");
        p.setProperty("errors", errors);
        return ResponseEntity.badRequest().body(p);
    }
}

⚠️ ลำดับ filter สำคัญ: MDC.get("traceId") จะได้ค่าก็ต่อเมื่อ TraceFilter (ดู §13) ทำงาน ก่อน exception handler — ถ้า order filter ไม่ถูก จะได้ null ตรวจ @Order หรือ Spring Security filter chain ให้ TraceFilter อยู่ก่อนสุด


10. Error Code Convention

Format

NOUN_VERB_REASON                  # ที่ stable + consistent
EMAIL_ALREADY_EXISTS
USER_NOT_FOUND
INSUFFICIENT_BALANCE
RATE_LIMIT_EXCEEDED
INVALID_TOKEN
TOKEN_EXPIRED

📝 ทำไมใช้ English? error code เป็น machine-readable identifier ที่ logic code ใน client ต้อง if (code === "EMAIL_ALREADY_EXISTS") ใช้ English ทำให้ universal (ทำงานข้ามภาษา) + stable (ไม่เปลี่ยนแม้แปล UI) ส่วนข้อความที่ user เห็นใช้ message หรือ i18n key แยกต่างหาก

Use Code, Not Message

json
✅ { "code": "INSUFFICIENT_BALANCE" }
❌ if (response.message === "You don't have enough balance") { ... }

→ Message สำหรับ user, code สำหรับ logic

i18n

json
{
    "error": {
        "code": "EMAIL_EXISTS",
        "message": "Email already exists",          // English fallback
        "messageKey": "errors.email.exists"          // for i18n on client
    }
}

หรือ — เก็บ message ใน client (frontend) ตาม locale, server ส่งแค่ code


11. Status Code — เลือกถูก

http
400 Bad Request           - format ผิด, missing required field
401 Unauthorized          - no auth / token หมดอายุ
403 Forbidden             - มี auth แต่ไม่มีสิทธิ์
404 Not Found             - resource ไม่มี
405 Method Not Allowed    - endpoint ไม่รองรับ method
406 Not Acceptable        - Accept header ไม่รองรับ
408 Request Timeout       - client ช้าเกิน
409 Conflict              - state ขัด (email ซ้ำ, version mismatch)
410 Gone                  - resource หายไป (deprecated v1)
412 Precondition Failed   - If-Match ไม่ตรง
413 Payload Too Large
415 Unsupported Media Type
422 Unprocessable Entity  - validation fail
425 Too Early             - retry สั้นเกิน
429 Too Many Requests     - rate limit

500 Internal Server Error
501 Not Implemented
502 Bad Gateway           - upstream service error
503 Service Unavailable   - server down/maintenance
504 Gateway Timeout

400 vs 422 — ใช้ตัวไหน?

ดีเบทเก่า:

  • 400 Bad Request = "request ผิด (parse ไม่ได้)" — เช่น invalid JSON
  • 422 Unprocessable Content = "parse ได้ แต่ validation ผิดเชิง semantic" — เช่น email ผิด format, age < 18

แนวทางในหนังสือนี้ — ใช้ 422 เมื่อ field-level validation fail (semantic ชัดกว่า แยก parse error ออกได้) และใช้ 400 เมื่อ request ผิดรูป (JSON พัง, missing required field ที่ parse ไม่ได้):

เคสcode
Body ไม่ใช่ JSON / parse error400
Missing required field ที่ binder fail400
Field ครบแต่ validation rule ผิด (@Email, @Min)422
Type mismatch ที่ Jackson แปลงไม่ได้400

Framework default ต่างกัน — รู้ไว้:

  • Spring Boot validation บน @RequestBody (@Valid fail → MethodArgumentNotValidException) → default คืน 400 (จะเปลี่ยนเป็น 422 ต้อง override ใน handleMethodArgumentNotValid)
  • ⚠️ Spring Boot validation บน @RequestParam/@PathVariable (เช่น @RequestParam @Email String emailConstraintViolationException) → default คืน 500 (ต้องเพิ่ม @ExceptionHandler(ConstraintViolationException.class) คืน 400/422)
  • Stripe / Twitter API → ใช้ 400 สำหรับทุก validation error
  • GitHub API → ใช้ 422 สำหรับ validation

📝 คำศัพท์:

  • Bean Validation = มาตรฐาน Java (JSR 380) ของ annotation ที่ใส่บน field/parameter เพื่อ validate (@Email, @Min(0), @NotBlank)
  • Jackson = library ที่ Spring ใช้แปลง JSON ↔ Java object (เป็นด่านแรกก่อน Bean Validation ทำงาน — ถ้า JSON parse ไม่ผ่านที่ Jackson จะเป็น 400 ก่อนถึง validation)
  • binder = ตัวที่ Spring ใช้ map request body / query param เข้า Java parameter

เลือกอย่างใดอย่างหนึ่ง + consistent ทั้ง API สำคัญกว่าเลือกถูก/ผิด


12. Common Error Scenarios

มาดูว่า error format มาตรฐานใช้กับสถานการณ์จริงแต่ละแบบยังไง — not found (404), validation (422 พร้อมรายละเอียดทุก field), conflict (409), auth (401/403), rate limit (429 พร้อม Retry-After) ส่วนนี้รวม response ที่ควรคืนในแต่ละเคสให้ใช้เป็นแบบ:

Resource Not Found

GET /users/9999

HTTP 404 Not Found
{
    "type": ".../errors/not-found",
    "title": "Not Found",
    "status": 404,
    "detail": "User with id 9999 not found"
}

Validation

POST /users
{ "email": "bad", "age": 5 }

HTTP 422 Unprocessable Entity
{
    "type": ".../errors/validation",
    "title": "Validation Failed",
    "status": 422,
    "errors": [
        { "field": "email", "code": "INVALID_FORMAT" },
        { "field": "age", "code": "OUT_OF_RANGE", "min": 18 }
    ]
}

Conflict

POST /users
{ "email": "exists@x.com", ... }

HTTP 409 Conflict
{
    "code": "EMAIL_EXISTS",
    "message": "Email already in use",
    "conflictingField": "email"
}

Authorization

GET /admin/users
[no auth header]

HTTP 401 Unauthorized
{ "code": "MISSING_TOKEN", "message": "Authorization required" }

HTTP 401 Unauthorized
{ "code": "EXPIRED_TOKEN", "message": "Token expired" }

HTTP 403 Forbidden
{ "code": "INSUFFICIENT_ROLE", "message": "Admin role required" }

Rate Limit

HTTP 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1747562460
{
    "code": "RATE_LIMITED",
    "message": "Too many requests. Retry in 60 seconds"
}

13. Trace ID — Debug Critical

เมื่อ user แจ้งว่า "API error" แต่ไม่รู้ว่า request ไหน — trace ID แก้ปัญหานี้ ทุก request มี ID ที่ใส่ทั้งใน response และ log เมื่อ user ส่ง ID มา dev ก็ตามไปหา log ของ request นั้นได้ทันที ส่วนนี้แสดง filter ที่ generate/propagate trace ID:

Request:
X-Request-ID: abc-123     ← client send (optional) 
                          → server use หรือ generate

Response:
X-Request-ID: abc-123
java
@Component
public class TraceFilter extends OncePerRequestFilter {
    private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9\\-]{1,128}");

    @Override
    protected void doFilterInternal(...) {
        String traceId = req.getHeader("X-Request-ID");
        // ⚠️ sanitize ค่าจาก client ก่อนใช้ — กัน log injection (newline, control char)
        if (traceId == null || !SAFE_ID.matcher(traceId).matches()) {
            traceId = UUID.randomUUID().toString();
        }

        MDC.put("traceId", traceId);   // ใส่ traceId เข้า MDC (Mapped Diagnostic Context) → ทุกบรรทัด log ของ request นี้จะมี traceId ติดไป
        try {
            res.setHeader("X-Request-ID", traceId);
            chain.doFilter(req, res);
        } finally {
            MDC.clear();
        }
    }
}

⚠️ อย่า trust header จาก client ตรง ๆX-Request-ID มาจาก attacker ก็ได้ ถ้าใส่ค่ามี \n แล้วเขียนตรง ๆ ลง log จะเป็น log injection (attacker ปลอม log entry ได้) sanitize ให้รับเฉพาะ alphanumeric + dash เสมอ

ทุก log ของ Spring Boot จะมี traceId → debug ง่าย

🚀 ขั้นสูง — รอเรียนเมื่อพร้อม deploy จริง: W3C Trace Context + OpenTelemetry เป็นมาตรฐาน production สมัยใหม่ — มือใหม่อ่านเอาแนวคิดก่อน ยังไม่ต้องลงมือใช้กับ project แรก

คำอ่าน + ขยาย:

  • W3C = World Wide Web Consortium (องค์กรกำหนดมาตรฐานเว็บ)
  • OpenTelemetry (อ่าน "โอเพ่น-เทเลเมตรี่" บางคนเรียกย่อ "OTel" = "โอ-เทล") = framework สำหรับเก็บ trace/metric/log ข้าม service

ตัวอย่างข้างบนใช้ X-Request-ID แบบเก่าเพื่อความเข้าใจง่าย — ของจริงปี 2026 มาตรฐานคือ header traceparent (W3C Trace Context, W3C Rec) ที่ propagate trace/span id ข้าม service ได้ และ OpenTelemetry SDK auto-instrument ให้ Spring Boot 3+ มี built-in support ผ่าน Micrometer Tracing (management.tracing.*) → traceId/spanId เข้า MDC ให้อัตโนมัติ ไม่ต้องเขียน filter เอง · เจาะลึกในหมวด observability


14. Don't Leak Sensitive Info

error message ที่ละเอียดเกินไปช่วย attacker — ชื่อตาราง, SQL, stack trace บอกใบ้โครงสร้างภายในและช่องโหว่ กฎคือ log รายละเอียดเต็มไว้ฝั่ง server แต่คืน message กลาง ๆ ให้ client (พอ debug ได้ด้วย traceId แต่ไม่ leak):

❌ "Database error: ER_DUP_ENTRY at users_email_key"
❌ "User not found: select * from users where email = 'attacker_input'"
❌ Stack trace ใน production response

✅ "Resource not found"
✅ "Service temporarily unavailable"

Log full error server-side → return generic message ให้ client


15. Idempotent Error Handling

จุดที่มักลืม — idempotency ต้องครอบ "error" ด้วย ไม่ใช่แค่ success ถ้า request แรก fail ด้วย validation error การ retry ด้วย key เดิมต้องคืน error เดิม (ไม่ใช่ลองใหม่จนสำเร็จ) เพื่อให้พฤติกรรมคาดเดาได้และกัน duplicate side effect:

POST/PATCH ที่ใช้ Idempotency-Key — return same error if retry:

POST /payments
Idempotency-Key: abc-123
{ "amount": -100 }     ← invalid

HTTP 422 (first time)
{ "code": "INVALID_AMOUNT" }

[client retry]

POST /payments
Idempotency-Key: abc-123

HTTP 422 (same response)    ← ⭐ cached

Rule ตอน implement idempotency cache

  1. Cache key = Idempotency-Key + hash(request body) — กัน client ที่ส่ง key เดิมแต่ body ต่าง (เจตนาหรือพลาด) · hash ในที่นี้ใช้ algorithm ที่ deterministic อะไรก็ได้ (เช่น SHA-256) — ไม่ต้องเป็น crypto-strength เพราะใช้เทียบความเหมือนของ body เท่านั้น ไม่ใช่กัน collision ของ attacker
  2. ถ้า key เดิม + body ต่าง → คืน 409 Conflict (ไม่ใช่คืน response เก่า, ไม่ใช่ทำ request ใหม่)
  3. TTL — กำหนดอายุ cache ที่สมเหตุสมผล (Stripe spec: 24 ชั่วโมง — ตรวจ status ครั้งสุดท้าย 2026-06 · Stripe Docs — Idempotent Requests)
  4. กัน concurrent retry — request ที่มาพร้อมกันด้วย key เดียวกัน ต้อง lock (เช่น Redis SET NX + ttl) ไม่งั้นจะ process ซ้ำก่อน cache จะถูก write
  5. Error response ก็ต้อง cache — ตามตัวอย่างด้านบน retry แล้วต้องเจอ error เดิม ไม่ใช่ retry จนผ่าน

16. ⚠️ Common Pitfalls

ปิดท้ายบทด้วยสรุปกับดักเรื่อง versioning และ error handling ที่เจอบ่อย — คืน 200 พร้อม error ใน body, error format ไม่ consistent, ไม่มี version, leak stack trace ตารางจับคู่ "ผิด" กับ "ถูก" ใช้เป็น checklist ตรวจ API ก่อนปล่อย:

200 OK + { "error": ... }proper 4xx/5xx status
Generic "Internal Error"specific code (404, 409, 422)
Stack trace exposedlog internal, return safe message
Different format ใน endpoint ต่างกันglobal handler + consistent format
Magic status code (599)standard codes only
code: "ERR1"descriptive EMAIL_EXISTS
No trace IDalways include
500 for validation400/422
404 for forbidden403 (with auth)
Same wording in different errorslocalize per case

17. Versioning Pitfalls

ไม่มี version ใน URL/api/v1/... ตั้งแต่แรก
เพิ่ม required field โดยไม่ versionbump version หรือ optional
Drop endpoint without noticedeprecate + sunset header + email
Version 1.0.0, 1.0.1, ...use major only
Multiple version foreversunset old versions

18. Checkpoint

🛠️ Checkpoint 3.1 — Setup Global Error Handler
ใน Spring Boot project:

  • @RestControllerAdvice ที่ handle: NotFound, Validation, EmailExists, Generic
  • Return Problem Details (RFC 9457)
  • รวม traceId
  • Test ด้วย curl + ดู response

🛠️ Checkpoint 3.2 — Versioning

  • API v1: /api/v1/users/{id} → return { "name": "...", "age": 25 }
  • API v2: /api/v2/users/{id} → return { "firstName": "...", "lastName": "...", "age": 25 }
  • Share service — split DTO

🛠️ Checkpoint 3.3 — Deprecation
ใส่ deprecation header ใน v1:

  • Deprecation: true
  • Sunset: <date>
  • Link: </api/v2/...>; rel="successor-version"

🛠️ Checkpoint 3.4 — Error Codes
สร้าง error code catalog (เอกสาร):

  • 20+ error code ที่ใช้ในระบบ
  • description + HTTP status + ตัวอย่าง response

19. สรุปบท

Versioning จำเป็นสำหรับ breaking change — URI versioning (/v1/...) ง่ายสุด
✅ Breaking = ลบ/rename, เปลี่ยน type. Non-breaking = เพิ่ม field/optional
Deprecation header + sunset date + monitoring + migration guide
Error response ต้องมี: HTTP status + code + message + detail + traceId
Problem Details (RFC 9457, supersedes RFC 7807) = standard ปี 2026 — application/problem+json
✅ Error code = stable + machine-readable (EMAIL_EXISTS > Error #42)
✅ Status code ตามมาตรฐาน — 400 vs 422 เลือก 1
Trace ID ทุก response — debug critical
✅ อย่า leak internal — log full / return safe message


← บทที่ 2 | บทที่ 4 → Auth + Security + Rate Limit


Glossary: ../glossary.md · Style guide: ../CONTRIBUTING.md last_verified: 2026-06-03 · review report: ../REVIEW-2026-06-03.md