A 弱点補強 — typing.Protocol × @runtime_checkable × Structural Subtyping × NotificationResult Value Object(MOps 通知システム Bad→Good Ch2/Ch4/Ch5/Ch10)

2026-06-28 (Day 50) 日曜 弱点補強 ★★★★☆ Python 3.12 / Protocol / runtime_checkable / Structural Subtyping Value Object / Open-Closed 原則 / ポリモーフィズム / Fail-Fast

概要

🦆

Protocol = 型安全な Duck Typing(Structural Subtyping)

typing.Protocol(PEP 544)は「明示的な継承なしに、メソッドシグネチャが一致すれば型互換」という Structural Subtyping を実現する。EmailNotifierNotifier を継承していなくても、send(recipient: str, message: str) -> None を持つだけで mypy / pyright は Notifier 互換と判断する。if isinstance(x, EmailNotifier) 分岐は完全に不要になる。

🔍

@runtime_checkable で isinstance が使える

Protocol に @runtime_checkable を付けると isinstance(notifier, Notifier) が動作する。ただし実行時チェックはメソッドの存在のみ確認し、シグネチャは見ない。そのため静的型チェック(mypy/pyright)との組み合わせが必須。Fail-Fast パターンで「ループ前に全 Notifier を検証」することで、ループ内での型エラーを防ぐ。

📦

NotificationResult = Value Object(frozen dataclass)

通知結果を list[bool] でなく @dataclass(frozen=True, slots=True) NotificationResult で表現する。success / channel / recipient / error を一つの不変オブジェクトにまとめることで、ログ集計・BigQuery 書き込み・DataDog メトリクス送信が型安全になる。frozen=True は誤った書き換えを防止し、ハッシュ可能にする。

🔓

Open/Closed 原則 — 新チャンネル追加時にコア変更ゼロ

Bad コードは新チャンネルのたびに dispatch_notificationif-elif を追加していた。Good コードは send(recipient, message) メソッドを持つクラスを追加するだけで動作する。send_campaign_notifications は一切変更不要。これが Open/Closed 原則(拡張に対してオープン、変更に対してクローズ)の実践。

問題

ECサイト MOps チームの通知システムを設計している。現在は各通知チャンネル(メール・SMS・Slack)が if isinstance(notifier, EmailNotifier) のような型チェックで分岐されており、新チャンネル追加のたびに分岐が増殖している。

以下の「悪いコード」を分析し、Protocol / ABC / runtime_checkable / Structural Subtyping を活用して Bad→Good にリファクタリングせよ。

制約・前提条件

  • Python 3.12+
  • typing.Protocol@runtime_checkable を使い、構造的サブタイピングで Notifier インターフェースを定義すること
  • 各 Notifier クラスは Protocol を継承せずとも静的型チェックで互換とみなされること(Structural Subtyping)
  • dataclass(frozen=True)NotificationResult を定義し、成功/失敗・チャンネル・受信者・エラーを記録すること
  • エラーは logging で記録し、握りつぶさないこと
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
  • send_campaign_notifications は Fail-Fast で未知の Notifier を事前検出できること
期待する回答形式: (1) 問題点の列挙(番号付き) (2) 改善後コード(完全版) (3) 実行例(input→output) (4) 適用した設計パターン名と書籍対応章

悪いコード (Before)

このコードには 6つの設計上の問題 が隠れています。見つけてみてください。
bad_notifier.py — isinstance 乱用・メソッド名バラバラ・エラー握りつぶし
import smtplib
from email.mime.text import MIMEText

class EmailNotifier:
    def __init__(self, smtp_host, port, user, password):
        self.smtp_host = smtp_host
        self.port = port
        self.user = user
        self.password = password

    def send(self, to, message):
        # 問題1: 型ヒントなし。何が渡されるか不明
        msg = MIMEText(message)
        msg["From"] = self.user
        msg["To"] = to
        msg["Subject"] = "通知"  # 問題2: マジックストリング
        with smtplib.SMTP(self.smtp_host, self.port) as server:
            server.login(self.user, self.password)
            server.sendmail(self.user, to, msg.as_string())

class SmsNotifier:
    def __init__(self, api_key, from_number):
        self.api_key = api_key
        self.from_number = from_number

    def send_sms(self, phone, text):
        # 問題3: メソッド名が統一されていない(send_sms)
        print(f"SMS to {phone}: {text}")

