EコマースAPI(第1部):モデル設計とCRUD

理論の説明は以上です。それでは、Goを使って実際のEC APIをゼロから構築していきましょう。

ボブは、独自のECプラットフォームを立ち上げることにした。3週間、3つのストーリー、そしてGoで構築された完全なバックエンド。

1. 学習内容


2. ストーリー:ボブの起業家としての初日

(1) 課題:「まずはMVPをビルドして、2週間後にリリースしよう。」

ボブは仕事を辞め、自身のビジネス――Eコマースプラットフォーム――を立ち上げた:

「初日、私はmain.goにすべてのロジックを詰め込んだモノリシックなシステムを構築しました。7日目にはコードが3,000行にまで膨れ上がり、どの部分が順序ロジックで、どの部分がユーザーロジックなのか見分けがつかなくなってしまいました。『クーポン』機能を追加しようとしたのですが、6つのファイルを修正してもまだ完了しませんでした。ビジネスパートナーは『2週間でリリースしよう』と言っていたが、2ヶ月でも厳しいかもしれない。」

しかし、落ち着いてGoの4層アーキテクチャについて考え直した結果、彼はコードのリファクタリングを行うことに決めた:

GO
// 再構築計画:
// cmd/server/        → エントリポイント
// internal/handler/  → HTTP プロセッサ
// internal/service/  → ビジネスロジック
// internal/repository/ → データへのアクセス
// pkg/model/         → ドメインモデル

(2) 学習目標:ユーザー、商品、注文に関する基本的なCRUD操作

第1週の目標:完全なフレームワークを構築し、3つの主要リソースに対してCRUD操作を実装する。

TEXT
POST /api/v1/register         → ユーザー登録
POST /api/v1/login            → ユーザーログイン
GET  /api/v1/products         → 商品一覧
GET  /api/v1/products/{id}    → 商品詳細
POST /api/v1/orders           → 注文を作成する
GET  /api/v1/orders/{id}      → 注文の詳細
GET  /api/v1/orders           → 注文一覧

3. プロジェクトの構成

TEXT
ecommerce/
├── cmd/
│   └── server/
│       └── main.go              # エントリポイント + 依存性注入
├── internal/
│   ├── handler/                  # HTTP 層
│   │   ├── user.go
│   │   ├── product.go
│   │   ├── order.go
│   │   └── response.go          # 統一レスポンスツール
│   ├── service/                  # ビジネス層
│   │   ├── user.go
│   │   ├── product.go
│   │   ├── order.go
│   │   └── errors.go            # ビジネスエラー定義
│   └── repository/              # データ層
│       ├── user.go
│       ├── product.go
│       └── order.go
├── pkg/
│   └── model/                   # ドメインモデル
│       ├── user.go
│       ├── product.go
│       └── order.go
├── go.mod
└── go.sum

4. 完全な実装

▶ サンプル:モデル層

GO
// pkg/model/ユーザー.go
パッケージ model

type User struct {
    ID        int     `json:"id"`
    Email     文字列  `json:"email"`
    Password  文字列  `json:"-"`       // クライアントには返さない
    Name      文字列  `json:"name"`
}

type RegisterRequest struct {
    Email    文字列 `json:"email"`
    Password 文字列 `json:"password"`
    Name     文字列 `json:"name"`
}

type LoginRequest struct {
    Email    文字列 `json:"email"`
    Password 文字列 `json:"password"`
}
▶ 試してみよう
GO
// pkg/model/product.go
package model

type Product struct {
    ID          int     `json:"id"`
    Name        string  `json:"name"`
    Description string  `json:"description"`
    Price       float64 `json:"price"`
    Stock       int     `json:"stock"`
}
GO
// pkg/model/order.go
package model

type Order struct {
    ID         int       `json:"id"`
    UserID     int       `json:"user_id"`
    ProductID  int       `json:"product_id"`
    Quantity   int       `json:"quantity"`
    TotalPrice float64   `json:"total_price"`
    Status     string    `json:"status"`
}

