2-6. パッケージ設計 - 凝集・結合・依存方向

所要時間: 40-60分(2セッション分) コミット内容: ~/learn/go/level2/day06/ にレイヤード設計の TODO API


このレッスンのゴール

  • 凝集度(cohesion)/ 結合度(coupling)の意味を1分で説明できる
  • handler / service / repository の3層に分けた TODO API を組める
  • internal/ で公開制限を効かせられる
  • インターフェースを「使う側」が定義する Go 流儀を実践できる
  • 循環依存を3つの方法で解消できる

なぜ学ぶか

main.go が1500行になってきた」「テストでDBを立てないと service が動かない」「修正がいつも複数ファイルに波及する」 - これらは 設計の劣化 の症状。良いパッケージ分割は コードベースの寿命を直接決める。本章を理解せずに業務コードを書き続けると、半年後にリファクタ地獄が待っている。

前章とのつながり

2-1_net_http2-5_エラー処理 で書いた各機能を、ここで レイヤード設計 として整理する。これまで main.go に書いていたものを cmd/server/main.go / internal/todo/handler.go / service.go / repository.go に再配置。「単一ファイルで動くから設計が不要」から「設計があるから保守できる」への転換点。

これができると何が嬉しいか

  • Service を DB なしで単体テスト できる(mockRepo を渡すだけ)
  • インメモリ→PostgreSQL の差し替えが main の1行 で済む
  • チームメンバーと並行して開発 してもコンフリクトが減る
  • OSS Go プロジェクトの構造が読める - Kubernetes, Docker, Terraform 系

ストーリー導入: util パッケージは「中身がぐちゃぐちゃの段ボール」

util/string.goutil/time.goutil/http.goutil/validate.go - 一見便利そう。だが半年後、util全パッケージが依存する変更不能の固まり に化ける。「段ボールに何でも入れる」と取り出しにくくなるのと同じ。本章で学ぶのは「段ボールを役割ごとに分ける」技術。Go の流儀は 小さく、責務明確、依存方向は一方向


大前提: パッケージ設計が「コードベース寿命」を決める

小さなプロジェクトなら main.go 1ファイルで全部書ける。しかし、1000行を超えた瞬間に:

  • どこに何があるか分からない
  • テストを書くのに「依存しすぎ」て、モックがでかくなる
  • 同僚と作業がコンフリクトする
  • 「ちょっとした変更」が広範囲に波及する

これらの破綻は 設計の劣化 で起きる。良いパッケージ分割は、コードの 寿命 を直接決める。

設計思想: Go のパッケージは「最小の依存単位」

Java のクラス、Python のモジュール、JavaScript のファイルとは違い、Go のパッケージは ビルド単位 + 依存単位 + 名前空間 を同時に表す。

  • パッケージ間に 循環依存 は禁止(コンパイルエラー)
  • パッケージ名は 小文字、短く、概念を表す名詞
  • 1パッケージ = 「1つの責務」が原則

この制約により、Go プロジェクトは「依存方向が一方向の DAG」になる。これが大規模化への耐性を生む。


セッション①: 分割の原則と命名(30分)

0. 録画スタート&作業ディレクトリ

mkdir -p ~/log ~/learn/go/level2/day06
cd ~/learn/go/level2/day06
script ~/log/go_level2_day06.log
 
# Go モジュール初期化(章本文で github.com/yourname/todo を想定)
go mod init github.com/yourname/todo

1. 凝集度 (Cohesion) と結合度 (Coupling)

パッケージ設計の2大指標

凝集度(Cohesion): パッケージ内の要素が「同じ目的」のためにあるか

  • 高凝集: user パッケージは User CRUD だけを扱う
  • 低凝集: util パッケージに User 操作・Date 計算・String 変換が混在

結合度(Coupling): パッケージ間の「依存の強さ

  • 低結合: handlerservice のインターフェースだけ知っている
  • 高結合: handlerservice の内部実装に依存している

目標: 高凝集 × 低結合。これが破綻しないコードベースの黄金律。

2. パッケージの命名規則

