設計の基礎
この部の 3 / 6 章 ・ 全体で 58 / 76 章 ・ 読了目安 45 分
- 変更しやすさを基準に設計を判断できる
- 依存の向きを一方向に保てる
- 早すぎる抽象化と DRY の誤用を避けられる
動くコードを書くのは、難しくありません。 難しいのは、半年後に別の人が安全に変更できるコードを書くことです。
設計とは、突き詰めると1つのことです。
将来の変更にかかるコストを、下げておくこと
きれいさや流行のためではありません。 「この修正、3日かかります」を「30分で済みます」に変えるためにやります。
まず、何が変わるのかを考える
変更コストを下げるには、何が変わりやすいかを知る必要があります。
| 変わりやすい | 変わりにくい |
|---|---|
| 画面のデザイン | 業務のルール(ドメインとは何か) |
| 使っているライブラリ | データの意味 |
| DB の種類・スキーマの詳細 | |
| 外部サービス(決済・メール送信) |
変わりやすいものに、変わりにくいものを依存させない。 これが設計の中心的な指針です。
悪い: 業務のルールが、DB のライブラリに依存している
→ DB を変えると、業務ロジックも書き換えになる
良い: DB のアクセス処理が、業務のルールに依存している
→ DB を変えても、業務ロジックは無傷
責務 — このコードを変更する理由は何か
「単一責任の原則」は、しばしば「1つのことだけをする」と説明されますが、 実務でより使えるのは次の言い方です。
このファイルを変更する理由は、1種類だけであるべき
// 変更理由が3つある
func HandleOrder(w http.ResponseWriter, r *http.Request) {
var req struct{ ... }
json.NewDecoder(r.Body).Decode(&req) // ← API の形が変わったら直す
if req.Quantity > 10 { ... } // ← 業務ルールが変わったら直す
db.Exec("INSERT INTO orders ...") // ← テーブルが変わったら直す
smtp.SendMail(...) // ← メール基盤が変わったら直す
}この関数は、4つの異なる理由で変更されます。 つまり4つのチームの都合が、1箇所でぶつかります。
分けると、それぞれが独立して変更できます。
handler HTTP の入出力だけ(JSON の形、ステータスコード)
service 業務のルールだけ(数量の上限、注文できる条件)
store 永続化だけ(SQL、テーブル)
notifier 通知だけ(メール、Slack)
「分けるべきか」で迷ったら、こう自問してください。
この2つは、同じ理由で・同じタイミングで変更されるか?
Yes なら一緒に置く。No なら分ける。 これはドメインとは何かの「一緒に変わるものは一緒に置く」と同じ原則です。
依存の向きを一方向にする
ハンズオン: サービスを1本作って動かすのハンズオンは、この形で作られていました。
[handler] ──→ [service] ──→ [store]
HTTP 業務ルール DB
矢印の逆流を許さない。service は HTTP を知らないし、store を実装として知らない
逆流を許すと、何が起きるか。
// service が HTTP を知ってしまっている
func (s *Service) Create(w http.ResponseWriter, r *http.Request) error {
...
w.WriteHeader(http.StatusBadRequest) // ← これで gRPC からもバッチからも呼べなくなった
}業務ロジックが HTTP に縛られると、 gRPC で公開したい時・バッチから呼びたい時・テストしたい時に全部困ります。
interface で向きを反転させる
service は「保存する手段」が必要です。
しかし service が具体的な DB 実装に依存すると、上の原則が崩れます。
そこで、必要とする側(service)が、必要な形(interface)を宣言します。
// service パッケージ側で宣言する。「私はこういうものが必要だ」
type Store interface {
Save(ctx context.Context, l Link) error
Get(ctx context.Context, key string) (Link, error)
}
type Service struct {
store Store // 具体的な実装ではなく、interface に依存する
}
func New(store Store) *Service { return &Service{store: store} }// 実装側(store パッケージ)は、interface の存在すら知らなくてよい
type Postgres struct{ db *sql.DB }
func (p *Postgres) Save(ctx context.Context, l Link) error { ... }// 組み立ては main で行う(依存性注入)
func main() {
store := store.NewPostgres(db)
svc := service.New(store) // ここで初めて具体と抽象がつながる
h := handler.New(svc)
}得られるものは3つです。
- DB を差し替えられる(メモリ・Postgres・Spanner)
- テストで偽物を渡せる(テストを書く)
- service だけを読めば、業務ルールが全部分かる
「差し替えられるように」と、すべての構造体に interface を作るのは害です。
- 実装が1つしかない interface は、読む人の負担を増やすだけ
- 定義にジャンプしても抽象しか無く、実際の処理にたどり着けない
作る基準は、「差し替える具体的な理由があるか」です。
- 外部サービス(決済・メール・ストレージ)→ ほぼ必ず作る
- DB アクセス → テストのために作ることが多い
- 純粋な計算処理 → 作らない
新しい言語をどう学ぶかで触れたとおり、Go では「使う側が必要になった時に定義する」のが慣習です。
副作用を端に寄せる
テストが書きにくいコードには、共通の形があります。 計算と副作用(DB・時刻・乱数・通信)が混ざっているのです。
// テストしにくい: 時刻と DB が中に埋まっている
func (s *Service) IsExpired(ctx context.Context, id string) (bool, error) {
l, err := s.store.Get(ctx, id)
if err != nil { return false, err }
return time.Now().After(l.ExpiresAt), nil // ← 「明日になったら」をテストできない
}// 分ける: 判定は純粋な関数にする
func IsExpired(l Link, now time.Time) bool {
return now.After(l.ExpiresAt)
}
// 副作用は呼び出し側に置く
func (s *Service) CheckExpired(ctx context.Context, id string) (bool, error) {
l, err := s.store.Get(ctx, id)
if err != nil { return false, err }
return IsExpired(l, s.now()), nil
}IsExpired は引数だけで結果が決まるので、
境界値のテストがいくらでも書けます。
ハンズオン: サービスを1本作って動かすのハンズオンで Now を注入していたのは、これが理由です。
type Config struct {
Now func() time.Time // 既定は time.Now、テストでは固定値
}時刻・乱数・UUID の生成は、必ず外から差し込めるようにしておくと、 テストが劇的に書きやすくなります。
抽象化のタイミング
新人が最もやりがちな失敗は、早すぎる抽象化です。
「将来こういう要件が来るかもしれないから、プラグイン方式にしておこう」
→ その要件は来ない
→ 誰も使わない拡張ポイントだけが残り、読む人全員が悩む
似たコードが2箇所に現れても、まだ共通化しない。 3箇所目が現れた時点で、初めて「本当に同じもの」かが分かります。
早すぎる共通化は、次の形で必ず破綻します。
// 2箇所で使うために共通化した
func Format(s string, opt Option) string { ... }
// 3箇所目の都合でフラグが増える
func Format(s string, opt Option, isAdmin bool, legacy bool) string { ... }フラグ引数が増え始めたら、共通化が間違っていた合図です。 分岐が多い共通関数より、似ているが別々の関数のほうがましです。
DRY(Don't Repeat Yourself)は「同じ知識を重複させるな」という原則です。 「同じ見た目のコードを重複させるな」ではありません。
たまたま今は同じだが、変更理由が違うもの → 共通化してはいけない
例えば「消費税の計算」と「手数料の計算」が、
今はどちらも × 1.1 だからといって共通化すると、
税率が変わった時に手数料まで変わります。
判断基準は「一緒に変更されるか」です。
リファクタリング
動作を変えずに、内部の構造だけを改善することです。
リファクタリング = 振る舞いは変えない。テストは変えない
機能変更 = 振る舞いを変える
この2つを同じ PR に混ぜないでください。 混ざると、レビュアーは「どの変更が意図的な挙動変更か」を判別できません。
いつやるか
| タイミング | 内容 |
|---|---|
| 機能追加の直前 | 追加しやすい形に整えてから、追加する |
| バグ修正の後 | 同じバグが起きにくい構造に直す |
| 触ったついで | 小さいものだけ(ボーイスカウト・ルール) |
「リファクタリング専用の期間」を後から取ろうとすると、 価値が説明できず、たいてい承認されません。 作業のたびに少しずつが現実的です。
手順
1. まずテストがあることを確認する(無ければ先に書く)
2. 小さく変える
3. テストを走らせる
4. コミットする
5. 2 に戻る
テストが無い状態で大きく作り直すのは、リファクタリングではなく賭けです。
技術的負債
急いで作った結果、後で払うことになるコストのことです。
| 内容 | 対応 | |
|---|---|---|
| 意図的な負債 | 期限のために、承知の上で手を抜く | 記録し、返す時期を決める |
| 無自覚な負債 | 知らずに悪い設計をした | 学んで直す |
| 放置された負債 | 分かっているのに誰も直さない | 最も高くつく |
意図的な負債は、悪いことではありません。 市場に出す速度が重要な場面はあります。 問題は、記録されないことです。
// TODO(2026-10 まで): 決済リトライを一時的に無効化している。
// 決済基盤側の冪等性対応(PROJ-1234)が入るまでの暫定。- 期限を書く(誰がいつ見直すか)
- 理由を書く(なぜ今はこれでよいのか)
- チケット番号を書く(追跡できるように)
「あとで直す」とだけ書かれたコメントは、永久に直りません。 ドキュメントを書くの ADR に「この負債を承知で選んだ」と記録するのが理想です。
決済サービスを呼び出す処理を実装します。設計として最も適切なのはどれですか。
実務の落とし穴まとめ
- 1つの関数が複数の理由で変更される — HTTP・業務・DB を分ける
- 業務ロジックが HTTP や DB に依存 — 依存の向きは一方向に
- 実装が1つしかない interface を量産 — 読む人の負担が増える
time.Now()を処理の中で呼ぶ — テストが書けない- 2箇所目で共通化する — 3回目まで待つ
- 見た目が同じだけのコードを共通化 — 変更理由が違えば分けたまま
- フラグ引数が増えていく — 共通化が間違っている合図
- リファクタリングと機能変更を同じ PR に混ぜる — レビューできない
- テストが無い状態で作り直す — 賭けになる
- 「あとで直す」とだけ書く — 期限も理由も無い負債は永久に残る
まとめ
- 設計の目的は将来の変更コストを下げること。美しさではない
- 変わりやすいものに、変わりにくいものを依存させない
- 分ける基準は「同じ理由で・同じタイミングで変更されるか」
- 依存は一方向に。必要なら interface で向きを反転させ、
組み立ては
mainで行う(依存性注入) - 副作用(時刻・DB・通信)を端に寄せると、テストが書けるようになる
- 早すぎる抽象化を避ける。3回目まで待つ。 DRY は「同じ知識」の話であって「同じ見た目」の話ではない
- リファクタリングと機能変更は混ぜない。テストを先に用意する
- 技術的負債は取ってよい。ただし期限・理由・チケットを記録する
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| Refactoring Guru(日本語) | https://refactoring.guru/ja |
| The Twelve-Factor App(日本語) | https://12factor.net/ja/ |
| Martin Fowler のブログ | https://martinfowler.com/ |
| Google eng-practices | https://google.github.io/eng-practices/ |
章末問題
「注文の合計金額に、キャンペーン割引を適用する」処理を追加します。どこに書くべきですか。
2つの画面で似た整形処理があります。共通化すべきか判断する基準はどれですか。
次は、同じ設計の話をもう一段細かい粒度で—— 名前・関数・コメントのレベルで扱います。