type CreateOrderRequest struct {
    ProductID int `json:"product_id"`
    Quantity  int `json:"quantity"`
}

▶ サンプル:リポジトリ層

GO
// internal/リポジトリ/ユーザー.go
パッケージ リポジトリ

import (
    "データベース/sql"
    "ecommerce/pkg/model"
)

type UserRepository interface {
    FindByID(id int) (*model.User, エラー)
    FindByEmail(email 文字列) (*model.User, エラー)
    Create(ユーザー *model.User) エラー
}

type userRepository struct {
    db *sql.DB
}

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

func (r *userRepository) FindByID(id int) (*model.User, エラー) {
    u := &model.User{}
    err := r.db.QueryRow("SELECT id, email, password, name FROM users WHERE id = ?", id).
        Scan(&u.ID, &u.Email, &u.Password, &u.Name)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return u, err
}

func (r *userRepository) FindByEmail(email 文字列) (*model.User, エラー) {
    u := &model.User{}
    err := r.db.QueryRow("SELECT id, email, password, name FROM users WHERE email = ?", email).
        Scan(&u.ID, &u.Email, &u.Password, &u.Name)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return u, err
}

func (r *userRepository) Create(ユーザー *model.User) エラー {
    result, err := r.db.Exec(
        "INSERT INTO users (email, password, name) VALUES (?, ?, ?)",
        ユーザー.Email, ユーザー.Password, ユーザー.Name,
    )
    if err != nil {
        return err
    }
    id, _ := result.LastInsertId()
    ユーザー.ID = int(id)
    return nil
}
▶ 試してみよう
GO
// internal/repository/product.go
package repository

import (
    "database/sql"
    "ecommerce/pkg/model"
)

type ProductRepository interface {
    FindByID(id int) (*model.Product, error)
    List() ([]*model.Product, error)
    Create(product *model.Product) error
    UpdateStock(id int, stock int) error
}

type productRepository struct {
    db *sql.DB
}

func NewProductRepository(db *sql.DB) ProductRepository {
    return &productRepository{db: db}
}

func (r *productRepository) FindByID(id int) (*model.Product, error) {
    p := &model.Product{}
    err := r.db.QueryRow("SELECT id, name, description, price, stock FROM products WHERE id = ?", id).
        Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.Stock)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return p, err
}

func (r *productRepository) List() ([]*model.Product, error) {
    rows, err := r.db.Query("SELECT id, name, description, price, stock FROM products")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var products []*model.Product
    for rows.Next() {
        p := &model.Product{}
        if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.Stock); err != nil {
            return nil, err
        }
        products = append(products, p)
    }
    return products, rows.Err()
}

func (r *productRepository) Create(product *model.Product) error {
    result, err := r.db.Exec(
        "INSERT INTO products (name, description, price, stock) VALUES (?, ?, ?, ?)",
        product.Name, product.Description, product.Price, product.Stock,
    )
    if err != nil {
        return err
    }
    id, _ := result.LastInsertId()
    product.ID = int(id)
    return nil
}

func (r *productRepository) UpdateStock(id int, stock int) error {
    _, err := r.db.Exec("UPDATE products SET stock = ? WHERE id = ?", stock, id)
    return err
}
GO
// internal/repository/order.go
package repository

import (
    "database/sql"
    "ecommerce/pkg/model"
)

type OrderRepository interface {
    FindByID(id int) (*model.Order, error)
    ListByUserID(userID int) ([]*model.Order, error)
    Create(order *model.Order) error
}

type orderRepository struct {
    db *sql.DB
}

func NewOrderRepository(db *sql.DB) OrderRepository {
    return &orderRepository{db: db}
}

func (r *orderRepository) FindByID(id int) (*model.Order, error) {
    o := &model.Order{}
    err := r.db.QueryRow(
        "SELECT id, user_id, product_id, quantity, total_price, status FROM orders WHERE id = ?", id,
    ).Scan(&o.ID, &o.UserID, &o.ProductID, &o.Quantity, &o.TotalPrice, &o.Status)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return o, err
}

