16
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SOLID原則を“暗記で終わらせない”ために:料金計算で腹落ちさせる

Last updated at Posted at 2026-01-22

新卒で実装していると、こんな場面ありませんか?

  • 「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変更で計算ロジック修正
16
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?