// 良い名前
package user
package order
package payment
package storage
 
// 悪い名前
package utils
package common
package helpers
package shared
package misc

アンチパターン: util / common / helpers パッケージ

internal/
└── util/
    ├── string.go
    ├── time.go
    ├── http.go
    ├── jwt.go
    └── validate.go

なぜNG:

  • 凝集度ゼロ: 「util」は責務を表していない、何でも入る
  • 依存の集中点: ほぼ全パッケージが util をインポートする → util の変更で全部リビルド
  • 役割の重複: util.ParseTimetime.Parse の使い分けが曖昧になる

修正: 機能ごとに分ける(stringutilhttputilvalidate)。あるいは 「util と呼びたくなる関数」は本当に必要か? を疑う(標準ライブラリで足りるケースが多い)。

Go コミュニティの命名慣習

  • 小文字、短く、複数形を避ける: users ではなく usertasks ではなく task
  • 動詞ではなく名詞: parser より parse(パッケージ全体が「パースする機能」を表す)
  • インターフェース名と被らない: package reader 内に type Reader interface {...} は冗長(reader.Reader になる)
  • OSS 例: net/http, encoding/json, crypto/tls, database/sql - すべて短く、概念的

3. internal/ ディレクトリ - 公開制限

myapp/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── user/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── repository.go
│   └── auth/
│       └── jwt.go
├── pkg/  ← 外部にも公開する場合のみ
│   └── client/
└── go.mod

internal/ は Go 公式の「公開制限」機構

internal/ ディレクトリ以下のパッケージは そのプロジェクト外からインポート不可

例: github.com/yourname/todo/internal/user は、

  • github.com/yourname/todo/cmd/server/main.go からはインポート可能
  • github.com/yourname/todo/api/handler.go からはインポート可能
  • github.com/otheruser/something/main.go からはコンパイルエラー

効果:

  • 外部に公開する意図のあるコードと内部実装を物理的に分離
  • 外部利用者が「内部実装をインポートする」誘惑を断つ
  • リファクタリング時の影響範囲を限定できる

cmd/ と pkg/ のディレクトリ慣習

  • cmd/<name>/main.go: 実行バイナリのエントリポイント。プロジェクトに複数バイナリがある場合に整理しやすい
  • pkg/: 外部公開する SDK ライクなコード(必須ではない)
  • internal/: 公開しない実装本体

「Standard Go Project Layout」と呼ばれる構造だが、Go 公式の標準ではないことに注意。プロジェクト規模が小さければ素直にトップレベルに置いても OK。

4. レイヤード設計 - handler / service / repository

internal/
└── todo/
    ├── handler.go     ← HTTP 受付(リクエスト解釈、レスポンス生成)
    ├── service.go     ← ビジネスロジック(バリデーション、ルール)
    ├── repository.go  ← データアクセス(DB / 外部API / ストレージ)
    └── model.go       ← データ構造
[クライアント]
    ↓ HTTP リクエスト
[handler]   ← JSON → struct、struct → JSON
    ↓ 関数呼び出し
[service]   ← ビジネスルール、トランザクション境界
    ↓ インターフェース呼び出し
[repository] ← SQL、外部 API、キャッシュ

各層の責務(明確な分離)

責務やらないこと
handlerHTTP の入出力、認証、レスポンス整形ビジネスロジック、DB アクセス
serviceドメインルール、トランザクション、複数 repository の組み合わせHTTP の事情、SQL 文
repositoryDB / 外部 API への問い合わせ、永続化ビジネスルール

鉄則:

  • handler は service を呼ぶだけ、service の中身を知らない
  • service は repository インターフェースに依存、具体実装(PostgreSQL なのか、MySQL なのか)を知らない
  • repository はビジネスルールを持たない(「在庫を減らす」は service、「UPDATE 文を打つ」は repository)

5. 実装: TODO API のレイヤード分割

// internal/todo/model.go
package todo
 
import "time"
 
type Todo struct {
	ID    int
	Title string
	Done  bool
	At    time.Time
}
// internal/todo/repository.go
package todo
 