func (r *orderRepository) ListByUserID(userID int) ([]*model.Order, error) {
    rows, err := r.db.Query(
        "SELECT id, user_id, product_id, quantity, total_price, status FROM orders WHERE user_id = ?",
        userID,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var orders []*model.Order
    for rows.Next() {
        o := &model.Order{}
        if err := rows.Scan(&o.ID, &o.UserID, &o.ProductID, &o.Quantity, &o.TotalPrice, &o.Status); err != nil {
            return nil, err
        }
        orders = append(orders, o)
    }
    return orders, rows.Err()
}

func (r *orderRepository) Create(order *model.Order) error {
    result, err := r.db.Exec(
        "INSERT INTO orders (user_id, product_id, quantity, total_price, status) VALUES (?, ?, ?, ?, ?)",
        order.UserID, order.ProductID, order.Quantity, order.TotalPrice, order.Status,
    )
    if err != nil {
        return err
    }
    id, _ := result.LastInsertId()
    order.ID = int(id)
    return nil
}

(3) サービス層

GO
// internal/service/errors.go
パッケージ service

import "errors"

var (
    ErrEmailTaken      = errors.New("email already registered")
    ErrInvalidCreds    = errors.New("invalid email or password")
    ErrProductNotFound = errors.New("product not found")
    ErrInsufficientStock = errors.New("insufficient stock")
    ErrOrderNotFound   = errors.New("順序 not found")
    ErrUnauthorized    = errors.New("unauthorized")
)
GO
// internal/service/user.go
package service

import (
    "ecommerce/internal/repository"
    "ecommerce/pkg/model"
    "errors"
    "strings"
)

type UserService struct {
    userRepo repository.UserRepository
}

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

func (s *UserService) Register(req model.RegisterRequest) (*model.User, error) {
    if strings.TrimSpace(req.Email) == "" {
        return nil, errors.New("email is required")
    }
    if strings.TrimSpace(req.Password) == "" {
        return nil, errors.New("password is required")
    }
    if strings.TrimSpace(req.Name) == "" {
        return nil, errors.New("name is required")
    }

    existing, _ := s.userRepo.FindByEmail(req.Email)
    if existing != nil {
        return nil, ErrEmailTaken
    }

    user := &model.User{
        Email:    req.Email,
        Password: req.Password, // 本番環境は bcrypt
        Name:     req.Name,
    }

    if err := s.userRepo.Create(user); err != nil {
        return nil, err
    }

    return user, nil
}

func (s *UserService) Login(req model.LoginRequest) (*model.User, error) {
    user, err := s.userRepo.FindByEmail(req.Email)
    if err != nil {
        return nil, err
    }
    if user == nil || user.Password != req.Password {
        return nil, ErrInvalidCreds
    }
    return user, nil
}

func (s *UserService) GetUserByID(id int) (*model.User, error) {
    return s.userRepo.FindByID(id)
}
GO
// internal/service/product.go
package service

import (
    "ecommerce/internal/repository"
    "ecommerce/pkg/model"
    "errors"
)

type ProductService struct {
    productRepo repository.ProductRepository
}

func NewProductService(productRepo repository.ProductRepository) *ProductService {
    return &ProductService{productRepo: productRepo}
}

func (s *ProductService) ListProducts() ([]*model.Product, error) {
    return s.productRepo.List()
}

func (s *ProductService) GetProduct(id int) (*model.Product, error) {
    product, err := s.productRepo.FindByID(id)
    if err != nil {
        return nil, err
    }
    if product == nil {
        return nil, ErrProductNotFound
    }
    return product, nil
}
GO
// internal/service/order.go
パッケージ service

import (
    "ecommerce/internal/リポジトリ"
    "ecommerce/pkg/model"
)

type OrderService struct {
    orderRepo   リポジトリ.OrderRepository
    productRepo リポジトリ.ProductRepository
    userRepo    リポジトリ.UserRepository
}

func NewOrderService(
    orderRepo リポジトリ.OrderRepository,
    productRepo リポジトリ.ProductRepository,
    userRepo リポジトリ.UserRepository,
) *OrderService {
    return &OrderService{
        orderRepo:   orderRepo,
        productRepo: productRepo,
        userRepo:    userRepo,
    }
}

func (s *OrderService) CreateOrder(userID int, req model.CreateOrderRequest) (*model.Order, エラー) {
    if req.Quantity <= 0 {
        return nil, errors.New("quantity must be positive")
    }

    product, err := s.productRepo.FindByID(req.ProductID)
    if err != nil {
        return nil, err
    }
    if product == nil {
        return nil, ErrProductNotFound
    }
    if product.Stock < req.Quantity {
        return nil, ErrInsufficientStock
    }

    totalPrice := product.Price * float64(req.Quantity)

    順序 := &model.Order{
        UserID:     userID,
        ProductID:  req.ProductID,
        Quantity:   req.Quantity,
        TotalPrice: totalPrice,
        Status:     "pending",
    }

    if err := s.orderRepo.Create(順序); err != nil {
        return nil, err
    }

    // 在庫を減算
    newStock := product.Stock - req.Quantity
    if err := s.productRepo.UpdateStock(product.ID, newStock); err != nil {
        return nil, err
    }

    return 順序, nil
}

func (s *OrderService) GetOrder(orderID, userID int) (*model.Order, エラー) {
    順序, err := s.orderRepo.FindByID(orderID)
    if err != nil {
        return nil, err
    }
    if 順序 == nil {
        return nil, ErrOrderNotFound
    }
    if 順序.UserID != userID {
        return nil, ErrUnauthorized
    }
    return 順序, nil
}

func (s *OrderService) ListUserOrders(userID int) ([]*model.Order, エラー) {
    return s.orderRepo.ListByUserID(userID)
}

(4) ハンドラ層

GO
// internal/handler/response.go
package handler

import (
    "encoding/json"
    "net/http"
)

type APIResponse struct {
    Data  interface{} `json:"data,omitempty"`
    Error string      `json:"error,omitempty"`
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse{Data: data})
}

