新卒で実装していると、こんな場面ありませんか?
- 「SOLID?聞いたことあるけど、説明しろと言われると詰まる」
- 「設計って、結局なにを意識すればいいの…?」
- 「仕様追加が怖い。どこを直せばいいかわからない」
この記事では 「料金計算(税・送料・割引・クーポン)」 を題材に、SOLIDを “変更が怖くなくなる観点” として理解できるように整理します。
(コードはPythonで、できるだけ素朴に書きます)
TL;DR
- SOLIDは「きれいに書くため」じゃなく、仕様変更を安全にするための5つの観点。
- 料金計算みたいに「ルールが増えがち」な領域だと効果が出やすい。
- Before(if地獄)→ After(責務分割+差し替え)で、5原則がつながって見えるようになる。
SOLIDとは
SOLIDは オブジェクト指向でよく使われる設計の5原則の頭字語で、目的は 設計を分かりやすく・柔軟にして・保守しやすくすることです。
SOLIDを「暗記→使える観点」にする1枚まとめ
| 原則 | 一言(What) | Why(守らないと何が困る?) | 料金計算での具体例 | 主戦場(覚えやすい分類) |
|---|---|---|---|---|
| SRP | 1つのモジュールは1つの責務(≒1つの変更理由) | 変更が衝突して壊れやすい | 税/送料/割引/確定処理が1箇所に混ざる | 責務 |
| OCP | 拡張に開き、修正に閉じる | 追加のたびに既存コードをいじって事故る | 新しい割引を足すたびにifが増える | 拡張 |
| LSP | 派生型は基底型として置換できる(差し替えても壊れない) | “同じ扱い”のはずが差し替えるとバグる | 割引ポリシーを差し替えたら合計が負になる等 | 置換 |
| ISP | 使わないメソッドへの依存を強制しない | 変更が波及して関係ない所が壊れる | 「計算だけしたい」人に「レシート整形」まで強制 | IF |
| DIP | 上位(方針)は下位(詳細)に依存しない。抽象に依存する | テスト不能・差し替え不能・外部都合に振り回される | 税率APIやDBにベタ依存してテストできない | 依存 |
題材:料金計算の要件
今回は例として、ざっくりこういう仕様を考えます。
- 小計 = 商品合計
- 送料:地域で変わる(例:離島は高い)
- 税:税率は外部サービスから取得する想定
- 割引:会員ランク割 / クーポン / キャンペーン(今後増える)
そして、現場で起きがちな変更はこう。
変更シナリオ(これが“事故”の原因)
| 変更 | よくある結果(SOLIDがないと) |
|---|---|
| ブラックフライデー割引を追加 | 既存のifを編集→別の割引が壊れる |
| 税率取得APIの仕様変更 | 料金計算ロジックまで巻き込まれる |
| 送料ルール変更(離島判定が変わる) | いろんな場所に同じ判定が散って修正漏れ |
Before → After でSOLIDを体感する
Before:ありがちな「全部入り料金計算」(つらい例)
まずは、よくある “とりあえず動く” 実装です(短くするために単純化してます)。
from dataclasses import dataclass
@dataclass
class Order:
subtotal: int # 商品合計(円)
region: str # "tokyo" / "remote" など
member_rank: str # "normal" / "gold"
coupon: str | None # "WELCOME10" など
def fetch_tax_rate_from_api(region: str) -> float:
# 本当は外部APIを叩く想定(ここでは固定値)
return 0.10 if region != "remote" else 0.08
def calculate_total(order: Order) -> int:
total = order.subtotal
# 割引(if地獄になりがち)
if order.member_rank == "gold":
total = int(total * 0.95)
if order.coupon == "WELCOME10":
total -= 10_000
# キャンペーンが増えるたびここが伸びる…
if total >= 100_000:
total = int(total * 0.97)
# 送料(地域別)
shipping = 0
if order.subtotal < 5_000:
shipping = 800
if order.region == "remote":
shipping += 1200
# 税(外部都合が混ざる)
tax_rate = fetch_tax_rate_from_api(order.region)
tax = int(total * tax_rate)
return total + shipping + tax
どこがつらい?(Why)
- 「割引追加」=
calculate_totalを編集 → 既存の条件分岐を壊しやすい - 税率APIの都合が料金計算に直撃 → テストもしづらい
- 送料/税/割引が1箇所に混ざる → 読みづらい&修正漏れしやすい
Beforeの依存(全部が1点に集まる)
[calculate_total]
|-- 割引ルール(if...)
|-- 送料ルール(if...)
|-- 税率API(fetch_tax_rate_from_api)
S:SRP(単一責任)— まず「変更理由」で分ける
What
SRPは「1つのモジュールは1つのアクター(変更要求者)に責任を持つ」や、「1つのクラスは1つの理由でしか変更されない」として説明されます。
Why:料金計算で起きる痛み
- 税:税制や外部APIで変わる
- 送料:物流や地域判定で変わる
- 割引:販促で増える
→ 変わる理由が違うのに1箇所にあると、変更が衝突します
How:責務ごとに分ける(まずは素直に)
from dataclasses import dataclass
from typing import Protocol
@dataclass
class Order:
subtotal: int
region: str
member_rank: str
coupon: str | None
class TaxRateProvider(Protocol):
def get_tax_rate(self, region: str) -> float: ...
class SimpleTaxRateProvider:
def get_tax_rate(self, region: str) -> float:
return 0.10 if region != "remote" else 0.08
class TaxCalculator:
def __init__(self, tax_rate_provider: TaxRateProvider):
self._provider = tax_rate_provider
def calc_tax(self, taxable_amount: int, region: str) -> int:
rate = self._provider.get_tax_rate(region)
return int(taxable_amount * rate)
class ShippingCalculator:
def calc_shipping(self, subtotal: int, region: str) -> int:
shipping = 0
if subtotal < 5_000:
shipping = 800
if region == "remote":
shipping += 1200
return shipping
ここではまだ割引に手を付けません。先に「税」「送料」を切り出して、
calculate_totalの“混ざり”を減らします。
O:OCP(開放閉鎖)— 割引は「追加」で増やしたい
What
OCPは「拡張に対して開いていて、修正に対して閉じているべき」です。
Why:料金計算で一番効く
割引は増えるのが普通です。
「追加のたびに1箇所を編集」だと、いつか事故ります。
How:割引をStrategyとして“差し替え可能”にする
from typing import Iterable
class DiscountPolicy(Protocol):
def apply(self, current_total: int, order: Order) -> int: ...
class GoldMemberDiscount:
def apply(self, current_total: int, order: Order) -> int:
if order.member_rank == "gold":
return int(current_total * 0.95)
return current_total
class WelcomeCouponDiscount:
def apply(self, current_total: int, order: Order) -> int:
if order.coupon == "WELCOME10":
return current_total - 10_000
return current_total
class BulkDiscount:
def apply(self, current_total: int, order: Order) -> int:
if current_total >= 100_000:
return int(current_total * 0.97)
return current_total
class DiscountApplier:
def __init__(self, policies: Iterable[DiscountPolicy]):
self._policies = list(policies)
def apply_all(self, subtotal: int, order: Order) -> int:
total = subtotal
for p in self._policies:
total = p.apply(total, order)
return total
新しい割引を追加するときは、DiscountPolicy を実装したクラスを1つ足して、リストに入れるだけ。
既存の割引実装や計算フローを“編集しない”方向に寄せられます。
L:LSP(リスコフの置換)— 「差し替えても壊れない」契約
What
LSPはざっくり言うと「サブクラス(派生)が、親クラス(基底)の代わりとして使えて、プログラムの意味が壊れないこと」です。
Why:OCPをやると、LSPの重要性が上がる
OCPで “差し替え可能” にすると、次の事故が起きがちです:
- 「割引のつもりで入れたクラス」が、合計をマイナスにして後続処理が崩れる
- 「割引のつもり」が、実は値上げ(サーチャージ)をしてしまう
つまり「割引」の契約が曖昧だと、差し替えが危険になります。
How:DiscountPolicyの“契約”を決める
ここではシンプルに、割引の契約をこう定義します:
-
applyは 現在の合計以下 を返す(値上げしない) - 返り値は 0以上
これを守らない実装が入ると危険。
class BadDiscount:
# ❌ 割引のはずが合計を増やす(契約違反)
def apply(self, current_total: int, order: Order) -> int:
return current_total + 500
class SafeDiscountApplier(DiscountApplier):
def apply_all(self, subtotal: int, order: Order) -> int:
total = subtotal
for p in self._policies:
new_total = p.apply(total, order)
# “契約”チェック(本番で常にやるかは状況次第)
if new_total > total:
raise ValueError("DiscountPolicy must not increase total")
if new_total < 0:
raise ValueError("Total must be >= 0")
total = new_total
return total
ポイントは「継承する/しない」より 置換できる契約が保たれているか。
LSPは「差し替え可能設計(OCP)」の安全装置として効きます。
I:ISP(インターフェース分離)— “使わない依存”を減らす
What
ISPは「使わないメソッドに依存させられるべきではない」という原則です。
Why:料金計算でよくある罠
例えば「管理画面で税だけ見たい」「見積もりだけ出したい」など、利用者(クライアント)が複数出てきます。
そのとき「巨大サービスクラス」に全部生やすと、関係ない変更が波及しがちです。
How:クライアント別に小さく分ける(PythonならProtocolが便利)
class TotalCalculator(Protocol):
def calc_total(self, order: Order) -> int: ...
class TaxOnlyCalculator(Protocol):
def calc_tax(self, taxable_amount: int, region: str) -> int: ...
- 見積もり画面は
TotalCalculatorだけに依存 - 税表示画面は
TaxOnlyCalculatorだけに依存
こうしておくと、例えば「レシートの表示形式を変えたい」という変更で、見積もり側が巻き込まれにくくなります。
D:DIP(依存性逆転)— “方針”を“詳細”から守る
What
DIPは色々な言い方がありますが、例えば
「高レベルの方針は低レベルの詳細に依存すべきでない」「抽象に依存する」という形で説明されます。
Why:料金計算で地味に効く
税率APIやDBなどの“詳細”に料金計算が直接依存すると:
- テストが難しい(外部がないと動かない)
- 詳細変更に引きずられる(API変更で計算ロジックまで修正)
How:上位(料金計算)→ 抽象(Protocol)→ 下位(実装)にする
すでに TaxRateProvider を Protocol にして、TaxCalculator がそれに依存する形にしました。
同じ要領で、永続化も抽象に寄せられます。
class OrderRepository(Protocol):
def save(self, order: Order, total: int) -> None: ...
class InMemoryOrderRepository:
def __init__(self):
self.saved = []
def save(self, order: Order, total: int) -> None:
self.saved.append((order, total))
class CheckoutService:
def __init__(
self,
discount_applier: DiscountApplier,
shipping_calculator: ShippingCalculator,
tax_calculator: TaxCalculator,
repo: OrderRepository,
):
self._discounts = discount_applier
self._shipping = shipping_calculator
self._tax = tax_calculator
self._repo = repo
def checkout(self, order: Order) -> int:
discounted = self._discounts.apply_all(order.subtotal, order)
shipping = self._shipping.calc_shipping(order.subtotal, order.region)
tax = self._tax.calc_tax(discounted, order.region)
total = discounted + shipping + tax
self._repo.save(order, total)
return total
これで CheckoutService は「DBがSQLiteか」「税率がどのAPIか」みたいな詳細から距離を取れます。
(テストでは InMemoryOrderRepository やスタブの TaxRateProvider を差し込めばOK)
After:全体像
Afterの依存(責務と差し替え点が見える)
[CheckoutService] (方針)
|-- DiscountApplier ----> [DiscountPolicy ...] (追加で拡張 / OCP)
|-- ShippingCalculator
|-- TaxCalculator ----> TaxRateProvider(抽象) ----> (API実装) (DIP)
|-- OrderRepository(抽象) -----------------------> (DB実装) (DIP)
変更シナリオに戻ると?
- ブラックフライデー割引追加
→DiscountPolicyを1クラス追加(中心の計算フローは基本触らない) - 税率API変更
→TaxRateProviderの実装差し替え(計算ロジックは守られる) - 送料変更
→ShippingCalculatorの責務内で完結しやすい
“やりすぎ”注意(SOLIDは目的じゃなく道具)
SOLIDは強力ですが、次の状態になると逆効果になりがちです。
- 将来のための抽象化を先にやりすぎて読みにくい
- 小さい変更しか来てないのに、IFやクラスが増えすぎて迷子
- 「原則を守ること」が目的化して、なぜ分けたか説明できない
原則はあくまで 設計判断の補助ツールとして使うのが安全です。
実務で効く:コードレビューで使えるチェックリスト
「理解したつもり」から一歩進めるなら、指摘の言葉に落とすのが強いです。
| 観点 | レビューでの質問 | よくあるNGサイン | 料金計算の例 |
|---|---|---|---|
| SRP | 「このクラスが変わる理由、何個ある?」 | 税/送料/割引/保存が同居 |
calculate_totalが全部やってる |
| OCP | 「新しい割引、どこを編集する?」 | if/switchに追記 | 割引追加=中心関数を編集 |
| LSP | 「差し替えたら結果の性質は保たれる?」 | サブクラスが前提を増やす | “割引”なのに値上げする |
| ISP | 「この依存、使わない機能まで抱えてない?」 | “万能サービス”に全部集約 | 見積もりがレシート整形にも依存 |
| DIP | 「方針が詳細に引きずられてない?」 | 外部API直叩きでテスト不能 | 税率API変更で計算ロジック修正 |