Go: Goプロジェクトのアーキテクチャ:標準レイアウト、クリーンアーキテクチャの4層モデル、依存性注入
最終更新:2026-08-26
Goには、フレームワークによって強制されるプロジェクト構造はありません。しかし、それはつまり、自分で設計しなければならないということです。よく考え抜かれたアーキテクチャがあれば、プロジェクトが1,000行になっても構造が明確であり続け、100,000行に達しても管理しやすさを維持できます。
コードがすべてmain.goに詰め込まれているプロジェクトを引き継いだ場合、リファクタリングの最初のステップは、そのコードを適切なディレクトリに移動することです。
1. 学習内容
- Goプロジェクトの標準的なディレクトリ構造:
cmd/、internal/、pkg/ - クリーンアーキテクチャの4層モデル
- 依存性注入(コンストラクタ注入)
- 型定義が不正です
- プロジェクトの枠組みの構築
2. バックエンドエンジニアの実話
(1) 課題:10万行のコードがすべて main.go に含まれている
アリスはあるEコマースプロジェクトを引き受けた:
「前任者は、ルート登録、データベース操作、HTMLテンプレート、ビジネスロジックといったすべてのコードを
main.goに詰め込んでいました。3万行ものコードがごちゃ混ぜになっていたのです。ユーザーのステータスフィールドを追加しようとしたとき、正しく動作させるために5カ所も変更を加える必要がありました。機能の追加に2週間かかり、コードを探すだけで3日も費やしてしまいました。」
// Bad code: everything piled into main.go
package main
var db *sql.DB
func main() {
// Database connection
// Route registration
// HTML templates
// User handler
// Order handler
// Product handler
// All mixed together!
}
// To find "user" related code: Ctrl+F search for "user", scattered across 50 places
(2) Goの解決策:4層アーキテクチャ
myapp/
├── cmd/
│ └── server/
│ └── main.go # Entry → dependency injection + start service
├── internal/
│ ├── handler/ # Layer 1: HTTP handlers (parse request / return response)
│ ├── service/ # Layer 2: Business logic (domain rules)
│ └── repository/ # Layer 3: Data access (database / external API)
├── pkg/
│ └── model/ # Layer 4: Domain models (data structures)
└── go.mod
(3) 回帰:カオス対層化
| シナリオ | カオス型アーキテクチャ | 4層アーキテクチャ |
|---|---|---|
| 新規フィールド | 位置不明の変更が5件 | モデルとハンドラーのみの変更 |
| データベースの切り替え | すべてのデータベース呼び出しを更新 | リポジトリ層のみを更新 |
| ユニットテスト | ユニットテストが実行できない | モックを使用すれば、各レイヤーを個別にテストできる |
| 初心者向け入門 | 30,000行にわたるmain.goをざっと見てみる | ディレクトリ名を見るだけで役割を理解する |
3. 標準的なプロジェクト構成
(1) ディレクトリ構造の詳細な説明
myproject/
├── cmd/ # Executable entry points
│ ├── server/ # server binary
│ │ └── main.go
│ └── migrate/ # database migration tool
│ └── main.go
├── internal/ # Private packages (cannot be imported externally)
│ ├── handler/ # HTTP handlers
│ ├── service/ # Business logic
│ └── repository/ # Data access
├── pkg/ # Exportable public packages
│ └── model/ # Domain models
├── migrations/ # SQL migration files
├── config/ # Configuration files
├── scripts/ # Helper scripts
├── go.mod
└── go.sum
(2) cmd/ / internal/ / pkg/ の担当範囲
| ディレクトリ | 用途 | 外部からのインポートが可能 |
|---|---|---|
cmd/ |
実行可能エントリポイント(main関数) | 該当なし(メインパッケージであるため) |
internal/ |
プライベート実装 | ❌ Goコンパイラでは外部からのインポートが禁止されています |
pkg/ |
外部に公開されているコード | ✅ |
internal ディレクトリは Go コンパイラ専用のディレクトリであり、internal の親ディレクトリ外にあるパッケージからはこれをインポートすることはできません。これにより真のカプセル化が実現され、「命名規則」(_private など)よりも安全です。
4. クリーンアーキテクチャの4層モデル
handler (HTTP) → service (business) → repository (data)
↓ ↓ ↓
Request parsing Domain rules Database/API
Response return Transaction mgmt CRUD operations
Param validation Multi-step orchestration Cache access
▶ サンプル:4層構成の実装
// ---------- Layer 4: Model (domain models) ----------
// pkg/model/user.go
package model
type User struct {
ID int
Name string
Email string
CreatedAt time.Time
}
type CreateUserRequest struct {
Name string
Email string
}
// ---------- Layer 3: Repository (data access) ----------
// internal/repository/user.go
package repository
import (
"database/sql"
"myapp/pkg/model"
)
type UserRepository interface {
FindByID(id int) (*model.User, error)
Create(req model.CreateUserRequest) (*model.User, error)
List() ([]*model.User, error)
Delete(id int) error
}
type userRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindByID(id int) (*model.User, error) {
row := r.db.QueryRow("SELECT id, name, email, created_at FROM users WHERE id = ?", id)
user := &model.User{}
err := row.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return user, err
}
func (r *userRepository) Create(req model.CreateUserRequest) (*model.User, error) {
result, err := r.db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", req.Name, req.Email)
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return r.FindByID(int(id))
}
// ---------- Layer 2: Service (business logic) ----------
// internal/service/user.go
package service
import (
"errors"
"myapp/internal/repository"
"myapp/pkg/model"
"strings"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidName = errors.New("name is required")
ErrInvalidEmail = errors.New("invalid email format")
)
type UserService struct {
repo repository.UserRepository
}
func NewUserService(repo repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Create(req model.CreateUserRequest) (*model.User, error) {
if strings.TrimSpace(req.Name) == "" {
return nil, ErrInvalidName
}
if !strings.Contains(req.Email, "@") {
return nil, ErrInvalidEmail
}
return s.repo.Create(req)
}
func (s *UserService) Get(id int) (*model.User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
// ---------- Layer 1: Handler (HTTP handler) ----------
// internal/handler/user.go
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"myapp/internal/service"
"myapp/pkg/model"
)
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req model.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
user, err := h.svc.Create(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, user)
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
user, err := h.svc.Get(id)
if errors.Is(err, service.ErrUserNotFound) {
writeError(w, http.StatusNotFound, err.Error())
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, user)
}
// ---------- Utility functions ----------
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
(2) 依存関係の方向
Handler → Service → Repository (→ DB)
| | |
↓ ↓ ↓
Model Model Model
| レイヤー | 役割 | 依存関係 | テスト可能 |
|---|---|---|---|
| ハンドラー | HTTPの解析・応答 | サービス | モックサービス |
| サービス | ビジネスルール/オーケストレーション | リポジトリ | モックリポジトリ |
| リポジトリ | データのCRUD | データベース/SQL | 模擬データベース / 統合テスト |
| モデル | データ構造 | なし | — |
5. 依存性注入
▶ サンプル:コンストラクタ注入
⚙️ 前提条件:
go get github.com/mattn/go-sqlite3を実行する(CGO が必要。純粋な Go ドライバーを使用する場合は、代わりにmodernc.org/sqliteを使用してください)
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
"myapp/internal/handler"
"myapp/internal/repository"
"myapp/internal/service"
)
func main() {
// ---- Dependency injection (assemble all layers) ----
// 1. Database connection
db, err := sql.Open("sqlite3", "./app.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 2. Repository layer
userRepo := repository.NewUserRepository(db)
orderRepo := repository.NewOrderRepository(db)
// 3. Service layer (depends on Repository)
userSvc := service.NewUserService(userRepo)
orderSvc := service.NewOrderService(orderRepo, userRepo)
// 4. Handler layer (depends on Service)
userHandler := handler.NewUserHandler(userSvc)
orderHandler := handler.NewOrderHandler(orderSvc)
// 5. Route registration
mux := http.NewServeMux()
userHandler.Register(mux, "/api/v1/users")
orderHandler.Register(mux, "/api/v1/orders")
log.Println("Service listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
(2) 依存性注入手法の比較
| 手法 | 実装 | 利点 | 欠点 |
|---|---|---|---|
| コンストラクタ注入 | NewXxx(dep) |
明示的、コンパイル時チェック | 依存関係によりコードが長くなる |
| 手動での実装 | メイン関数の組み立て | サードパーティ製ライブラリは不要 | 大規模なプロジェクトではメンテナンスが煩雑 |
| Google Wire | コード生成 | 自動挿入 | 学習曲線 |
github.com/google/wire) を使用して、依存性注入コードを自動生成することを検討してください。
6. 完全な例:Eコマース・プロジェクトのフレームワーク
▶ サンプル:完全な実装
// cmd/server/main.go
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/mattn/go-sqlite3"
)
// ---------- Model (pkg/model) ----------
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
type Order struct {
ID int `json:"id"`
UserID int `json:"user_id"`
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
Total float64 `json:"total"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ---------- Repository (internal/repository) ----------
type ProductRepository struct {
db *sql.DB
}
func NewProductRepository(db *sql.DB) *ProductRepository {
return &ProductRepository{db: db}
}
func (r *ProductRepository) FindByID(id int) (*Product, error) {
p := &Product{}
err := r.db.QueryRow("SELECT id, name, price FROM products WHERE id = ?", id).
Scan(&p.ID, &p.Name, &p.Price)
if err == sql.ErrNoRows {
return nil, nil
}
return p, err
}
func (r *ProductRepository) List() ([]*Product, error) {
rows, err := r.db.Query("SELECT id, name, price FROM products")
if err != nil {
return nil, err
}
defer rows.Close()
var products []*Product
for rows.Next() {
p := &Product{}
if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil {
return nil, err
}
products = append(products, p)
}
return products, rows.Err()
}
type OrderRepository struct {
db *sql.DB
}
func NewOrderRepository(db *sql.DB) *OrderRepository {
return &OrderRepository{db: db}
}
func (r *OrderRepository) Create(order *Order) error {
result, err := r.db.Exec(
"INSERT INTO orders (user_id, product_id, quantity, total, status) VALUES (?, ?, ?, ?, ?)",
order.UserID, order.ProductID, order.Quantity, order.Total, order.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
order.ID = int(id)
return nil
}
func (r *OrderRepository) FindByID(id int) (*Order, error) {
o := &Order{}
err := r.db.QueryRow(
"SELECT id, user_id, product_id, quantity, total, status, created_at FROM orders WHERE id = ?", id,
).Scan(&o.ID, &o.UserID, &o.ProductID, &o.Quantity, &o.Total, &o.Status, &o.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return o, err
}
// ---------- Service (internal/service) ----------
type OrderService struct {
productRepo *ProductRepository
orderRepo *OrderRepository
}
func NewOrderService(productRepo *ProductRepository, orderRepo *OrderRepository) *OrderService {
return &OrderService{
productRepo: productRepo,
orderRepo: orderRepo,
}
}
type PlaceOrderInput struct {
UserID int
ProductID int
Quantity int
}
var (
ErrProductNotFound = &AppError{Code: "PRODUCT_NOT_FOUND", Message: "product not found", HTTPStatus: 404}
ErrInsufficientStock = &AppError{Code: "INSUFFICIENT_STOCK", Message: "insufficient stock", HTTPStatus: 409}
)
type AppError struct {
Code string `json:"code"`
Message string `json:"message"`
HTTPStatus int `json:"-"`
}
func (e *AppError) Error() string {
return e.Message
}
func (s *OrderService) PlaceOrder(input PlaceOrderInput) (*Order, error) {
product, err := s.productRepo.FindByID(input.ProductID)
if err != nil {
return nil, err
}
if product == nil {
return nil, ErrProductNotFound
}
total := product.Price * float64(input.Quantity)
order := &Order{
UserID: input.UserID,
ProductID: input.ProductID,
Quantity: input.Quantity,
Total: total,
Status: "created",
}
if err := s.orderRepo.Create(order); err != nil {
return nil, err
}
return order, nil
}
// ---------- Handler (internal/handler) ----------
type OrderHandler struct {
svc *OrderService
}
func NewOrderHandler(svc *OrderService) *OrderHandler {
return &OrderHandler{svc: svc}
}
func (h *OrderHandler) Register(mux *http.ServeMux, basePath string) {
mux.HandleFunc("POST "+basePath, h.PlaceOrder)
mux.HandleFunc("GET "+basePath+"/{id}", h.GetOrder)
}
type placeOrderRequest struct {
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
}
func (h *OrderHandler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
// Get user ID from Context (injected by auth middleware)
userID, ok := r.Context().Value("user_id").(int)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var req placeOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
order, err := h.svc.PlaceOrder(PlaceOrderInput{
UserID: userID,
ProductID: req.ProductID,
Quantity: req.Quantity,
})
if appErr, ok := err.(*AppError); ok {
writeError(w, appErr.HTTPStatus, appErr.Message)
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, order)
}
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
// ... business logic
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------- Main (dependency injection entry) ----------
func main() {
db, err := sql.Open("sqlite3", "./shop.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Initialize tables
db.Exec(`CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, price REAL
)`)
db.Exec(`CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER,
product_id INTEGER, quantity INTEGER, total REAL,
status TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
// Dependency injection
productRepo := NewProductRepository(db)
orderRepo := NewOrderRepository(db)
orderSvc := NewOrderService(productRepo, orderRepo)
orderHandler := NewOrderHandler(orderSvc)
mux := http.NewServeMux()
orderHandler.Register(mux, "/api/v1/orders")
// Health check
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Graceful shutdown
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
log.Println("E-commerce service listening on :8080")
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}
graph TB
subgraph Handler
H[HTTP Handler]
end
subgraph Service
S[Business Logic]
end
subgraph Repository
R[Data Access]
end
subgraph Model
M[Domain Structs]
end
subgraph External ["External Dependencies"]
DB[(Database)]
end
H -->|calls| S
S -->|calls| R
R -->|queries| DB
H --- M
S --- M
R --- M
style Handler fill:#e1f5fe
style Service fill:#fff3e0
style Repository fill:#e8f5e9
style Model fill:#f3e5f5
❓ よくある質問
NewXxx(dep1, dep2) *Xxx。すべての依存関係は、main関数(またはWireによって生成されたコード)内で作成・組み立てられます。利点:(1) 依存関係の明確化、(2) コンパイル時のチェック、(3) ユニットテスト時にモックを渡すことができること。internal ディレクトリを使うのですか?internal ディレクトリ内のパッケージが、その親ディレクトリ内のコードからのみインポートされるように強制します。これにより、真のカプセル化が実現され、外部のユーザーは internal パッケージをインポートできなくなります。大規模なプロジェクトでは、これによりアーキテクチャ上の違反(例えば、handlersがrepositoryに直接インポートされるようなケース)を防ぐことができます。📖 まとめ
- 標準レイアウト:
cmd/(エントリポイント)、internal/(プライベート)、pkg/(パブリック) - 4層モデル:ハンドラー → サービス → リポジトリ → モデル
- 依存関係の方向:外側から内側へ。内側の層は外側の層の存在を認識していない
- 依存性注入:コンストラクタ注入(最も一般的)
- インターフェースの分離:Goの暗黙的インターフェースにより、依存性の反転が自然に実現される
- エラーの種類:コード/メッセージ/HTTPステータスを含むカスタム AppError
internalディレクトリ:Go コンパイラによって強制されるカプセル化
📝 練習問題
-
基本 (難易度 ⭐): 以下のディレクトリを含むプロジェクトの骨格を作成してください:cmd/server/main.go、internal/handler/、internal/service/、internal/repository/、および pkg/model/。
HealthHandlerを返す単純な{"status": "ok"}を実装してください。 -
上級(難易度 ⭐⭐):4層アーキテクチャを用いて商品カテゴリ管理を実装してください。要件:(1) モデル:Category(id, name, parent_id); (2) ハンドラー:CRUDエンドポイント; (3) サービス:カテゴリ名が一意であることを検証し、循環参照を防止する; (4) リポジトリ:SQLiteを使用して実装; (5)
main関数が、コンストラクタ注入によってコンポーネントを組み立てる。 -
課題(難易度 ⭐⭐⭐):第22課のライブラリ管理システムを、4層アーキテクチャにリファクタリングしてください。要件:(1) コードを
cmd/internal/pkgディレクトリに移動すること;(2) ハンドラ層はHTTPのみを処理すること;(3) サービス層には完全なビジネスバリデーションを含めること; (4) リポジトリ層ではSQLiteを使用すること;(5) モックテストを可能にするため、リポジトリをインターフェースとして公開すること;(6) サービス層のビジネスルールをテストするためのユニットテスト(モックリポジトリを使用)を作成すること。