func writeError(w http.ResponseWriter, status int, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(APIResponse{Error: msg})
}
GO
// internal/handler/user.go
package handler

import (
    "encoding/json"
    "errors"
    "net/http"
    "ecommerce/internal/service"
    "ecommerce/pkg/model"
)

type UserHandler struct {
    userSvc *service.UserService
}

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

func (h *UserHandler) Register(mux *http.ServeMux) {
    mux.HandleFunc("POST /api/v1/register", h.RegisterUser)
    mux.HandleFunc("POST /api/v1/login", h.LoginUser)
}

func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
    var req model.RegisterRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    user, err := h.userSvc.Register(req)
    if errors.Is(err, service.ErrEmailTaken) {
        writeError(w, http.StatusConflict, err.Error())
        return
    }
    if err != nil {
        writeError(w, http.StatusBadRequest, err.Error())
        return
    }

    writeJSON(w, http.StatusCreated, user)
}

func (h *UserHandler) LoginUser(w http.ResponseWriter, r *http.Request) {
    var req model.LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    user, err := h.userSvc.Login(req)
    if errors.Is(err, service.ErrInvalidCreds) {
        writeError(w, http.StatusUnauthorized, err.Error())
        return
    }
    if err != nil {
        writeError(w, http.StatusInternalServerError, "login failed")
        return
    }

    writeJSON(w, http.StatusOK, user)
}
GO
// internal/handler/product.go
パッケージ handler

import (
    "errors"
    "net/http"
    "strconv"
    "ecommerce/internal/service"
)

type ProductHandler struct {
    productSvc *service.ProductService
}

func NewProductHandler(productSvc *service.ProductService) *ProductHandler {
    return &ProductHandler{productSvc: productSvc}
}

func (h *ProductHandler) Register(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/v1/products", h.ListProducts)
    mux.HandleFunc("GET /api/v1/products/{id}", h.GetProduct)
}

