Published on

การจัดการ Error ใน Rest API

Authors

การจัดการ Error ใน Rest API แบบ 3-layer architecture

ในการพัฒนา Rest API ส่วนใหญ่เราจะวางโครงสร้างโปรเจคแบบ 3-layer architecture เช่น Handler → Service → Repository ดังนั้นก่อนจะไปดูว่าจะส่ง error กลับไปยังไง เราควรเริ่มจากการแบ่งหน้าที่ความรับผิดชอบของแต่ละ layer ก่อน ดังนี้

  1. Repository Layer:
    • ให้ทำการ wrap database error นั้นๆ ด้วยข้อความที่สื่อความหมาย
    • หากรู้สาเหตุที่ชัดเจน ให้ return custom error กลับไป เช่น ErrDataIntegrity ในกรณี Foreign key, constraint violations หรือ ErrDuplicateEntry ในกรณี Conflict
  2. Service Layer:
    • จัดการ validate input และแปลง repository errors ไปเป็น business errors
    • ให้ log unexpected errors ไว้สำหรับการ debug
  3. Handler Layer:
    • แปลง service errors ไปเป็น HTTP response เช่น HTTP 400, 404, 500

ถัดมาลองสร้างรายการ error ที่เป็นไปได้ทั้งหมด ตามสถานะการทั่วไปมีอะไรบ้าง เช่น

