リファクタリング背景
とあるラーメン店舗のラーメンを提供するためのmake_ramenメソッドの話です。
開店当初は注文が少なく、men, soup, oil, toppings
のような個別の引数 で渡しても問題はありませんでした。
しかし、店が繁盛して注文が増えると、順番違い・取り違え・追加忘れといったミスが起きやすくなります。
そこで注文票にあたる**構造体(DTO)**へ情報を集約してmake_ramenに渡す設計に移行することとなりました。
TL;DR
- 一時的に「旧形式も新形式も呼べる入口」を用意します。
- 実行時の実装は1本に集約し、内部でDTOに正規化してから本処理します。
- 旧形式は
DeprecationWarning
を出して段階移行を促します。 - 呼び出し側の置換完了後、旧経路とオーバーロード宣言を削除してDTO一本化します。
サンプル
from __future__ import annotations
from dataclasses import dataclass, field
from typing import overload, Iterable, Any, Tuple, Dict
import warnings
# 最終形のDTO(= 1杯の注文票)
@dataclass(frozen=True)
class RamenOrder:
men: str
soup: str
oil: str
toppings: list[str] = field(default_factory=list)
# @overload は型チェッカー専用(実行時は最後の実装だけが動作)
@overload
def make_ramen(men: str, soup: str, oil: str, toppings: Iterable[str] = ...) -> None: ...
@overload
def make_ramen(order: RamenOrder, /) -> None: ...
def make_ramen(*args: Any, **kwargs: Any) -> None:
order = _to_order(args, kwargs)
_cook(order)
def _to_order(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> RamenOrder:
# 新: DTO を直接
if args and isinstance(args[0], RamenOrder):
return args[0]
if isinstance(kwargs.get("order"), RamenOrder):
return kwargs["order"]
# 旧: 平置き(位置引数)
if args and isinstance(args[0], str):
if len(args) < 3:
raise TypeError("make_ramen requires (men, soup, oil[, toppings]) or RamenOrder")
_warn_deprecated()
men, soup, oil = args[:3]
toppings = list(args[3]) if len(args) > 3 else list(kwargs.get("toppings", []))
return RamenOrder(men=men, soup=soup, oil=oil, toppings=toppings)
# 旧: 平置き(キーワード引数)
if all(k in kwargs for k in ("men", "soup", "oil")):
_warn_deprecated()
return RamenOrder(
men=kwargs["men"],
soup=kwargs["soup"],
oil=kwargs["oil"],
toppings=list(kwargs.get("toppings", [])),
)
raise TypeError("Invalid arguments for make_ramen().")
def _warn_deprecated() -> None:
warnings.warn(
"旧インターフェイスは非推奨です。RamenOrder を渡してください。",
DeprecationWarning,
stacklevel=3,
)
def _cook(order: RamenOrder) -> None:
print(f"麺:{order.men} スープ:{order.soup} 油:{order.oil} 具:{','.join(order.toppings)}")
移行中の呼び出し例
# 旧(動くが警告が出ます)
make_ramen("中太", "醤油", "鶏油", toppings=["チャーシュー", "ねぎ"])
make_ramen(men="細麺", soup="塩", oil="香味油")
# 新(推奨)
make_ramen(RamenOrder(men="中太", soup="醤油", oil="鶏油", toppings=["チャーシュー"]))
進め方(3ステップ)
-
DTO導入:
RamenOrder
を定義し、必要となるフィールド値をDTOに書きます。 -
互換レイヤー投入:
@overload
で入口を表明し、実体は 正規化 → DTOコア の流れに一本化します。 - 削除フェーズ:呼び出し側の置換が終わったら、旧経路(旧オーバーロード宣言・分岐・警告)を削除して DTOに一本化 します。
テストやCIで
-W default
あるいはerror::DeprecationWarning
を有効にすると置換漏れを早期検出できるため有効。
なぜ効くか
- コア一点化(Single Source of Truth):本処理をDTOに固定することで、以降の変更を 1か所 に閉じ込めることができる。
- 縫い代(Seam)の明確化:互換レイヤーを入口に限定し、差分吸収の責務を 正規化関数に集約 することで、外側のゆらぎを中に持ち込まない。
-
安全な段階移行:旧形式は動かしつつ
DeprecationWarning
で寿命を明示することで、呼び出し側の書き換えをスプリント単位で進めやすくなる。
よくある落とし穴
- 正規化関数の肥大化:入口以外に分岐を書かず、正規化 → DTO → コア の順序を厳守する。
- 警告が見えない:Pythonの既定では警告が非表示の場合があります。テスト/CIで必ず表示・検出設定を行います。
付記(+α:実務での拡張)
外部入力やAPI連携でバリデーションが必要であれば、DTOを dataclass から Pydantic に置き換えると便利。(model_validate
で dict/JSON を吸収可能)。
同じ 「入口で正規化 → コア一点化」 の設計は維持できる。