【SOLID原則(OCP編)】開発現場最恐のジレンマが解決!?拡張性は「本質」の見極めが決め手

はじめに
前回はSRPを学びました。
そこでは各クラスの責務はたった1つに適切に規定しなければ、あっという間にコードの取り合いや冗長な防衛コードで溢れかえってしまう状況を見てきました。
今回は、開発者が日常的に抱える最も深刻なジレンマについての原則です。
開発現場最恐のジレンマとは?
「機能追加はどんどんしたい。でも、今動いているコードには指一本触れたくない」
矛盾しているように聞こえるこのジレンマを解決する魔法、それがOCPです。
OCP: 「ソフトウェアの構成要素は、拡張に対してオープン(開いている)であり、変更に対してクローズ(閉じている)であるべき」
OCPを「プラグイン」とイメージする
この原則を直感的に理解するために、「Webブラウザと拡張機能(プラグイン)」の関係を想像してください。
あなたはブラウザに好きな拡張機能をインストールして、機能をどんどん追加できます(Open)。
しかし、拡張機能を追加するたびに、ブラウザ本体のソースコードを書き換えたり、ブラウザを作り直したりする必要はあるでしょうか?
ありませんね。
ブラウザ本体は、拡張機能の追加という変更の影響を受けません(Closed)
このような、「本体(本質)」と「プラグイン(仕様)」の関係を、あなたのコードの中に作り出すこと。
これがOCPのゴールです。
具体的なコードの状態
- Open(拡張に開いている): 新しい機能(仕様)を追加するとき、既存のコードを書き換えるのではなく、新しいクラス(ファイル)を追加するだけで済む状態。
- Closed(変更に閉じている): 新しいクラスを追加しても、呼び出し元のメインロジック(本質)は一切修正しなくていい状態。
「ベタ付け」が開発プロセスを破綻させる ~FizzBuzzの失敗~
では、OCPに違反したらどうなるのか?
簡単なFizzBuzzの実装を通じて、その「災難」を体感してみましょう。
FizzBuzzの機能要件:
- 整数の入力値をあるルールに従って変換表示する
- 3の倍数なら "Fizz"
- 5の倍数なら "Buzz"
- 両方なら "FizzBuzz"
- それ以外は入力値をそのまま表示
ベタ付けの実装(OCP違反)
要件に単純に従って、if文を連ねて実装してみます。
func fizzBuzz(num int) string{
if num % 3 == 0 && num % 5 == 0{
return "FizzBuzz"
}else if num % 3 == 0{
return "Fizz"
}else if num % 5 == 0{
return "Buzz"
}else{
return strconv.Itoa(num)
}
}
「ベタ付け」に隠された時限爆弾
このコードは動きます。しかし、ここには 「時限爆弾」 が埋まっています。
ここで新たな機能が追加されることを想像してください。
新たな機能内容:「7の倍数だったらJazzと表示する」
このために既存の関数の中にあるif文のロジックにメスを入れ、コードを 「手術」 しなければなりません。
手術における2つのリスク
-
既存機能の破壊リスク: if-elseの複雑な連鎖をいじることで、これまで動いていた「3の倍数」の判定ロジックをうっかり壊してしまうかもしれません。
-
テストの崩壊: たった1つのルールを追加しただけなのに、この関数全体が書き換わったため、既存の全てのルール(Fizz, Buzz, そのまま...)に対して再テストが必要になります。
機能が増えれば増えるほど、この関数は肥大化し、近寄りたくない恐怖の「開かずの間」となっていきます。
これがOCP違反の末路です。
仕様と抽象を分離する ~FizzBuzzの再生~
このジレンマを解決するには、「仕様(変わりやすいもの)」を削ぎ落とし、「本質(変わらないもの)」だけを残す必要があります。
恣意的なもの?本質の抽出
まず、FizzBuzzという機能を冷静に分析し 「仕様」と「本質」 に分解します。
仕様(Specification) = 開発者や要望によって恣意的に決められた内容
- 「3」や「5」という数字
- 「Fizz」や「Buzz」という文字列
- これらは、明日にも変わる可能性があります。
本質(Essence) = この機能が成立するための土台・仕組み
- 整数を入力として受け取る
- 変換ルールは複数存在するかもしれない
- ルールを順番に適用し、結果を累積する
- 最終的な文字列を返す
- これらは、ルールの中身が「7」になろうが「Jazz」になろうが、変わりません。
OCPを適用した構造
この分析に基づき、パッケージを2つに分けます。
- coreパッケージ(本質): ルールを順次適用する「ルールエンジン」の仕組みだけを持つ。具体的な「3」や「Fizz」のことは一切知らない。
- specパッケージ(仕様): 具体的なルール(CyclicnumberRule, PassThroughRule)を持つ。
UML図:

