💨

AWS Lambda + API Gateway構成でPydanticを導入して型安全性と可読性を改善した話

に公開

AWS Lambda + API Gateway構成でPydanticを導入して型安全性と可読性を改善した話

はじめに

私が関わっているプロダクトはWEBシステムです。
バックエンドはPythonで書かれており、AWS API GatewayからAWS Lambdaを呼び出す構成で動いています。

構成の特徴は以下の通りです。

  • 1つのAPIエンドポイント = 1つのLambda関数
  • Lambdaごとに専用のPythonファイルを用意して実装
  • 共通処理はLambda Layerで用途ごとにPythonファイルで実装
  • API Gateway → Lambda間はJSON形式のeventでデータ受け渡し

今回、そのバックエンドにPydanticを導入し、型安全性と認知負荷軽減を実現しました。
この記事では、導入の背景・実装内容・効果・今後の課題を共有します。

公式ドキュメント:https://docs.pydantic.dev/latest/

この記事は学びを再構築したものであり、実際のシステムの内容とは異なります。

導入前の課題感

Lambdaで受け取るAPIリクエストはeventに格納されますが、この中身はAPI Gatewayの統合設定によって変わります。
例えばHTTP APIの場合、以下のような構造になります。

{
  "queryStringParameters": {
    "page": "1",
    "status": "active"
  },
  "body": "{\"name\":\"taro\",\"age\":\"25\"}"
}

※ REST APIの場合はpathParametersやmultiValueQueryStringParametersなど、構造が異なる場合があります。

このまま辞書アクセスで使うと、次のような問題がありました。

  • API設計を見ないとeventに何が格納されているのか正しくは分からない
  • 値は全て文字列として入ってくるため、型が堅牢ではない
  • バリデーションや変換処理がドメインロジックに混在し、認知負荷が高い

導入の狙いと選定理由

テックリードが中心となり技術選定を行いました。
私はクラス活用のアーキテクチャ面の検討をしています。
当初は dataclass の採用も検討しましたが、dataclassでは型注釈を付けても異なる型を代入できてしまうため、実行時に型安全性を担保することができませんでした。
そこで、実行時の型チェックや変換が可能な Pydantic を採用しました。

導入の目的は大きく3つです。

  1. パラメータ定義の明確化
    どんな値を受け取るのかをコード上で即座に把握できるようにする。
  2. 型安全性の担保
    実行時に型チェックを行い、不正な値を早期に検知できるようにする。
  3. 関心の分離
    バリデーションを専用クラスに集約し、ドメインロジックと分離することでコードの見通しを良くする。

導入前後の比較

Before(辞書アクセス)

import json
def handler(event, context):
    body = json.loads(event["body"])
    age = int(body["age"]) if "age" in body else None

    if age is None or age < 0:
        raise ValueError("Invalid age")

    # ドメインロジック
    return {"message": f"Hello, {body['name']}!"}

After(Pydanticモデル利用)

import json
from typing import Optional, Annotated
from pydantic import BaseModel, Field, field_validator, StrictStr

class UserRequest(BaseModel):
    name: StrictStr
    age: Annotated[int, Field(ge=0)]
    # 任意項目はOptional + Strict型で定義
    nickname: Optional[StrictStr] = None

    @field_validator("age", mode="before")
    def coerce_age_from_str(cls, v):
        # すでに int → そのまま
        if isinstance(v, int):
            return v
        # "25" のような数字文字列だけ受け入れる
        if isinstance(v, str) and v.isdigit():
            return int(v)
        raise TypeError("age must be an integer (numeric string allowed)")


def handler(event, context):
    body = json.loads(event["body"])
    req = UserRequest(**body)
    return {"message": f"Hello, {req.name}!"}

※ ageは「数値文字列も受け入れたい」ため、StrictIntではなくint型を採用。

変化ポイント

  • 型定義が明確になり、コードの見通しが向上。
  • バリデーションが集約され、ドメインロジックがシンプルに。

注意ポイント

  • 実際のプロダクトコードではクラス定義はドメインロジックより下の方が良いかも。
    ファイルの関心はロジックにあり、データ構造は常に参照するものではないため。
  • queryStringParametersで受け取る際、Keyに対してValueが無いものはNoneではなく、空文字としてやってくる。(APIGatewayの設定次第かも?)

実現したこと

今回、Pydanticで行ったのは以下です。

  • APIパラメータを表すクラスの定義
  • フィールドごとの必須/任意(Optional)の切り分け
  • StrictStrなどの利用による型の厳格化
  • field_validatormode="before" での入力値チェック
  • field_validatormode="after" での値の加工処理

導入して良かったこと

  • 型の堅牢性向上 → 不正な型の混入を防止
  • 認知負荷の低下 → バリデーションが集約され、処理の見通しが改善
  • テストのしやすさ向上 → Pydanticモデル単体での検証が可能
  • チーム内の共通理解 → モデル定義がドキュメント代わりになる

今後の課題

  • 経験差の解消
    チーム全員がPydanticを使いこなせるようにする。
    Pydanticに対するチームとしての技術的なキャッチアップが必要。
    ただ、それ以上にクラス設計に対するキャッチアップも求められる想定でいます。
  • 未利用機能の習熟
    まだ試していない機能も効果を期待できるのであれば取り入れたい。
    field_validatorの同じmodeに1つの値を複数定義した際の挙動なども正確に把握できていない。
  • クラスファイルの分離
    データクラスをドメインロジックと分離したファイルで扱いたい。
    1API=1Lambda構成のため、現状クラス定義を同一ファイルに置かざるを得ない。
    Lambdaのデプロイやファイル構成に関する知見が増えれば、この制約を解消できる可能性がある気がする。

まとめ

  • AWS Lambda + API Gateway環境でのPydantic導入は型安全性と開発効率を大幅に向上。
  • event構造の可視化・型不整合解消に有効。
  • 静的型付け言語ほどの安全性はないが、柔軟性と簡潔さを得られる。

感想

  • Javaなどの静的型付け言語の恩恵を思い出しつつも、コードがすっきりする楽しさを再認識できた。

参考

今回、Pythonでのクラスの扱い方を理解するにあたりサプーさんの動画にとても助けられました。

Discussion