Error TypeHTTP StatusWhen It Happens?
ErrInvalidRequest400 Bad RequestInvalid/malformed request (e.g., missing required fields, invalid format)
ErrValidation422 UnprocessableWhen request data is structurally valid but violates business rules
ErrAuthentication401 UnauthorizedMissing or invalid authentication token
ErrAuthorization403 ForbiddenUser does not have permission to access a resource
ErrResourceNotFound404 Not FoundResource not found (e.g., user, order, product doesn't exist)
ErrDuplicateEntry409 ConflictConflict with existing resource (e.g., duplicate email, order already processed)
ErrBusinessLogic422 UnprocessableFailed logical validation (e.g., insufficient funds)
ErrTooManyRequests429 Too Many RequestsRate-limiting (too many API requests in a short time)
ErrDatabaseFailure500 Internal Server ErrorDatabase connection failure, SQL error, or transaction failure
ErrDataIntegrity500 Internal Server ErrorDatabase-related issues: Foreign key, constraint violations
ErrServiceDependency503 Service UnavailableExternal service failure
ErrOperationFailed500 Internal Server ErrorUnknown errors

เมื่อได้รายการทั้งหมดออกมาแล้ว มาเริ่มเขียนโค้ดกันเลย โดยจะใช้ภาษา Go ในการแสดงตัวอย่าง

Step 1: สร้าง Custom errors

สร้าง custom error struct ขึ้นมา โดยมี error type และ message

// file: util/errs/errs.go
package errs

// ErrorType defines a type for different error categories in the service layer
type ErrorType string

const (
 // Input and Request Issues
 ErrInvalidRequest ErrorType = "invalid_request"  // Malformed JSON, missing/invalid fields
 ErrValidation     ErrorType = "validation_error" // Field-level validation (business rules)

 // Authentication and Authorization
 ErrAuthentication ErrorType = "authentication_error" // Invalid credentials
 ErrAuthorization  ErrorType = "authorization_error"  // Permission denied

 // Resource State Issues
 ErrResourceNotFound ErrorType = "resource_not_found"   // Missing entity
 ErrDuplicateEntry   ErrorType = "duplicate_entry"      // Conflict (e.g., unique constraint)
 ErrBusinessLogic    ErrorType = "business_logic_error" // Violations of business rules

 // Infrastructure and General Failures
 ErrDataIntegrity     ErrorType = "data_integrity_error" // Foreign key, constraint violations
 ErrDatabaseFailure   ErrorType = "database_failure"     // DB-level errors
 ErrServiceDependency ErrorType = "service_dependency"   // External service unavailability
 ErrOperationFailed   ErrorType = "operation_failed"     // Generic internal failures
)

// AppError represents a structured error used in the service layer
type AppError struct {
 Type       ErrorType      `json:"type"`        // Error category
 Message    string         `json:"message"`     // Friendly message for clients
}

// Error implements the error interface
func (e *AppError) Error() string {
 return fmt.Sprintf("[%s] %s", e.Type, e.Message)
}

// NewAppError creates a new AppError
func NewAppError(typ ErrorType, message string) *AppError {
 return &AppError{
  Type:       typ,
  Message:    message,
 }
}

// GetErrorType extracts the error type from an error
func GetErrorType(err error) ErrorType {
 if appErr, ok := err.(*AppError); ok {
  return appErr.Type
 }
 return ErrOperationFailed // Default error type if not recognized
}

Step 2: Repository Layer

  • Wrap database errors
  • Return ErrDuplicateEntry ในกรณีที่ email ซ้ำ
// file: repository/customer.go
package repository

import (
 "context"
 "database/sql"
 "fmt"
 "time"

 "github.com/lib/pq"

 "my-app/models"
)

type UserRepository struct {
 db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
 return &UserRepository{db *sql.DB}
}

func (r *UserRepository) Create(ctx context.Context, user *models.User) error {
 query := `INSERT INTO public.users (email, name) VALUES ($1, $2) RETURING id`

 err := r.db.QueryRowContext(ctx, query, user.Email, user.Name).
 Scan(&user.ID)
 if err != nil {
  // Detect duplicate entry (Postgres unique_violation code: 23505)
  if pqErr, ok := err.(*pq.Error); ok {
   if pqErr.Code == "23505" {
    return NewAppError(ErrDuplicateEntry, "email already registered")
   }
  }
  // Wrap database error
  return fmt.Errorf("failed to create user: %w", err)
 }

 return nil
}

Step 3: Service Layer

  • จัดการ validate input
  • แปลง repository errors ไปเป็น business errors
  • ให้ log unexpected errors ไว้สำหรับการ debug
// service/user.go
package service

import (
 "context"
 "log"
 "my-app/dtos"
 "my-app/models"
 "my-app/repository"
 "my-app/util/errs"
)

type UserService struct {
 userRepo *repository.UserRepository
}

func NewOrderService(userRepo repository.UserRepository) *UserService {
 return &UserService{userRepo: userRepo}
}

func (s *UserService) CreateUser(ctx context.Context, req *dtos.User) (int, error) {
  // validation input
 if req.Email == "" {
  return errs.NewAppError(errors.ErrValidation, "email is required")
 }

 user := &models.User{
  Email: req.Email,
  Name:  req.Name,
 }

 err := s.userRepo.Create(ctx, user)
 if err != nil {
  // log unexpected error
  log.Println(err)
  // handle repository error
  if ErrDuplicateEntry == errs.GetErrorType(err) {
   return err
  }
  return errs.NewAppErr(ErrDatabaseFailure, "failed to create user")
 }

 return user.ID, nil
}

Step 4: Handler Layer

  • แปลง service errors ไปเป็น HTTP response
// file: handler/user.go
package handler

import (
 "github.com/gin-gonic/gin"
 "my-app/service"
 "my-app/dtos"
 "my-app/util/errs"
 "net/http"
)

type UserHandler struct {
 service *service.UserService
}

func NewUserHandler(srv service.UserService) *UserHandler{
 return &UserHandler{service: srv}
}

func (h *UserHandler) CreateUser(c *gin.Context) {
 var user dtos.User
 if err := c.ShouldBindJSON(&user); err != nil {
  // Invalid JSON format error
  h.handleError(c, errs.NewAppError(errors.ErrJSONParse, "invalid JSON format"))
  return
 }

 id, err := h.service.CreateUser(c.Request.Context(), &user)

 if err != nil {
  // Handle business validation error
  handleError(c, err)
  return
 }

 c.JSON(http.StatusCreated, gin.H{"id": id})
}

  • สร้างฟังก์ชัน handleError ไว้ช่วยจัดการ error response
// file: handler/handler.go
package handler

import (
 "github.com/gin-gonic/gin"
 "my-app/util/errs"
)

type errorResponse struct {
 ErrorType    errs.ErrorType `json:"error_type"`
 ErrorMessage string         `json:"error_message"`
}

// Centralized error handling
func (h *CustomerHandler) handleError(c *gin.Context, err error) {
 // Convert non-AppError to AppError with type ErrOperationFailed
 appErr, ok := err.(*errs.AppError);
 if !ok {
  appErr = errors.NewAppError(errs.ErrOperationFailed, err.Error())
 }

 // Get the appropriate HTTP status code based on the error type
 statusCode := errs.GetHTTPStatus(err)

 // Return the error in structured JSON format
 c.JSON(statusCode, errorResponse{
  ErrorType:    appErr.Type,
  ErrorMessage: appErr.Message,
 })
}

ซึ่งจะเห็นได้ว่าวิธีการนี้ เป็นการจัดการ Error ที่สอดคล้องกันในทุก Layer

  • Repository Layer: ระบุข้อผิดพลาดฐานข้อมูล
  • Service Layer: จัดการข้อผิดพลาด Validation และ Bussiness Login
  • Handler Layer: แปลง Error เป็น JSON Response ที่เหมาะสม

ประโยชน์:

  • ลดการทำงานซ้ำซ้อน (DRY Principle)
  • มีการจัดการข้อผิดพลาดที่ชัดเจนและแยกความรับผิดชอบของแต่ละ Layer อย่างเหมาะสม

🎯 สรุปภาพรวม

  • แยกประเภท Error อย่างชัดเจน ตามบริบท (Validation, Business, Database, External)
  • รองรับ HTTP Status Code ที่ถูกต้อง ตามมาตรฐาน REST API
  • ดักจับข้อผิดพลาดฐานข้อมูลละเอียด ด้วย PostgreSQL Error Code
  • ลดความซ้ำซ้อน โดยใช้ AppError กลางสำหรับทุก Layer
  • ขยายง่าย รองรับข้อผิดพลาดใหม่ ๆ ได้โดยเพิ่มเพียง ErrorType
  • เพิ่มความยืดหยุ่น ในการแปลงและการแสดงผล Error อย่างมีโครงสร้าง