import (
	"context"
	"errors"
	"sync"
)
 
var ErrNotFound = errors.New("todo not found")
 
// Repository は永続化の抽象。具体実装は差し替え可能
type Repository interface {
	Create(ctx context.Context, t *Todo) error
	Get(ctx context.Context, id int) (*Todo, error)
	List(ctx context.Context) ([]Todo, error)
	Delete(ctx context.Context, id int) error
}
 
// インメモリ実装
type memoryRepo struct {
	mu     sync.Mutex
	store  map[int]Todo
	nextID int
}
 
func NewMemoryRepo() Repository {
	return &memoryRepo{store: map[int]Todo{}}
}
 
func (r *memoryRepo) Create(ctx context.Context, t *Todo) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.nextID++
	t.ID = r.nextID
	r.store[r.nextID] = *t
	return nil
}
 
func (r *memoryRepo) Get(ctx context.Context, id int) (*Todo, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	t, ok := r.store[id]
	if !ok {
		return nil, ErrNotFound
	}
	return &t, nil
}
 
func (r *memoryRepo) List(ctx context.Context) ([]Todo, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]Todo, 0, len(r.store))
	for _, t := range r.store {
		out = append(out, t)
	}
	return out, nil
}
 
func (r *memoryRepo) Delete(ctx context.Context, id int) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.store[id]; !ok {
		return ErrNotFound
	}
	delete(r.store, id)
	return nil
}
// internal/todo/service.go
package todo
 
import (
	"context"
	"errors"
	"strings"
	"time"
)
 
var ErrInvalidTitle = errors.New("title must not be empty")
 
// Service はビジネスロジック層
type Service struct {
	repo Repository
}
 
func NewService(r Repository) *Service {
	return &Service{repo: r}
}
 
func (s *Service) Create(ctx context.Context, title string) (*Todo, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return nil, ErrInvalidTitle
	}
	t := &Todo{Title: title, At: time.Now()}
	if err := s.repo.Create(ctx, t); err != nil {
		return nil, err
	}
	return t, nil
}
 
func (s *Service) Get(ctx context.Context, id int) (*Todo, error) {
	return s.repo.Get(ctx, id)
}
 
func (s *Service) List(ctx context.Context) ([]Todo, error) {
	return s.repo.List(ctx)
}
 
func (s *Service) Delete(ctx context.Context, id int) error {
	return s.repo.Delete(ctx, id)
}
// internal/todo/handler.go
package todo
 
import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
)
 
type Handler struct {
	svc *Service
}
 
func NewHandler(s *Service) *Handler {
	return &Handler{svc: s}
}
 