class SlackNotifier:
    def notify(self, channel, msg):
        # 問題3: メソッド名が統一されていない(notify)
        print(f"Slack #{channel}: {msg}")

def dispatch_notification(notifier, recipient, message):
    # 問題4: isinstance チェックで分岐。新チャンネルのたびに変更が必要
    if isinstance(notifier, EmailNotifier):
        notifier.send(recipient, message)
    elif isinstance(notifier, SmsNotifier):
        notifier.send_sms(recipient, message)
    elif isinstance(notifier, SlackNotifier):
        notifier.notify(recipient, message)
    else:
        raise ValueError(f"未知の Notifier: {type(notifier)}")

def send_campaign_notifications(notifiers, recipients, message):
    # 問題5: 型ヒントなし
    results = []
    for notifier in notifiers:
        for recipient in recipients:
            try:
                dispatch_notification(notifier, recipient, message)
                results.append(True)
            except Exception:
                results.append(False)
                # 問題6: エラーを握りつぶしている。失敗原因が追跡不能
    return results

ヒント

ヒント1: 方向性(クリックで展開)

dispatch_notificationif isinstance(...) 分岐を撲滅するには、全 Notifier クラスに 統一されたメソッドシグネチャ を持たせる必要がある。Python で「明示的な継承なしに型互換を保証する」メカニズムを探してみよう。

キーワード: typing.Protocol (PEP 544) / Structural Subtyping / Duck Typing

ヒント2: アプローチ(クリックで展開)

typing.Protocol を使うと、クラスが Protocol を継承していなくても「同じメソッドシグネチャを持っていれば型互換」とみなせる(Structural Subtyping)。

@runtime_checkable デコレータを付けると isinstance() によるランタイムチェックも可能になる。

統一するメソッドシグネチャ:

def send(self, recipient: str, message: str) -> None: ...
ヒント3: 骨格コード(クリックで展開)
from typing import Protocol, runtime_checkable
from dataclasses import dataclass

@runtime_checkable
class Notifier(Protocol):
    def send(self, recipient: str, message: str) -> None: ...

@dataclass(frozen=True, slots=True)
class NotificationResult:
    success: bool
    channel: str
    recipient: str
    error: str | None = None

# 各クラスは Notifier を継承しない — send() があれば型互換
class SmsNotifier:
    def send(self, recipient: str, message: str) -> None: ...

# dispatch_notification は不要になる!
# send_campaign_notifications が直接 notifier.send() を呼ぶ

問題点分析(6つ)

1
型ヒントなし

全クラスのメソッドに型ヒントがなく、引数・戻り値が不明。mypy/pyright が何もチェックできない。→ def send(self, recipient: str, message: str) -> None: で明示。

2
マジックストリング

"通知" がハードコードされている。変更が必要になったとき検索・修正が困難。→ DEFAULT_SUBJECT: Final[str] = "MOps キャンペーン通知" で名前付き定数化。

3
メソッド名の不統一

send / send_sms / notify と三者三様。dispatch_notification が各クラスのメソッド名を知らなければ呼べない。→ typing.Protocolsend() に統一。

4
isinstance 分岐(Open/Closed 原則違反)

新チャンネル追加のたびに dispatch_notification を修正する必要がある。→ Protocol で統一されたシグネチャを定義することで dispatch_notification 自体が不要になる。

5
戻り値が list[bool](情報不足)

失敗したのがどのチャンネル・どの受信者か分からない。→ NotificationResult(frozen=True) Value Object で success/channel/recipient/error を記録。

6
エラーの握りつぶし

except Exception: results.append(False) でエラー情報がすべて消える。DataDog / Cloud Logging で障害を追跡不能。→ logger.error(..., exc_info=True) で記録し、NotificationResult(error=str(exc)) に格納。

設計パターン図(Before / After)

❌ BEFORE — isinstance 分岐 dispatch_notification() if isinstance(EmailNotifier) elif isinstance(SmsNotifier) ... EmailNotifier def send() SmsNotifier def send_sms() SlackNotifier def notify() ⚠ 新チャンネルのたびに dispatch を変更 ⚠ メソッド名バラバラ ⚠ エラー握りつぶし ✅ AFTER — Protocol + Structural Subtyping «Protocol» Notifier @runtime_checkable def send(recipient: str, msg: str) → None EmailNotifier def send() ✓ 構造的互換 structural SmsNotifier def send() ✓ 構造的互換 SlackNotifier def send() ✓ 構造的互換 send_campaign_notifications() notifiers: list[Notifier] notifier.send(recipient, message) isinstance() Fail-Fast NotificationResult @dataclass(frozen=True, slots=True) success: bool / channel: str recipient: str / error: str | None LINE Notify 新追加

