Goプロジェクトのアーキテクチャ
Goには、フレームワークによって強制されるプロジェクト構造はありません。しかし、それはつまり、自分で設計しなければならないということです。よく考え抜かれたアーキテクチャがあれば、プロジェクトが1,000行になっても構造が明確であり続け、100,000行に達しても管理しやすさを維持できます。
コードがすべてmain.goに詰め込まれているプロジェクトを引き継いだ場合、リファクタリングの最初のステップは、そのコードを適切なディレクトリに移動することです。
1. 学習内容
- Goプロジェクトの標準的なディレクトリ構造:
cmd/、internal/、pkg/ - クリーンアーキテクチャの4層モデル
- 依存性注入(コンストラクタ注入)
- 型定義が不正です
- プロジェクトの枠組みの構築
2. バックエンドエンジニアの実話
(1) 課題:10万行のコードがすべて main.go に含まれている
アリスはあるEコマースプロジェクトを引き受けた:
「前任者は、ルート登録、データベース操作、HTMLテンプレート、ビジネスロジックといったすべてのコードを
main.goに詰め込んでいました。3万行ものコードがごちゃ混ぜになっていたのです。ユーザーステータスのフィールドを追加しようとしたとき、正しく動作させるために5カ所も変更を加える必要がありました。機能1つを追加するのに2週間かかり、コードを探すだけで3日も費やしてしまいました。」
// 悪いコード:すべてを main.go に詰め込む
package main
var db *sql.DB
func main() {
// データベース接続
// ルーティング登録
// HTML テンプレート
// ユーザー処理関数
// 注文処理関数
// 商品処理関数
// すべて混在!
}
// 「ユーザー」関連コードを探す:Ctrl+F で "user" を検索、50 箇所に散らばっている
(2) Goのソリューション:4層アーキテクチャ
myapp/
├── cmd/
│ └── server/
│ └── main.go # エントリポイント → 依存性注入 + サービス起動
├── internal/
│ ├── handler/ # 層 1:HTTP ハンドラ(リクエストの解析/レスポンスを返す)
│ ├── service/ # 層 2:ビジネスロジック(ドメインルール)
│ └── repository/ # 層 3:データアクセス(データベース/外部 API)
├── pkg/
│ └── model/ # 層 4:ドメインモデル(データ構造)
└── go.mod
(3) 回帰:カオス対層化
| シナリオ | カオス型アーキテクチャ | 4層アーキテクチャ |
|---|---|---|
| 新規フィールド | 場所不明の変更が5件 | モデルとハンドラーのみの変更 |
| データベースの切り替え | すべてのデータベース呼び出しを更新 | リポジトリ層のみを更新 |
| ユニットテスト | ユニットテストが実行できない | モックを使用すれば各レイヤーを個別にテストできる |
| 初心者向け入門 | 30,000行にわたるmain.goをざっと見てみる | ディレクトリ名を見るだけで役割を理解する |
3. 標準的なプロジェクト構成
(1) ディレクトリ構造の詳細な説明
myproject/
├── cmd/ # 実行ファイルのエントリポイント
│ ├── server/ # server バイナリ
│ │ └── main.go
│ └── migrate/ # データベースマイグレーションツール
│ └── main.go
├── internal/ # プライベートパッケージ(外部からのインポート不可)
│ ├── handler/ # HTTP プロセッサ
│ ├── service/ # ビジネスロジック
│ └── repository/ # データへのアクセス
├── pkg/ # エクスポート可能な共有パッケージ
│ └── model/ # ドメインモデル
├── migrations/ # SQL マイグレーションファイル
├── config/ # プロファイル
├── scripts/ # 補助スクリプト
├── go.mod
└── go.sum
(2) cmd/ / internal/ / pkg/ の担当範囲
| 目次 | 用途 | 外部からのインポートが可能 |
|---|---|---|
cmd/ |
実行可能エントリポイント(main関数) | 該当なし(メインパッケージであるため) |
internal/ |
プライベート実装 | ❌ Goコンパイラでは外部からのインポートが禁止されています |
pkg/ |
外部に公開されているコード | ✅ |
internal ディレクトリは Go コンパイラ専用のディレクトリであり、internal の親ディレクトリ外にあるパッケージからはこれをインポートすることはできません。これにより真のカプセル化が実現され、「命名規則」(_private など)よりも安全です。
4. クリーンアーキテクチャの4層モデル
handler (HTTP) → service (事業) → repository (データ)
↓ ↓ ↓
リクエストの解析 ドメインルール データベース/API
応答の返却 業務管理 CRUD 操作
パラメータの検証 多段階のオーケストレーション キャッシュへのアクセス
▶ サンプル:4層構成の実装
// ---------- 層 4:Model(ドメインモデル) ----------
// pkg/model/ユーザー.go
パッケージ model
type User struct {
ID int
Name 文字列
Email 文字列
CreatedAt time.Time
}
type CreateUserRequest struct {
Name 文字列
Email 文字列
}
// ---------- 層 3:Repository(データアクセス) ----------
// internal/リポジトリ/ユーザー.go
パッケージ リポジトリ
import (
"データベース/sql"
"myapp/pkg/model"
)
type UserRepository interface {
FindByID(id int) (*model.User, エラー)
Create(req model.CreateUserRequest) (*model.User, エラー)
List() ([]*model.User, エラー)
Delete(id int) エラー
}
type userRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindByID(id int) (*model.User, エラー) {
row := r.db.QueryRow("SELECT id, name, email, created_at FROM users WHERE id = ?", id)
ユーザー := &model.User{}
err := row.Scan(&ユーザー.ID, &ユーザー.Name, &ユーザー.Email, &ユーザー.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return ユーザー, err
}
func (r *userRepository) Create(req model.CreateUserRequest) (*model.User, エラー) {
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))
}
// ---------- 層 2:Service(ビジネスロジック) ----------
// internal/service/ユーザー.go
パッケージ service
import (
"errors"
"myapp/internal/リポジトリ"
"myapp/pkg/model"
"strings"
)
var (
ErrUserNotFound = errors.New("ユーザー not found")
ErrInvalidName = errors.New("name is required")
ErrInvalidEmail = errors.New("invalid email format")
)
type UserService struct {
repo リポジトリ.UserRepository
}
func NewUserService(repo リポジトリ.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Create(req model.CreateUserRequest) (*model.User, エラー) {
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, エラー) {
ユーザー, err := s.repo.FindByID(id)
if err != nil {
return nil, err
}
if ユーザー == nil {
return nil, ErrUserNotFound
}
return ユーザー, nil
}
// ---------- 層 1:Handler(HTTP ハンドラ) ----------
// internal/handler/ユーザー.go
パッケージ 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
}
ユーザー, err := h.svc.Create(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, ユーザー)
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
ユーザー, 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 エラー")
return
}
writeJSON(w, http.StatusOK, ユーザー)
}
// ---------- ユーティリティ関数 ----------
func writeJSON(w http.ResponseWriter, ステータス int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(ステータス)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, ステータス int, msg 文字列) {
writeJSON(w, ステータス, map[文字列]文字列{"エラー": msg})
}
(1) 依存関係の方向
Handler → Service → Repository (→ DB)
| | |
↓ ↓ ↓
Model Model Model
| レイヤー | 役割 | 依存関係 | テスト可能 |
|---|---|---|---|
| ハンドラー | HTTPの解析・応答 | サービス | モックサービス |
| サービス | ビジネスルール/オーケストレーション | リポジトリ | モックリポジトリ |
| リポジトリ | データのCRUD | データベース/SQL | 模擬データベース / 統合テスト |
| モデル | データ構造 | なし | — |
5. 依存性注入
▶ サンプル:コンストラクタ注入
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
"myapp/internal/handler"
"myapp/internal/repository"
"myapp/internal/service"
)
func main() {
// ---- 依存性注入(全レイヤーを組み立てる) ----
// 1. データベース接続
db, err := sql.Open("sqlite3", "./app.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 2. Repository 層
userRepo := repository.NewUserRepository(db)
orderRepo := repository.NewOrderRepository(db)
// 3. Service 層(Repository に依存)
userSvc := service.NewUserService(userRepo)
orderSvc := service.NewOrderService(orderRepo, userRepo)
// 4. Handler 層(Service に依存)
userHandler := handler.NewUserHandler(userSvc)
orderHandler := handler.NewOrderHandler(orderSvc)
// 5. ルーティング登録
mux := http.NewServeMux()
userHandler.Register(mux, "/api/v1/users")
orderHandler.Register(mux, "/api/v1/orders")
log.Println("サービスの開始は :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
(2) 依存性注入手法の比較
| 手法 | 実装 | 利点 | 欠点 |
|---|---|---|---|
| コンストラクタ注入 | NewXxx(dep) |
明示的、コンパイル時チェック | 依存関係によりコードが長くなる |
| 手動での実装 | メイン関数の組み立て | サードパーティ製ライブラリは不要 | 大規模なプロジェクトではメンテナンスが煩雑 |
| Google Wire | コード生成 | 自動挿入 | 学習曲線 |
github.com/google/wire) を使用して、依存性注入コードを自動生成することを検討してください。
6. 完全な例:Eコマース・プロジェクトのフレームワーク
// cmd/サーバー/main.go
パッケージ main
import (
"context"
"データベース/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 文字列 `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 文字列 `json:"ステータス"`
CreatedAt time.Time `json:"created_at"`
}
// ---------- Repository (internal/リポジトリ) ----------
type ProductRepository struct {
db *sql.DB
}
func NewProductRepository(db *sql.DB) *ProductRepository {
return &ProductRepository{db: db}
}
func (r *ProductRepository) FindByID(id int) (*Product, エラー) {
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, エラー) {
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) エラー {
result, err := r.db.Exec(
"INSERT INTO orders (user_id, product_id, quantity, total, ステータス) VALUES (?, ?, ?, ?, ?)",
順序.UserID, 順序.ProductID, 順序.Quantity, 順序.Total, 順序.Status,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
順序.ID = int(id)
return nil
}
func (r *OrderRepository) FindByID(id int) (*Order, エラー) {
o := &Order{}
err := r.db.QueryRow(
"SELECT id, user_id, product_id, quantity, total, ステータス, 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: "その商品は存在しません", HTTPStatus: 404}
ErrInsufficientStock = &AppError{Code: "INSUFFICIENT_STOCK", Message: "在庫不足", HTTPStatus: 409}
)
type AppError struct {
Code 文字列 `json:"code"`
Message 文字列 `json:"message"`
HTTPStatus int `json:"-"`
}
func (e *AppError) Error() 文字列 {
return e.Message
}
func (s *OrderService) PlaceOrder(input PlaceOrderInput) (*Order, エラー) {
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{
UserID: input.UserID,
ProductID: input.ProductID,
Quantity: input.Quantity,
Total: total,
Status: "created",
}
if err := s.orderRepo.Create(順序); err != nil {
return nil, err
}
return 順序, 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 文字列) {
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) {
// Context からユーザー ID を取得(認証ミドルウェアが注入)
userID := r.Context().Value("user_id").(int)
var req placeOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
順序, 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 エラー")
return
}
writeJSON(w, http.StatusCreated, 順序)
}
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
// ... 実装
}
func writeJSON(w http.ResponseWriter, ステータス int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(ステータス)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, ステータス int, msg 文字列) {
writeJSON(w, ステータス, map[文字列]文字列{"エラー": msg})
}
// ---------- Main(依存性注入のエントリポイント) ----------
func main() {
db, err := sql.Open("sqlite3", "./shop.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// テーブルの初期化
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,
ステータス TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
// 依存性注入
productRepo := NewProductRepository(db)
orderRepo := NewOrderRepository(db)
orderSvc := NewOrderService(productRepo, orderRepo)
orderHandler := NewOrderHandler(orderSvc)
mux := http.NewServeMux()
orderHandler.Register(mux, "/api/v1/orders")
// ヘルスチェック
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[文字列]文字列{"ステータス": "ok"})
})
サーバー := &http.Server{
Addr: ":8080",
Handler: mux,
}
// グレースフルシャットダウン
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("サービスを停止中です...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
サーバー.Shutdown(ctx)
}()
log.Println("Eコマースサービスは :8080")
if err := サーバー.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 ["外部依存関係"]
DB[(Database)]
end
H -->|呼び出し| S
S -->|呼び出し| R
R -->|検索| 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がリポジトリに直接インポートされるようなケース)を防ぐことができます。📖 まとめ
- 標準レイアウト:
cmd/(エントリポイント)、internal/(プライベート)、pkg/(パブリック) - 4層モデル:ハンドラー → サービス → リポジトリ → モデル
- 依存関係の方向:外側から内側へ。内側の層は外側の層の存在を認識していない
- 依存性注入:コンストラクタ注入(最も一般的)
- インターフェースの分離:Goの暗黙的インターフェースにより、依存性の反転が自然に実現される
- エラーの種類:コード/メッセージ/HTTPステータスを含むカスタム AppError
internalディレクトリ:Go コンパイラによって強制されるカプセル化
📝 練習問題
-
基本演習(難易度 ⭐):以下のディレクトリを含むプロジェクトの骨格を作成してください:cmd/サーバー/main.go、internal/handler/、internal/service/、internal/リポジトリ/、および pkg/model/。
HealthHandlerを返す単純な{"ステータス": "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) サービス層のビジネスルールをテストするためのユニットテスト(モックリポジトリを使用)を作成すること。