実装のイメージ
上記のような構成にすることで、コードは劇的に変わります。
まずは、本質(core)のコードです。
package core
type Converter struct {
Rules []Rule
}
func NewConverter(rules []Rule) *Converter {
return &Converter{Rules: rules}
}
func (f Converter) Execute(inputNumber int) string {
out := ""
for _, rule := range f.Rules {
if rule.Match(inputNumber, out) {
out = rule.Apply(inputNumber, out)
}
}
return out
}
package core
type Rule interface{
Match(n int, carry string) bool
Apply(n int, carry string) string
}
以下が、仕様(spec)の実装です。
package spec
type CyclicNumberRule struct{
base int
replacement string
}
func NewCyclicNumberRule(n int, replacement string) *CyclicNumberRule{
return &CyclicNumberRule{
base :n,
replacement: replacement,
}
}
func (c CyclicNumberRule) Match(n int, carry string) bool{
return n % c.base == 0
}
func (c CyclicNumberRule) Apply(n int, carry string) string{
return carry + c.replacement
}
package spec
import "strconv"
type PassThroughRule struct{
}
func NewPassThroughRule() *PassThroughRule{
return &PassThroughRule{}
}
func (p PassThroughRule) Match(n int, carry string) bool{
return carry == ""
}
func (p PassThroughRule) Apply(n int, carry string) string{
return strconv.Itoa(n)
}
最終的にmain.goで使ってみます。手順はたったの4ステップ
- match, applyを実装した具象クラスインスタンスを作成
- インターフェースのリストに格納
- Converterクラスインスタンスにインターフェースを持たせる
- Converterクラスメソッドであるexecuteに入力値を渡す
package main
import (
"fmt"
"FizzBuzz/core"
"FizzBuzz/spec"
)
func main() {
var rules []core.Rule
rules = append(rules, spec.NewCyclicNumberRule(3, "Fizz")) // 3で割り切れる時の処理
rules = append(rules, spec.NewCyclicNumberRule(5, "Buzz")) // 5で割り切れる時の処理
rules = append(rules, spec.NewPassThroughRule()) // 全ての条件が合致しない時の条件式・処理
converter := core.NewConverter(rules)
fmt.Println(converter.Execute(3)) //Fizzと返ってくる
fmt.Println(converter.Execute(5)) //Buzzと返ってくる
fmt.Println(converter.Execute(15)) //FizzBuzzと返ってくる
fmt.Println(converter.Execute(4)) //4が返ってくる
}
この構造にしたことで、先ほどの「7の倍数ならJazz」という追加要望はどうなるでしょうか?更に、「JazzはBuzzの前に必ず来るようにする」という希望にも応えてみましょう。
...
var rules []core.Rule
rules = append(rules, spec.NewCyclicNumberRule(3, "Fizz")) // 3で割り切れる時の処理
rules = append(rules, spec.NewCyclicNumberRule(7, "Jazz")) // 7で割り切れる時の処理
rules = append(rules, spec.NewCyclicNumberRule(5, "Buzz")) // 5で割り切れる時の処理
converter := core.NewConverter(rules)
fmt.Println(converter.Execute(3)) //Fizzと返ってくる
fmt.Println(converter.Execute(35)) //JazzBuzzと返ってくる
...
coreパッケージのメインロジックは、1行も書き換える必要がありません。もちろん、再テストも不要です
まるでブラウザに新しいプラグインを入れるように、安全かつ簡単に機能を拡張できるようになりました。
これこそが、OCPが目指す世界です。
本質を救い出すコツ
最後に、この「本質(Core)」をどうやって見極めれば良いのでしょうか?
ここで、パッケージ原則 SAP(安定抽象の原則) を思い出してください。
「抽象的であるほど、安定する」
本質を抽出する際の最大のヒントは 「このプログラムが長期運用されるとしたら、何が変わり、何が変わらないだろう?」 と問いかけることです。
- 変わりやすいもの(仕様) は、具象クラスとして外側に追い出す。
- 変わらないもの(本質) は、抽象(インターフェース)や制御ロジックとして中心に据える。
そして、「変わりやすい仕様」が「変わらない本質」に依存する形を作ること。
これはパッケージ原則の SDP(安定依存の原則) を守ることにもなり、結果としてシステム全体が堅牢になります。
OCPとは、単なるコーディングテクニックではありません。
プロダクトの「本質」を見極めて変化の波から守り抜くための、設計者の哲学なのです。
参考文献:「ちょうぜつソフトウェア設計入門――PHPで理解するオブジェクト指向の活用 著: 田中ひさてる」
Discussion