func (h *Handler) Routes(mux *http.ServeMux) {
	mux.HandleFunc("GET /todos", h.list)
	mux.HandleFunc("POST /todos", h.create)
	mux.HandleFunc("GET /todos/{id}", h.get)
	mux.HandleFunc("DELETE /todos/{id}", h.delete)
}
 
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
	items, err := h.svc.List(r.Context())
	if err != nil {
		http.Error(w, "internal error", 500)
		return
	}
	json.NewEncoder(w).Encode(items)
}
 
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Title string `json:"title"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "bad json", 400)
		return
	}
	t, err := h.svc.Create(r.Context(), in.Title)
	if errors.Is(err, ErrInvalidTitle) {
		http.Error(w, err.Error(), 400)
		return
	}
	if err != nil {
		http.Error(w, "internal error", 500)
		return
	}
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(t)
}
 
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.Atoi(r.PathValue("id"))
	t, err := h.svc.Get(r.Context(), id)
	if errors.Is(err, ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, "internal error", 500)
		return
	}
	json.NewEncoder(w).Encode(t)
}
 
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.Atoi(r.PathValue("id"))
	if err := h.svc.Delete(r.Context(), id); errors.Is(err, ErrNotFound) {
		http.NotFound(w, r)
		return
	} else if err != nil {
		http.Error(w, "internal error", 500)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}
// cmd/server/main.go
package main
 
import (
	"log/slog"
	"net/http"
	"os"
	"time"
 
	"github.com/yourname/todo/internal/todo"
)
 
func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
 
	repo := todo.NewMemoryRepo()
	svc := todo.NewService(repo)
	h := todo.NewHandler(svc)
 
	mux := http.NewServeMux()
	h.Routes(mux)
 
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	logger.Info("starting", "addr", srv.Addr)
	srv.ListenAndServe()
}

セッション②: 依存性注入と循環依存(30分)

6. DI(依存性注入)入門

// service は Repository インターフェースに依存(具体実装には依存しない)
type Service struct {
	repo Repository  // インターフェース
}
 
func NewService(r Repository) *Service {
	return &Service{repo: r}
}
 
// main で具体実装を「注入」
repo := todo.NewMemoryRepo()      // インメモリ実装
// 後で PostgreSQL に切り替える時は:
// repo := todo.NewPostgresRepo(db)
svc := todo.NewService(repo)

DI(Dependency Injection)の効能

依存するものを外から渡す」設計パターン。Go では NewXxx(dep) の引数で渡すのが基本。

利点:

  • テスタビリティ向上: モック実装を渡して service だけ単体テストできる
  • 実装差し替え可能: インメモリ → PostgreSQL の切り替えが main の1行だけ
  • 開発時とテスト時で振る舞いを変えられる: 例えば本番は実 API、テストは固定値返すモック

テストでの活用

// mock_repository.go
type mockRepo struct {
    todos map[int]*Todo
}
func (m *mockRepo) Get(ctx context.Context, id int) (*Todo, error) {
    if t, ok := m.todos[id]; ok {
        return t, nil
    }
    return nil, ErrNotFound
}
// ... 他のメソッドも実装
 
// service_test.go
func TestServiceCreate(t *testing.T) {
    repo := &mockRepo{todos: map[int]*Todo{}}
    svc := NewService(repo)
 
    todo, err := svc.Create(ctx, "test")
    if err != nil { t.Fatal(err) }
    // ...
}

ポイント: service のテストに DB を立てる必要がない。インターフェース駆動のメリット。

7. インターフェースは「使う側」が定義する

Go の流儀: 「使う側」がインターフェースを所有

Java / C# は「実装する側」がインターフェースを定義する慣習がある。Go は :

  • todo.Service が必要とする操作のインターフェースは、todo パッケージ内に定義
  • todo.PostgresRepo 実装は todo パッケージ内、もしくは別パッケージ
// internal/todo/service.go (使う側)
package todo
 
type Repository interface {  // ← service が必要なメソッドを定義
    Get(ctx context.Context, id int) (*Todo, error)
    Create(ctx context.Context, t *Todo) error
}
 
type Service struct { repo Repository }

利点: service が必要としていない余計なメソッドをインターフェースに入れずに済む。「インターフェースは小さく」が Go 流。

8. 循環依存 - その回避

// パッケージ user
package user
 
import "myapp/order"  // user は order を知っている
 
type User struct {
	ID     int
	Orders []order.Order
}
 
// パッケージ order
package order
 
import "myapp/user"  // ← 循環!コンパイルエラー
 
type Order struct {
	ID    int
	Owner *user.User
}

循環依存はコンパイルエラー

Go は パッケージ間の循環依存をコンパイル時に検知して止める。これにより、依存グラフは必ず DAG(Directed Acyclic Graph)になる。

エラーメッセージ:

import cycle not allowed
        imports myapp/user
        imports myapp/order
        imports myapp/user

循環依存の解消パターン

(1) 共通の型を別パッケージに切り出す

internal/
  model/   ← User, Order 等の純粋なデータ型
  user/    ← User の処理(model.User を使う)
  order/   ← Order の処理(model.Order を使う)

(2) 依存方向を一方向にする

order → user  (order が user を知る、user は order を知らない)

User の中で Orders スライスを持つのを諦め、Order 側で OwnerID int を持つ。

(3) インターフェースで疎結合化

// user パッケージ
type OrderLister interface {
    ListByUser(userID int) []Order
}
// user.GetWithOrders(ol OrderLister) のように引数で受ける

9. go mod の基本

# モジュール初期化
go mod init github.com/yourname/myapp
 
# 依存追加(go get、または import 後の go mod tidy)
go get github.com/google/uuid
 
# 不要な依存削除
go mod tidy
 
# 依存のロックファイル更新
cat go.sum
 
# 依存を vendor/ ディレクトリに固める(再現性向上)
go mod vendor

go.mod のキーワード

module github.com/yourname/myapp

go 1.22

require (
    github.com/google/uuid v1.6.0
    github.com/go-chi/chi/v5 v5.0.10
)

replace github.com/foo/bar => ../bar  // ローカル開発用
  • module: このパッケージの import パス
  • go: Go のバージョン
  • require: 直接依存
  • replace: 「このパッケージは別のソースを使え」(ローカルの WIP、フォーク版の指定)

Semantic Versioning と Major Version の罠

Go モジュールは semver に従う。v1.0.0, v1.2.3, … まではいつも通り。

v2 以上は import パスに反映する必要がある:

// v1
import "github.com/go-chi/chi"
 
// v5
import "github.com/go-chi/chi/v5"  // ← /v5 が付く

なぜ: 同一プロジェクト内で v1 と v2 を共存させるため。仕様の不便さがあるが、互換性違反を強制的に明示化する設計。

10. 命名規則と公開・非公開

// 公開(大文字始まり)
func NewService(...)
type Repository interface {...}
const MaxRetries = 3
 
// 非公開(小文字始まり、パッケージ内のみ)
func internalHelper(...)
type config struct {...}
var defaultTimeout = 5 * time.Second

命名の鉄則

  • 大文字始まり: パッケージ外から見える(public 相当)
  • 小文字始まり: パッケージ内のみ(private 相当)

Go には public private キーワードがない。名前の大文字小文字で公開制御 している。

結果:

  • 「公開したくないものは小文字」が自然
  • インターフェースの実装をパッケージ外に出さない(Repository を返す関数を作って構造体は隠す)

ファクトリ関数の慣習: NewXxx

Go では「コンストラクタ」は言語機能としてない。代わりに NewXxx 関数 を作る:

// 慣習: NewXxx で初期化 + 公開
func NewService(repo Repository) *Service {
    return &Service{repo: repo, started: time.Now()}
}

パッケージが Service という1つの主要型を持つ場合、New() だけでも OK(todo.New() のように呼ばれる)。

11. 巨大 main.go アンチパターン

アンチパターン: main.go に全部詰める

// main.go (1500行)
package main
 
type User struct {...}
type Todo struct {...}
 
func handleUsersGet(...) {...}
func handleUsersPost(...) {...}
func handleTodosGet(...) {...}
// ... 30個のハンドラ
 
func main() {
    // ... 200行の初期化
    http.HandleFunc("/users", handleUsersGet)
    // ... 30個の登録
}

なぜNG:

  • テストできない: main.go の中身は func main() 経由でしか動かない
  • 責務がごちゃ混ぜ: HTTP、ビジネス、DB が同じファイル
  • 同僚と作業がコンフリクトする: 同じファイルを全員が触る
  • 依存方向が混乱: 何が何を呼んでいるか追跡不能

修正: 上記のレイヤード分割。main.go は 「組み立て」だけやる。100行以内が目安。


練習課題

  1. 上記の TODO API を実装し、curl で動作確認
  2. Repository インターフェースの モック実装 を書いて、Service の単体テストを実装
  3. PostgresRepo のスケルトンを書いて database/sqlCreate / Get を実装(実 DB は不要、コンパイル通せばOK)
  4. 意図的に循環依存 を作ってコンパイルエラーを観察 → インターフェース分離で解消
  5. internal/ の外から internal/todo をインポートしようとしてエラーを再現
  6. 思考課題: userorder の双方向参照をどう設計するか、3パターン考える

締め: git で証跡を残す

cd ~/learn/go/level2/day06
git add .
git commit -m "feat(go-packages): handler/service/repositoryでTODO APIを分割"
exit

アンチパターン集 - やらかし事例

パッケージ設計のやらかし

1. util / common / helpers パッケージ作成 凝集度ゼロ、全パッケージから依存される変更不能の固まりに。

2. main.go に全部詰める

// main.go (1500行)

テスト不能、責務混在、コンフリクト多発。

3. インターフェースを実装側に置く Java/C# の癖。Go は 使う側(service 側)に置く。

4. 循環依存を解消せずパッケージ分割を諦める 1つのパッケージに User と Order を同居 → さらに凝集度悪化。 解消パターン: 共通型を別パッケージ / 依存方向の一方向化 / インターフェース分離。

5. internal/ を使わない 外部に公開する意図のないコードが GitHub で誰でも import 可能に。

対比表で違いを明確化

レイヤー責務マトリクス

やるやらない
handlerHTTP I/O、JSON⇔struct、ステータスコードビジネスルール、SQL
serviceドメインルール、トランザクション境界HTTP の事情、SQL 詳細
repositorySQL、外部 API、永続化ビジネスルール

internal vs pkg vs cmd

ディレクトリ用途アクセス制限
cmd/<name>/main.go実行バイナリ通常
internal/プロジェクト内部実装プロジェクト外からインポート不可
pkg/外部に公開する SDK通常

自己評価チェックリスト

手を動かせた

  • handler / service / repository に分けて TODO API を書いた
  • Repository インターフェースを service 側に定義
  • memoryRepo の単体テストを書いた
  • mock 実装で Service の単体テストを書いた
  • internal/ 外から import エラーを再現
  • 意図的に循環依存を作り、解消した

説明できる

  • 凝集度・結合度の意味
  • util パッケージがアンチパターンな理由
  • internal/ の効能と制限
  • インターフェースを「使う側」が所有する Go 流儀
  • 循環依存の解消パターン3つ

やらかし回避

  • util / common / helpers と名付けない
  • main.go に全部詰めない
  • DI で具体実装を「外から渡す」設計

詰まった時のチートシート

やりたいことコマンド / 設計
モジュール初期化go mod init <path>
依存追加go get <pkg> または import 後 go mod tidy
公開する名前を大文字始まりに
非公開にする名前を小文字始まりに
外部から隠すinternal/ 配下に置く
インターフェース所有使う側 のパッケージに定義
循環依存解消共通型を別パッケージ / インターフェース分離 / 依存方向の一方向化
バイナリ複数管理cmd/<name>/main.go

「実務OK」基準

  • util パッケージを作りたくなったら一度立ち止まれる
  • handler / service / repository の3層で TODO API を即書ける
  • モック実装を使った単体テストが書ける
  • 循環依存をコンパイラに教えられた時、設計を見直せる
  • internal/ を使って公開 API を意識的に絞れる

さらに深掘りするなら

  • 公式: Effective Go - Package names
  • 公式: Organizing a Go module
  • Standard Go Project Layout: github.com/golang-standards/project-layout(参考程度、絶対ではない)
  • Dave Cheney: “Practical Go: Real world advice for writing maintainable Go programs” - パッケージ設計の章必読
  • 書籍: 『実用 Go 言語』(渋川よしき他) - レイヤード分割の章
  • OSS 例: Kubernetes staging/src/k8s.io/ の構造、Hashicorp 系(Terraform, Consul)のパッケージ分割

次のレッスン

2-7 REST API 統合 で、ここまでの全章(http / routing / json / middleware / errors / packages)を統合して、実務OKレベルの TODO REST API を本気で書く。バリデーション、構造化ロギング、graceful shutdown まで一気通貫。Level 2 の総決算回。

つながりの予告

  • 本章のレイヤード設計を 次章で完成形 に - バリデーション・ロギング・shutdown 追加
  • Repository インターフェースは 3-1_database_sqlPostgresRepo 実装 に発展
  • mock 実装は 3-3_テスト基礎 で本格的なテスト戦略に