プログラマのための IT 教科書

設計の基礎

この部の 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つです。

  1. DB を差し替えられる(メモリ・Postgres・Spanner)
  2. テストで偽物を渡せる(テストを書く)
  3. service だけを読めば、業務ルールが全部分かる
interface を作りすぎない

「差し替えられるように」と、すべての構造体に 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 は引数だけで結果が決まるので、 境界値のテストがいくらでも書けます。

`time.Now()` を直接呼ばない

ハンズオン: サービスを1本作って動かすのハンズオンで Now を注入していたのは、これが理由です。

type Config struct {
    Now func() time.Time   // 既定は time.Now、テストでは固定値
}

時刻・乱数・UUID の生成は、必ず外から差し込めるようにしておくと、 テストが劇的に書きやすくなります。

抽象化のタイミング

新人が最もやりがちな失敗は、早すぎる抽象化です。

「将来こういう要件が来るかもしれないから、プラグイン方式にしておこう」
→ その要件は来ない
→ 誰も使わない拡張ポイントだけが残り、読む人全員が悩む
3回目まで待つ

似たコードが2箇所に現れても、まだ共通化しない。 3箇所目が現れた時点で、初めて「本当に同じもの」かが分かります。

早すぎる共通化は、次の形で必ず破綻します。

// 2箇所で使うために共通化した
func Format(s string, opt Option) string { ... }
 
// 3箇所目の都合でフラグが増える
func Format(s string, opt Option, isAdmin bool, legacy bool) string { ... }

フラグ引数が増え始めたら、共通化が間違っていた合図です。 分岐が多い共通関数より、似ているが別々の関数のほうがましです。

DRY の誤用

DRY(Don't Repeat Yourself)は「同じ知識を重複させるな」という原則です。 「同じ見た目のコードを重複させるな」ではありません。

たまたま今は同じだが、変更理由が違うもの → 共通化してはいけない

例えば「消費税の計算」と「手数料の計算」が、 今はどちらも × 1.1 だからといって共通化すると、 税率が変わった時に手数料まで変わります。

判断基準は「一緒に変更されるか」です。

リファクタリング

動作を変えずに、内部の構造だけを改善することです。

リファクタリング  = 振る舞いは変えない。テストは変えない
機能変更          = 振る舞いを変える

この2つを同じ PR に混ぜないでください。 混ざると、レビュアーは「どの変更が意図的な挙動変更か」を判別できません。

いつやるか

タイミング内容
機能追加の直前追加しやすい形に整えてから、追加する
バグ修正の後同じバグが起きにくい構造に直す
触ったついで小さいものだけ(ボーイスカウト・ルール)

「リファクタリング専用の期間」を後から取ろうとすると、 価値が説明できず、たいてい承認されません。 作業のたびに少しずつが現実的です。

手順

1. まずテストがあることを確認する(無ければ先に書く)
2. 小さく変える
3. テストを走らせる
4. コミットする
5. 2 に戻る

テストが無い状態で大きく作り直すのは、リファクタリングではなく賭けです。

技術的負債

急いで作った結果、後で払うことになるコストのことです。

内容対応
意図的な負債期限のために、承知の上で手を抜く記録し、返す時期を決める
無自覚な負債知らずに悪い設計をした学んで直す
放置された負債分かっているのに誰も直さない最も高くつく

意図的な負債は、悪いことではありません。 市場に出す速度が重要な場面はあります。 問題は、記録されないことです。

// TODO(2026-10 まで): 決済リトライを一時的に無効化している。
// 決済基盤側の冪等性対応(PROJ-1234)が入るまでの暫定。
負債を「返せる形」で残す
  • 期限を書く(誰がいつ見直すか)
  • 理由を書く(なぜ今はこれでよいのか)
  • チケット番号を書く(追跡できるように)

「あとで直す」とだけ書かれたコメントは、永久に直りません。 ドキュメントを書くの ADR に「この負債を承知で選んだ」と記録するのが理想です。

決済サービスを呼び出す処理を実装します。設計として最も適切なのはどれですか。

実務の落とし穴まとめ

  1. 1つの関数が複数の理由で変更される — HTTP・業務・DB を分ける
  2. 業務ロジックが HTTP や DB に依存 — 依存の向きは一方向に
  3. 実装が1つしかない interface を量産 — 読む人の負担が増える
  4. time.Now() を処理の中で呼ぶ — テストが書けない
  5. 2箇所目で共通化する — 3回目まで待つ
  6. 見た目が同じだけのコードを共通化 — 変更理由が違えば分けたまま
  7. フラグ引数が増えていく — 共通化が間違っている合図
  8. リファクタリングと機能変更を同じ PR に混ぜる — レビューできない
  9. テストが無い状態で作り直す — 賭けになる
  10. 「あとで直す」とだけ書く — 期限も理由も無い負債は永久に残る

まとめ

  • 設計の目的は将来の変更コストを下げること。美しさではない
  • 変わりやすいものに、変わりにくいものを依存させない
  • 分ける基準は「同じ理由で・同じタイミングで変更されるか」
  • 依存は一方向に。必要なら 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-practiceshttps://google.github.io/eng-practices/

章末問題

「注文の合計金額に、キャンペーン割引を適用する」処理を追加します。どこに書くべきですか。

2つの画面で似た整形処理があります。共通化すべきか判断する基準はどれですか。

次は、同じ設計の話をもう一段細かい粒度で—— 名前・関数・コメントのレベルで扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。