1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個別の引数からDTOへ安全に移行する - @overloadの活用事例

Posted at

リファクタリング背景

とあるラーメン店舗のラーメンを提供するための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ステップ)

  1. DTO導入RamenOrder を定義し、必要となるフィールド値をDTOに書きます。
  2. 互換レイヤー投入@overload で入口を表明し、実体は 正規化 → DTOコア の流れに一本化します。
  3. 削除フェーズ:呼び出し側の置換が終わったら、旧経路(旧オーバーロード宣言・分岐・警告)を削除して 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 を吸収可能)。
同じ 「入口で正規化 → コア一点化」 の設計は維持できる。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?