左: isinstance 分岐(新チャンネルのたびに dispatch 変更が必要)/ 右: Protocol で統一(新チャンネルは send() を持つクラスを追加するだけ)

模範解答

bad_notifier.py — 問題点(6箇所)
import smtplib
from email.mime.text import MIMEText

class EmailNotifier:
    def __init__(self, smtp_host, port, user, password):
        ...
    def send(self, to, message):  # ❌ 型ヒントなし
        msg = MIMEText(message)
        msg["Subject"] = "通知"  # ❌ マジックストリング
        ...

class SmsNotifier:
    def send_sms(self, phone, text):  # ❌ メソッド名バラバラ
        ...

class SlackNotifier:
    def notify(self, channel, msg):  # ❌ メソッド名バラバラ
        ...

def dispatch_notification(notifier, recipient, message):
    if isinstance(notifier, EmailNotifier):  # ❌ OCP違反
        notifier.send(recipient, message)
    elif isinstance(notifier, SmsNotifier):
        notifier.send_sms(recipient, message)
    elif isinstance(notifier, SlackNotifier):
        notifier.notify(recipient, message)
    else:
        raise ValueError(...)

def send_campaign_notifications(notifiers, recipients, message):
    # ❌ 型ヒントなし / 戻り値 list[bool](情報不足)
    results = []
    for notifier in notifiers:
        for recipient in recipients:
            try:
                dispatch_notification(notifier, recipient, message)
                results.append(True)
            except Exception:
                results.append(False)  # ❌ エラー握りつぶし
    return results
good_notifier.py — Protocol + Structural Subtyping(Ch2/Ch4/Ch10)
"""MOps 通知システム — Protocol + Structural Subtyping による型安全通知.

適用パターン:
    Strategy   (Ch4): 振る舞いを Protocol で抽象化
    Value Object (Ch2): NotificationResult
    Protocol / Structural Subtyping (Ch10)
"""
from __future__ import annotations

import logging
import smtplib
from dataclasses import dataclass
from email.mime.text import MIMEText
from typing import Final, Protocol, runtime_checkable

logger = logging.getLogger(__name__)

# ── 定数(マジックストリング廃絶)────────────────────
DEFAULT_SUBJECT: Final[str] = "MOps キャンペーン通知"
EMAIL_ENCODING: Final[str] = "utf-8"

# ── Value Object ─────────────────────────────────────
@dataclass(frozen=True, slots=True)
class NotificationResult:
    """通知結果 Value Object(不変・ハッシュ可能).

    Attributes:
        success:   通知が成功したか
        channel:   チャンネル名("EmailNotifier" 等)
        recipient: 送信先識別子
        error:     失敗時エラーメッセージ(成功時 None)
    """
    success: bool
    channel: str
    recipient: str
    error: str | None = None

# ── Notifier Protocol ────────────────────────────────
@runtime_checkable  # isinstance(x, Notifier) を有効化
class Notifier(Protocol):
    """マルチチャンネル通知インターフェース.

    Structural Subtyping: このクラスを継承しなくても
    ``send()`` シグネチャが一致すれば型互換とみなされる。
    """
    def send(self, recipient: str, message: str) -> None:
        """通知を送信する.

        Args:
            recipient: 送信先識別子
            message:   通知本文
        """
        ...

# ── 各実装(Protocol を継承しない — 構造的互換)────────
class EmailNotifier:
    """SMTP メール通知チャンネル."""

    def __init__(
        self, smtp_host: str, port: int,
        user: str, password: str,
        subject: str = DEFAULT_SUBJECT,
    ) -> None:
        self._smtp_host = smtp_host
        self._port = port
        self._user = user
        self._password = password
        self._subject = subject  # 名前付き定数をデフォルト値に

    def send(self, recipient: str, message: str) -> None:
        """メールを送信する.

        Args:
            recipient: 送信先メールアドレス
            message:   メール本文

        Raises:
            smtplib.SMTPException: SMTP 接続失敗
        """
        msg = MIMEText(message, "plain", EMAIL_ENCODING)
        msg["From"] = self._user
        msg["To"] = recipient
        msg["Subject"] = self._subject
        with smtplib.SMTP(self._smtp_host, self._port) as server:
            server.login(self._user, self._password)
            server.sendmail(self._user, recipient, msg.as_string())
        logger.info("email sent", extra={"to": recipient})