func (h *ProductHandler) ListProducts(w http.ResponseWriter, r *http.Request) {
    products, err := h.productSvc.ListProducts()
    if err != nil {
        writeError(w, http.StatusInternalServerError, "failed to list products")
        return
    }
    writeJSON(w, http.StatusOK, products)
}

func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid product ID")
        return
    }

    product, err := h.productSvc.GetProduct(id)
    if errors.Is(err, service.ErrProductNotFound) {
        writeError(w, http.StatusNotFound, err.Error())
        return
    }
    if err != nil {
        writeError(w, http.StatusInternalServerError, "failed to get product")
        return
    }

    writeJSON(w, http.StatusOK, product)
}
GO
// internal/handler/order.go
package handler

import (
    "encoding/json"
    "errors"
    "net/http"
    "strconv"
    "ecommerce/internal/service"
    "ecommerce/pkg/model"
)

type OrderHandler struct {
    orderSvc *service.OrderService
}

func NewOrderHandler(orderSvc *service.OrderService) *OrderHandler {
    return &OrderHandler{orderSvc: orderSvc}
}

func (h *OrderHandler) Register(mux *http.ServeMux) {
    mux.HandleFunc("POST /api/v1/orders", h.CreateOrder)
    mux.HandleFunc("GET /api/v1/orders", h.ListOrders)
    mux.HandleFunc("GET /api/v1/orders/{id}", h.GetOrder)
}

// シミュレーション:Context から userID を取得(第2課で JWT 実装)
func getUserID(r *http.Request) int {
    if id, ok := r.Context().Value("user_id").(int); ok {
        return id
    }
    return 1 // デフォルトユーザー(第1課の仮実装)
}

func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    userID := getUserID(r)

    var req model.CreateOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    order, err := h.orderSvc.CreateOrder(userID, req)
    if errors.Is(err, service.ErrProductNotFound) {
        writeError(w, http.StatusNotFound, err.Error())
        return
    }
    if errors.Is(err, service.ErrInsufficientStock) {
        writeError(w, http.StatusConflict, err.Error())
        return
    }
    if err != nil {
        writeError(w, http.StatusBadRequest, err.Error())
        return
    }

    writeJSON(w, http.StatusCreated, order)
}

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    userID := getUserID(r)
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid order ID")
        return
    }

    order, err := h.orderSvc.GetOrder(id, userID)
    if errors.Is(err, service.ErrOrderNotFound) {
        writeError(w, http.StatusNotFound, err.Error())
        return
    }
    if errors.Is(err, service.ErrUnauthorized) {
        writeError(w, http.StatusForbidden, err.Error())
        return
    }
    if err != nil {
        writeError(w, http.StatusInternalServerError, "failed to get order")
        return
    }

    writeJSON(w, http.StatusOK, order)
}

func (h *OrderHandler) ListOrders(w http.ResponseWriter, r *http.Request) {
    userID := getUserID(r)
    orders, err := h.orderSvc.ListUserOrders(userID)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "failed to list orders")
        return
    }
    writeJSON(w, http.StatusOK, orders)
}

(5) メイン(依存性注入)

GO
package main

import (
    "database/sql"
    "log"
    "net/http"

    "ecommerce/internal/handler"
    "ecommerce/internal/repository"
    "ecommerce/internal/service"

    _ "github.com/mattn/go-sqlite3"
)

