01.6 SOLID と定番パターン — 変更に強い設計(SRP / OCP / DIP)¶
これまでに学んだ「構造体・メソッド・埋め込み・interface・generics」を使って、 変更に強い設計をするための原則を学びます。
SOLID は 5 つの原則の頭文字ですが、このレッスンでは特にバックエンドで効く 次の 3 つに絞って、コードのリファクタリングを実演します。
- SRP(単一責任)— 1 つの型に 1 つの責任
- OCP(開放閉鎖)— 拡張には開かれ、変更には閉じている
- DIP(依存の反転)— 上位は下位の「具体的な実装」でなく「抽象(interface)」に依存する
このレッスンのゴール:
- 1 つの型に複数の責任が混ざったコードを見分けられる
- 責任を分離して、挙動を変えずにリファクタリングできる
- interface に依存する設計で、実装を差し替えやすくできる
1. SRP: 1 つの型に 1 つの責任¶
悪い例: 次の OrderHandler は 1 つで「注文を読む」「注文を検証する」「通知する」の
3 つの責任を持っています。
import (
"errors"
"fmt"
"strings"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
type Order struct {
ID string
Total int
Customer string
}
// BadOrderHandler は SRP 違反: 読み取り・検証・通知を全部 1 つの型でやる。
type BadOrderHandler struct{}
func (h *BadOrderHandler) Handle(raw Order) error {
// 責任 1: 注文を「検証」する
if raw.ID == "" {
return errors.New("order ID is empty")
}
if raw.Total <= 0 {
return errors.New("order total must be positive")
}
// 責任 2: 注文を「記録」する(簡易実装: ログ出力)
fmt.Printf("注文 %s を記録しました(合計 %d 円, %s 様)\n", raw.ID, raw.Total, raw.Customer)
// 責任 3: 注文を「通知」する
fmt.Printf("[Email] %s 様、ご注文が確定しました\n", raw.Customer)
return nil
}
%%
h := &BadOrderHandler{}
_ = h.Handle(Order{ID: "ORD-1", Total: 3000, Customer: "佐藤"})
fmt.Println("→ 検証・記録・通知が 1 つの Handle に混ざっている")
注文 ORD-1 を記録しました(合計 3000 円, 佐藤 様) [Email] 佐藤 様、ご注文が確定しました → 検証・記録・通知が 1 つの Handle に混ざっている
問題点:
- 通知方法を変えたくても、
Handle全体を触る - 「検証だけをテストしたい」のに、通知まで実行される
- 責任が増えるほど型が肥大する
SRP の考え方: 1 つの型は 1 つの理由でしか変わらないように、責任を分離します。
2. SRP 準拠に分解する(挙動は同じまま)¶
「検証」「記録」「通知」をそれぞれ独立の型に分け、Handle はそれらを組み立てるだけにします。
// 検証の責任
type OrderValidator struct{}
func (v *OrderValidator) Validate(o Order) error {
if o.ID == "" {
return errors.New("order ID is empty")
}
if o.Total <= 0 {
return errors.New("order total must be positive")
}
return nil
}
// 記録の責任
type OrderRecorder struct{}
func (r *OrderRecorder) Record(o Order) {
fmt.Printf("注文 %s を記録しました(合計 %d 円, %s 様)\n", o.ID, o.Total, o.Customer)
}
// 通知の責任
type OrderNotifier struct{}
func (n *OrderNotifier) Notify(o Order) {
fmt.Printf("[Email] %s 様、ご注文が確定しました\n", o.Customer)
}
// GoodOrderHandler は責任の「組み立て」だけをする
type GoodOrderHandler struct {
validator OrderValidator
recorder OrderRecorder
notifier OrderNotifier
}
func (h *GoodOrderHandler) Handle(o Order) error {
if err := h.validator.Validate(o); err != nil {
return err
}
h.recorder.Record(o)
h.notifier.Notify(o)
return nil
}
%%
g := &GoodOrderHandler{}
_ = g.Handle(Order{ID: "ORD-1", Total: 3000, Customer: "佐藤"})
fmt.Println("→ 同じ挙動。ただし責任ごとに独立している")
注文 ORD-1 を記録しました(合計 3000 円, 佐藤 様) [Email] 佐藤 様、ご注文が確定しました → 同じ挙動。ただし責任ごとに独立している
結果: 出力は前と同じ(挙動は変わらない)ですが、通知を変えたい時は OrderNotifier だけを、
検証をテストしたい時は OrderValidator だけを触れば良い構造になりました。
3. OCP / DIP: 抽象に依存して、拡張に対して開く¶
次の AlertService は、コンストラクタで注入された Notifier(01.4 の interface)に依存します。
具体的な通知方法を知りません。
この設計で、新しい通知方法を足す時(拡張)に、AlertService を一切変更しません(変更に閉じている)。
これが OCP と DIP です。
type Notifier interface {
Notify(message string) error
}
// AlertService は Notifier(抽象)にだけ依存する。
// 具体的な通知方法はコンストラクタで注入される(依存性注入 DI)。
type AlertService struct {
notifier Notifier
}
func NewAlertService(n Notifier) *AlertService {
return &AlertService{notifier: n}
}
func (s *AlertService) Send(alert string) error {
return s.notifier.Notify("アラート: " + alert)
}
type LogN struct{}
func (n LogN) Notify(message string) error {
fmt.Printf("[Log] %s\n", message)
return nil
}
type EmailN struct{ addr string }
func (e EmailN) Notify(msg string) error {
fmt.Printf("[Email → %s] %s\n", e.addr, msg)
return nil
}
%%
logN := LogN{}
svcLog := NewAlertService(logN)
_ = svcLog.Send("サーバー負荷が高い")
// 通知方法を Email に差し替えても AlertService は無変更
svcEmail := NewAlertService(EmailN{addr: "taro@example.com"})
_ = svcEmail.Send("サーバー負荷が高い")
fmt.Println("→ 同じ Send が、注入する Notifier に応じて異なる通知をする")
[Log] アラート: サーバー負荷が高い [Email → taro@example.com] アラート: サーバー負荷が高い → 同じ Send が、注入する Notifier に応じて異なる通知をする
読んでください: AlertService は 1 回書いたら最後まで変更していません。
通知方法の追加・差し替えは「Notifier を実装した新しい型を作る」だけで済みます。
これが OCP(拡張に開く) と DIP(抽象に依存) の実践です。
4. 直感・類推: レストランの厨房¶
- SRP: 料理人・レジ係・ウェイターは役割が分かれています。全員が全部をやる厨房は 注文が混ざります(= 肥大した型)
- OCP: 新しいメニュー(料理)を追加しても、厨房の設備(フロー)は変えません
- DIP: お客さん(上位)は「料理が出てくる」(抽象)に依存していて、シェフ個人(具象)には依存していない
バックエンドでは、ログ・通知・決済・DB など外部と繋がるものは interface に抽象化し、 実装を差し替えられる設計が標準です(テストでモックに差し替えるためにも必須です)。
練習問題 1.6: OrderService を実装しよう¶
OrderService に Complete メソッドを実装してください。
仕様:
type OrderService struct {
notifier Notifier
}
func NewOrderService(n Notifier) *OrderService {
return &OrderService{notifier: n}
}
func (s *OrderService) Complete(orderID string) error
Completeはs.notifier.Notify("注文 " + orderID + " が完了しました")を呼んで返す
ポイント: このメソッドは注入された抽象(Notifier)にだけ依存しています。
具体的な通知方法を一切知らないのが DIP の実装です。
// YOUR CODE HERE
// OrderService の型・NewOrderService・Complete を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
type OrderService struct {
notifier Notifier
}
func NewOrderService(n Notifier) *OrderService {
return &OrderService{notifier: n}
}
func (s *OrderService) Complete(orderID string) error {
return ErrUnanswered
}
チェックのためのヘルパー¶
import "reflect"
import "fmt"
func mustEqual(got, want any, name string) {
if reflect.DeepEqual(got, want) {
fmt.Printf("✅ Passed: %s\n", name)
return
}
panic(fmt.Sprintf("❌ %s\n got = %v (%T)\n want = %v (%T)", name, got, got, want, want))
}
// チェック用の記録型 Notifier
type recorderNotifier struct {
messages []string
}
func (r *recorderNotifier) Notify(message string) error {
r.messages = append(r.messages, message)
return nil
}
%%
rec := &recorderNotifier{}
svc := NewOrderService(rec)
err := svc.Complete("ORD-1")
if errors.Is(err, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(err, nil, "Complete はエラーなし")
mustEqual(rec.messages, []string{"注文 ORD-1 が完了しました"}, "注入された notifier が使われた")
mustEqual(len(rec.messages), 1, "1 回だけ通知された")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- SRP: 1 つの型に 1 つの責任。肥大した型は責任ごとに分ける
- OCP: 新しい機能は「既存を変更せず、追加で」実現する
- DIP: 上位は具象ではなく抽象(interface)に依存し、実装はコンストラクタで注入する
- 挙動を変えないリファクタリングを、前後で実行して確認する(このコース全体の流儀)
これで Module 1(オブジェクト指向)は完了です。答え合わせは 01.6-solid-design-patterns-solutions.ipynb。
次は Module 2 でデータベースを学びます。