class SmsNotifier:
    """SMS 通知チャンネル(Twilio 等).

    Bad: send_sms() → Good: send() に統一
    """

    def __init__(self, api_key: str, from_number: str) -> None:
        self._api_key = api_key
        self._from_number = from_number

    def send(self, recipient: str, message: str) -> None:
        """SMS を送信する.

        Args:
            recipient: 電話番号(E.164形式)
            message:   SMS 本文
        """
        # 実際は Twilio SDK: client.messages.create(...)
        logger.info("sms sent", extra={"to": recipient})

class SlackNotifier:
    """Slack 通知チャンネル.

    Bad: notify() → Good: send() に統一
    """

    def __init__(self, webhook_url: str) -> None:
        self._webhook_url = webhook_url

    def send(self, recipient: str, message: str) -> None:
        """Slack チャンネルに通知する.

        Args:
            recipient: チャンネル名(例: "#mops-alert")
            message:   通知本文
        """
        # 実際は requests.post(self._webhook_url, ...)
        logger.info("slack sent", extra={"channel": recipient})

# ── エントリーポイント ────────────────────────────────
def send_campaign_notifications(
    notifiers: list[Notifier],     # 型安全な引数
    recipients: list[str],
    message: str,
) -> list[NotificationResult]:     # 情報豊富な戻り値
    """複数チャンネル × 複数受信者へキャンペーン通知を送信する.

    dispatch_notification は不要: 全 Notifier が send() を持つため
    直接呼び出せる(ポリモーフィズム)。

    Args:
        notifiers:  通知チャンネルリスト(Notifier 互換)
        recipients: 送信先リスト
        message:    通知本文

    Returns:
        各 (notifier × recipient) の NotificationResult

    Raises:
        TypeError: Notifier 非互換オブジェクトが含まれる場合(Fail-Fast)
    """
    # Fail-Fast: ループ前に全 Notifier の互換性を一括検証
    for notifier in notifiers:
        if not isinstance(notifier, Notifier):
            raise TypeError(
                f"{type(notifier).__name__} は Notifier Protocol に非互換。"
                f"send(recipient: str, message: str) -> None を実装してください。"
            )

    results: list[NotificationResult] = []
    for notifier in notifiers:
        channel_name = type(notifier).__name__
        for recipient in recipients:
            try:
                notifier.send(recipient, message)  # dispatch 不要!
                results.append(NotificationResult(
                    success=True,
                    channel=channel_name,
                    recipient=recipient,
                ))
            except Exception as exc:
                # エラーを握りつぶさず logging で記録する
                logger.error(
                    "notification failed",
                    extra={
                        "channel": channel_name,
                        "recipient": recipient,
                        "error": str(exc),
                    },
                    exc_info=True,  # スタックトレースも記録
                )
                results.append(NotificationResult(
                    success=False,
                    channel=channel_name,
                    recipient=recipient,
                    error=str(exc),  # Result に格納して呼び出し元へ
                ))

    return results

# ── 実行例 ────────────────────────────────────────────
if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(levelname)s %(message)s",
    )

    notifiers: list[Notifier] = [
        SmsNotifier(api_key="dummy_key", from_number="+810000000000"),
        SlackNotifier(webhook_url="https://hooks.slack.com/dummy"),
    ]

    results = send_campaign_notifications(
        notifiers=notifiers,
        recipients=["#mops-alert", "+818012345678"],
        message="【キャンペーン】今週末限定セール開催中!",
    )

    for r in results:
        status = "✅" if r.success else "❌"
        print(f"{status} [{r.channel}] → {r.recipient}")
        if r.error:
            print(f"   エラー: {r.error}")
