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_http~2-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.go、util/time.go、util/http.go、util/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/todo1. 凝集度 (Cohesion) と結合度 (Coupling)
パッケージ設計の2大指標
凝集度(Cohesion): パッケージ内の要素が「同じ目的」のためにあるか
- 高凝集:
userパッケージは User CRUD だけを扱う- 低凝集:
utilパッケージに User 操作・Date 計算・String 変換が混在結合度(Coupling): パッケージ間の「依存の強さ」
- 低結合:
handlerはserviceのインターフェースだけ知っている- 高結合:
handlerがserviceの内部実装に依存している目標: 高凝集 × 低結合。これが破綻しないコードベースの黄金律。
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.ParseTimeとtime.Parseの使い分けが曖昧になる修正: 機能ごとに分ける(
stringutil、httputil、validate)。あるいは 「util と呼びたくなる関数」は本当に必要か? を疑う(標準ライブラリで足りるケースが多い)。
Go コミュニティの命名慣習
- 小文字、短く、複数形を避ける:
usersではなくuser、tasksではなく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、キャッシュ
各層の責務(明確な分離)
層 責務 やらないこと handler HTTP の入出力、認証、レスポンス整形 ビジネスロジック、DB アクセス service ドメインルール、トランザクション、複数 repository の組み合わせ HTTP の事情、SQL 文 repository DB / 外部 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 vendorgo.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 には
publicprivateキーワードがない。名前の大文字小文字で公開制御 している。結果:
- 「公開したくないものは小文字」が自然
- インターフェースの実装をパッケージ外に出さない(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行以内が目安。
練習課題
- 上記の TODO API を実装し、
curlで動作確認 Repositoryインターフェースの モック実装 を書いて、Serviceの単体テストを実装PostgresRepoのスケルトンを書いてdatabase/sqlでCreate/Getを実装(実 DB は不要、コンパイル通せばOK)- 意図的に循環依存 を作ってコンパイルエラーを観察 → インターフェース分離で解消
internal/の外からinternal/todoをインポートしようとしてエラーを再現- 思考課題:
userとorderの双方向参照をどう設計するか、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 可能に。
対比表で違いを明確化
レイヤー責務マトリクス
層 やる やらない handler HTTP I/O、JSON⇔struct、ステータスコード ビジネスルール、SQL service ドメインルール、トランザクション境界 HTTP の事情、SQL 詳細 repository SQL、外部 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_sql で PostgresRepo 実装 に発展- mock 実装は 3-3_テスト基礎 で本格的なテスト戦略に