func main() {
    db, err := sql.Open("sqlite3", "./ecommerce.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    initDB(db)

    // 依存性注入
    userRepo := repository.NewUserRepository(db)
    productRepo := repository.NewProductRepository(db)
    orderRepo := repository.NewOrderRepository(db)

    userSvc := service.NewUserService(userRepo)
    productSvc := service.NewProductService(productRepo)
    orderSvc := service.NewOrderService(orderRepo, productRepo, userRepo)

    userHandler := handler.NewUserHandler(userSvc)
    productHandler := handler.NewProductHandler(productSvc)
    orderHandler := handler.NewOrderHandler(orderSvc)

    mux := http.NewServeMux()
    userHandler.Register(mux)
    productHandler.Register(mux)
    orderHandler.Register(mux)

    log.Println("Eコマース API 開始日: :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

func initDB(db *sql.DB) {
    schema := `
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT UNIQUE NOT NULL,
        password TEXT NOT NULL,
        name TEXT NOT NULL
    );
    CREATE TABLE IF NOT EXISTS products (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        description TEXT,
        price REAL NOT NULL,
        stock INTEGER NOT NULL DEFAULT 0
    );
    CREATE TABLE IF NOT EXISTS orders (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        product_id INTEGER NOT NULL,
        quantity INTEGER NOT NULL,
        total_price REAL NOT NULL,
        status TEXT NOT NULL DEFAULT 'pending',
        FOREIGN KEY (user_id) REFERENCES users(id),
        FOREIGN KEY (product_id) REFERENCES products(id)
    );`
    if _, err := db.Exec(schema); err != nil {
        log.Fatal("initDB:", err)
    }
}

(6) ユニットテスト(モックリポジトリ)

GO
// internal/service/order_test.go
パッケージ service

import (
    "testing"
    "ecommerce/pkg/model"
)

// mock Repository
type mockProductRepo struct {
    products map[int]*model.Product
}

func (m *mockProductRepo) FindByID(id int) (*model.Product, エラー) {
    return m.products[id], nil
}

func (m *mockProductRepo) List() ([]*model.Product, エラー) {
    var result []*model.Product
    for _, p := range m.products {
        result = append(result, p)
    }
    return result, nil
}

func (m *mockProductRepo) Create(product *model.Product) エラー {
    product.ID = len(m.products) + 1
    m.products[product.ID] = product
    return nil
}

func (m *mockProductRepo) UpdateStock(id int, stock int) エラー {
    if p, ok := m.products[id]; ok {
        p.Stock = stock
    }
    return nil
}

type mockOrderRepo struct {
    orders []*model.Order
}

func (m *mockOrderRepo) FindByID(id int) (*model.Order, エラー) {
    for _, o := range m.orders {
        if o.ID == id {
            return o, nil
        }
    }
    return nil, nil
}

func (m *mockOrderRepo) ListByUserID(userID int) ([]*model.Order, エラー) {
    var result []*model.Order
    for _, o := range m.orders {
        if o.UserID == userID {
            result = append(result, o)
        }
    }
    return result, nil
}

func (m *mockOrderRepo) Create(順序 *model.Order) エラー {
    順序.ID = len(m.orders) + 1
    m.orders = append(m.orders, 順序)
    return nil
}

func TestCreateOrder_Success(t *testing.T) {
    productRepo := &mockProductRepo{
        products: map[int]*model.Product{
            1: {ID: 1, Name: "Laptop", Price: 999.99, Stock: 10},
        },
    }
    orderRepo := &mockOrderRepo{}
    svc := NewOrderService(orderRepo, productRepo, nil)

    順序, err := svc.CreateOrder(1, model.CreateOrderRequest{ProductID: 1, Quantity: 2})
    if err != nil {
        t.Fatalf("expected no エラー, got %v", err)
    }
    if 順序.TotalPrice != 1999.98 {
        t.Errorf("expected total 1999.98, got %.2f", 順序.TotalPrice)
    }
}

func TestCreateOrder_InsufficientStock(t *testing.T) {
    productRepo := &mockProductRepo{
        products: map[int]*model.Product{
            1: {ID: 1, Name: "Laptop", Price: 999.99, Stock: 1},
        },
    }
    orderRepo := &mockOrderRepo{}
    svc := NewOrderService(orderRepo, productRepo, nil)

    _, err := svc.CreateOrder(1, model.CreateOrderRequest{ProductID: 1, Quantity: 5})
    if err != ErrInsufficientStock {
        t.Errorf("expected ErrInsufficientStock, got %v", err)
    }
}

func TestCreateOrder_ProductNotFound(t *testing.T) {
    productRepo := &mockProductRepo{products: make(map[int]*model.Product)}
    orderRepo := &mockOrderRepo{}
    svc := NewOrderService(orderRepo, productRepo, nil)

    _, err := svc.CreateOrder(1, model.CreateOrderRequest{ProductID: 999, Quantity: 1})
    if err != ErrProductNotFound {
        t.Errorf("expected ErrProductNotFound, got %v", err)
    }
}
💡 ヒント: インターフェース(例:type UserRepository interface{...})を使用してリポジトリ層を定義することで、テストにモック実装を注入することができます。これはGoの暗黙的インターフェースの大きな利点です。モックフレームワークを必要とせず、手作業で軽量なモックを簡単に作成できるからです。


❓ よくある質問

Q 4層モデルは実際のプロジェクトでどのように実装されていますか?
A まず、モデル(データ構造)→リポジトリ(データベースのCRUD)→サービス(ビジネスルール)→ハンドラー(HTTP)の順で実装します。各ファイルは200行を超えないようにします。ファイルはリソース(ユーザー/製品/注文)ごとに整理されます。メイン関数は、すべての依存関係を組み立てる役割を担います(依存性注入)。
Q ユニットテスト用のモックテーブルはどのように設計すべきですか?
A インターフェースを使用してリポジトリを定義し、テスト時にはモック実装を渡します。モックデータはマップを使用してメモリ内に格納します。サービス層をテストする際はリポジトリをモック化し、ハンドラ層をテストする際はサービスをモック化します。モックフレームワークは必要ありません。Goの暗黙的なインターフェースにより、手動でのモック化は非常に軽量に行えます。
Q エラー処理にはどのような3つのスタイルがありますか?
A (1) カスタムエラー型(Code/Message/HTTPStatusを含む) — 大規模なプロジェクトに適しています;(2) センチネルエラー (var ErrXxx = errors.New(...)) — 中規模のプロジェクトに適しています;(3) 文字列エラー — 小規模なプロジェクトにのみ適しています。このプロジェクトではセンチネルエラーを採用しています。
Q RESTfulリソース設計表とは何ですか?
A ユーザー(登録/ログイン)、商品(一覧/詳細)、注文(作成/一覧/詳細)です。各リソースには、それぞれ独自のハンドラー、サービス、リポジトリがあります。URLには複数形の名詞が使用されます:/api/v1/users/api/v1/products/api/v1/orders
Q 検証ライブラリの比較は?
A (1) 手動による検証—このプロジェクトでは、チェックに strings.TrimSpace を使用しています—依存関係はゼロです;(2) go-playground/validator—タグベースの検証で、非常に強力です;(3) カスタム構造体 Validator。小規模なプロジェクトであれば、手動による検証で十分です。

📖 まとめ


📝 練習問題

  1. 基本演習(難易度 ⭐):このレッスンのコード全体を実行し、curl を使用して、登録、ログイン、商品一覧、注文作成などのエンドポイントをテストしてください。すべての CRUD 操作が正しく動作することを確認してください。

  2. 上級演習(難易度 ⭐⭐):システムのサービス層に完全なユニットテストを追加してください。要件:(1) UserService.Register における「重複するメールアドレス」のシナリオを網羅すること;(2) ProductService.GetProduct における「メールアドレスが見つからない」シナリオを網羅すること; (3) OrderService.GetOrder における「権限なし」のシナリオを網羅すること;(4) テストカバレッジが 80% 以上であること。

  3. 課題(難易度:⭐⭐⭐):システムにCategoryリソースを追加してください。要件:(1) ProductモデルにCategoryIDフィールドを追加すること;(2) Categoryに対するCRUD操作を実装すること(管理者機能); (3) カテゴリでフィルタリングするためのエンドポイント GET /products?category_id=X を実装すること; (4) サービス層でカテゴリの存在確認を行うこと; (5) 完全なユニットテストを用意すること。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%