EコマースAPI(第1部):モデル設計とCRUD
理論の説明は以上です。それでは、Goを使って実際のEC APIをゼロから構築していきましょう。
ボブは、独自のECプラットフォームを立ち上げることにした。3週間、3つのストーリー、そしてGoで構築された完全なバックエンド。
1. 学習内容
- EコマースAPIの要件分析およびアーキテクチャ設計
- 4層アーキテクチャ(モデル → リポジトリ → サービス → ハンドラー)の完全な実装
- CRUDエンドポイント + パラメータの検証
- カスタムエラータイプ + 一元化されたエラー処理
- 単体テスト(モックリポジトリ)
2. ストーリー:ボブの起業家としての初日
(1) 課題:「まずはMVPをビルドして、2週間後にリリースしよう。」
ボブは仕事を辞め、自身のビジネス――Eコマースプラットフォーム――を立ち上げた:
「初日、私は
main.goにすべてのロジックを詰め込んだモノリシックなシステムを構築しました。7日目にはコードが3,000行にまで膨れ上がり、どの部分が順序ロジックで、どの部分がユーザーロジックなのか見分けがつかなくなってしまいました。『クーポン』機能を追加しようとしたのですが、6つのファイルを修正してもまだ完了しませんでした。ビジネスパートナーは『2週間でリリースしよう』と言っていたが、2ヶ月でも厳しいかもしれない。」
しかし、落ち着いてGoの4層アーキテクチャについて考え直した結果、彼はコードのリファクタリングを行うことに決めた:
// 再構築計画:
// cmd/server/ → エントリポイント
// internal/handler/ → HTTP プロセッサ
// internal/service/ → ビジネスロジック
// internal/repository/ → データへのアクセス
// pkg/model/ → ドメインモデル
(2) 学習目標:ユーザー、商品、注文に関する基本的なCRUD操作
第1週の目標:完全なフレームワークを構築し、3つの主要リソースに対してCRUD操作を実装する。
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. プロジェクトの構成
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. 完全な実装
▶ サンプル:モデル層
// 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"`
}
// 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"`
}
// 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"`
}
▶ サンプル:リポジトリ層
// 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
}
// 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
}
// 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) サービス層
// 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")
)
// 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)
}
// 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
}
// 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) ハンドラ層
// 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})
}
// 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)
}
// 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)
}
// 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) メイン(依存性注入)
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) ユニットテスト(モックリポジトリ)
// 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の暗黙的インターフェースの大きな利点です。モックフレームワークを必要とせず、手作業で軽量なモックを簡単に作成できるからです。
❓ よくある質問
var ErrXxx = errors.New(...)) — 中規模のプロジェクトに適しています;(3) 文字列エラー — 小規模なプロジェクトにのみ適しています。このプロジェクトではセンチネルエラーを採用しています。/api/v1/users、/api/v1/products、/api/v1/orders。strings.TrimSpace を使用しています—依存関係はゼロです;(2) go-playground/validator—タグベースの検証で、非常に強力です;(3) カスタム構造体 Validator。小規模なプロジェクトであれば、手動による検証で十分です。📖 まとめ
- EコマースAPIフレームワーク:3つの主要リソース――ユーザー、商品、注文
- 4層アーキテクチャ:モデル → リポジトリ → サービス → ハンドラー
- リポジトリインターフェース:モックテストを容易にする
- サービスビジネスルール:検証 → クエリ → 計算 → 書き込み
- HTTPハンドラー:JSONの解析 + エラーのマッピング + 一元化されたレスポンス
- 単体テスト:サービスのビジネスロジックをテストするためのリポジトリのモック化
- 入力ファイル
main: すべてのレイヤーを組み立てるための依存性注入
📝 練習問題
-
基本演習(難易度 ⭐):このレッスンのコード全体を実行し、
curlを使用して、登録、ログイン、商品一覧、注文作成などのエンドポイントをテストしてください。すべての CRUD 操作が正しく動作することを確認してください。 -
上級演習(難易度 ⭐⭐):システムのサービス層に完全なユニットテストを追加してください。要件:(1) UserService.Register における「重複するメールアドレス」のシナリオを網羅すること;(2)
ProductService.GetProductにおける「メールアドレスが見つからない」シナリオを網羅すること; (3)OrderService.GetOrderにおける「権限なし」のシナリオを網羅すること;(4) テストカバレッジが 80% 以上であること。 -
課題(難易度:⭐⭐⭐):システムにCategoryリソースを追加してください。要件:(1) ProductモデルにCategoryIDフィールドを追加すること;(2) Categoryに対するCRUD操作を実装すること(管理者機能); (3) カテゴリでフィルタリングするためのエンドポイント
GET /products?category_id=Xを実装すること; (4) サービス層でカテゴリの存在確認を行うこと; (5) 完全なユニットテストを用意すること。