実行結果:
INFO sms sent {'to': '#mops-alert'}
INFO slack sent {'channel': '#mops-alert'}
INFO sms sent {'to': '+818012345678'}
INFO slack sent {'channel': '+818012345678'}
✅ [SmsNotifier] → #mops-alert
✅ [SlackNotifier] → #mops-alert
✅ [SmsNotifier] → +818012345678
✅ [SlackNotifier] → +818012345678

ポイント解説

  1. Protocol + Structural Subtyping(Ch10)

    @runtime_checkable class Notifier(Protocol) は「継承なしに型互換を保証する」仕組み。mypy/pyright は send() シグネチャの一致だけで互換性を判断する。EmailNotifierNotifier を一切インポートしなくて良い — これが ABC(抽象基底クラス)との最大の違い。

  2. @runtime_checkable の限界を理解する

    isinstance(notifier, Notifier) はメソッドの存在のみチェックする。シグネチャ(引数の型・数)は確認しない。そのため静的型チェック(mypy --strict)との組み合わせが必須。Fail-Fast はあくまで「完全に無関係なオブジェクト」を除外するための第一防衛線。

  3. メソッド名の統一で dispatch 関数を廃絶(Ch4/Ch5)

    Bad コードの dispatch_notification は「型ごとに異なるメソッド名を知っている」責務を担う神クラスだった。Protocol で send() に統一することで、呼び出し側は notifier.send(r, m) と書くだけ。新チャンネル追加時にコアロジックを一切変更しなくてよい。

  4. NotificationResult Value Object(Ch2)

    frozen=True, slots=True の dataclass は不変で軽量な Value Object。list[bool] と比較すると「どのチャンネルが・誰への・どんなエラーで失敗したか」が一つのオブジェクトに収まり、BigQuery 書き込み・DataDog メトリクス・Slack アラートへの接続が型安全になる。

  5. Fail-Fast + エラーを握りつぶさない(Ch1・実務原則)

    ループ前に全 Notifier の互換性を検証することで「途中で初めて TypeError」を防ぐ。各 send() は try-except で囲むが、except Exception: pass は禁止。logger.error(..., exc_info=True) でスタックトレースを記録し、NotificationResult(error=...)) で呼び出し元に伝播する。

実務への応用(MOps / ECサイト)

Argo Workflows タスクからの呼び出し例:
from typing import Any

def run_campaign_task(campaign_id: str, channel_config: dict[str, Any]) -> None:
    """Argo Workflows から呼び出されるキャンペーン配信タスク."""
    notifiers: list[Notifier] = []

    if email_cfg := channel_config.get("email"):
        notifiers.append(EmailNotifier(**email_cfg))
    if sms_cfg := channel_config.get("sms"):
        notifiers.append(SmsNotifier(**sms_cfg))
    if slack_cfg := channel_config.get("slack"):
        notifiers.append(SlackNotifier(webhook_url=slack_cfg["webhook"]))

    recipients = fetch_recipients(campaign_id)   # BigQuery から対象者取得
    results = send_campaign_notifications(
        notifiers, recipients, message="今週末限定セール!"
    )

    # 結果を BigQuery に書き込み
    write_results_to_bq(campaign_id, results)

    # 失敗率を DataDog メトリクスに送信
    failures = [r for r in results if not r.success]
    if failures:
        statsd.gauge(
            "campaign.notification.failure_rate",
            value=len(failures) / len(results),
            tags=[f"campaign:{campaign_id}"],
        )
    # 新チャンネル(LINE Notify 等)追加 → NotifierProtocol を実装するだけ
    # send_campaign_notifications は変更不要
Protocol vs ABC の選択基準:
  • Protocol(Structural): 外部ライブラリのクラスや複数リポジトリ間の連携に最適。継承の縛りなし。
  • ABC(Nominal): 同一パッケージ内で「必ず継承させたい」場合(@abstractmethod でオーバーライド漏れを強制検出)。
  • ECサイト MOps: チャンネルは将来的に社外 SDK(LINE SDK, Firebase等)を使うため Protocol が適切。

今日のまとめ

typing.Protocol + @runtime_checkable で「明示的な継承なしに型互換を保証する Structural Subtyping」を実現し、if isinstance(...) 分岐を廃絶してポリモーフィズムに置き換えることで、Open/Closed 原則を満たした拡張可能な通知システムを設計できる。

新チャンネル追加時は send(recipient: str, message: str) -> None を持つクラスを追加するだけ — コアロジック変更ゼロ。

自己評価