zodとF#を補助線に、Domain Modeling Made FunctionalをPython/pydanticで実践する

きっかけ

最近Pythonを触る機会が増えて、pydanticを使い始めたのだけど、最初は「スキーマ管理ツール」くらいの認識だった。

で、Domain Modeling Made Functional(以下DMMF)の型駆動な設計をPythonでもやりたいなと思ったときに、「pydanticはどこで使って、どこでは使わないのか」がよく分からなかった。zodならどうするか、F#なら言語だけでどこまでできるか、という補助線を引いたら、だいぶ整理がついたので書き残しておく。

DMMFが目指していること

DMMFのワークフローは、型の変換のステップとして表現される。

UnvalidatedOrder → ValidatedOrder → PricedOrder → CompletedOrder

各ステップで「得られた知識を型に保存する」。ValidatedOrderが存在するなら、それはバリデーション済みだという証拠になる。以降のコードで「念のため」再チェックする必要がない。防御的プログラミングからの解放だ。

これはAlexis Kingの「Parse, don't validate」とも通じる話で、validateがboolを返して知識を捨てるのに対して、parseはより構造化された型を返して知識を保存する。

じゃあこれをPythonでやるにはどうすればいいのか。

まず、zodならどうやるか

TypeScriptでは型が実行時に消えるので、zodがスキーマ+型導出+ランタイム検証を一手に引き受ける。

const UserSchema = z.object({ name: z.string(), age: z.number() });
type User = z.infer<typeof UserSchema>; // スキーマから型を導出
const user = UserSchema.parse(data);    // ランタイムでパース

スキーマ定義、型導出、パースの3ステップ。TypeScriptの型が消えるという制約があるから、zodがこの形になっている。

DMMFのワークフローをzodで書くなら、各ステップのスキーマを定義して、parseで変換して、z.inferで型を取り出す。やれなくはないけど、zodは元々こういうワークフローの合成を主目的にしたツールではない。

F#なら言語だけでできる

F#だと、判別共用体とprivate constructorが言語に組み込まれている。

type EmailAddress = private EmailAddress of string

let create (s: string) =
    if s.Contains("@") then Ok (EmailAddress s)
    else Error "invalid email"

EmailAddressを外から直接構築できない。バリデーションを経由しないとインスタンスが作れない仕組みが、言語機能だけで実現できる。DMMFがF#で書かれているのは偶然ではなくて、F#の型システムがこのスタイルにぴったりだからだ。

パターンマッチの網羅性チェックもコンパイラがやってくれるので、ワークフローの各ステップで「この型のこのケースを処理し忘れてる」というミスをコンパイル時に検出できる。

ここがPythonとの一番大きな差になる。

Pythonの型ヒントは、あるけど効かない

Pythonの型ヒントは実行時にも__annotations__として残っている。ただ、残っているだけで何もしない。

class User:
    name: str
    age: int

u = User()
u.age = "hello"  # エラーにならない。普通に動く

mypyを使えば静的解析でエラーは検出できる。でもそれはコードを書いた人のミスを防ぐ話で、APIリクエストやJSONファイルから実行時に入ってくるデータには無力だ。

F#は「型 = 実行時の実体」、TypeScriptは「型が消える」、Pythonは「型情報はあるけど効かない」。それぞれの穴の形が違うから、それを埋めるライブラリの形も変わる。zodのz.inferが存在するのはTypeScriptの型が消えるからだし、pydanticがクラス定義=スキーマで済むのはPythonの型情報が残っているからだ。

pydanticは境界のsmart constructor

ここでpydanticの役割がはっきりする。

DMMFのワークフローの最初のステップ、UnvalidatedOrder → ValidatedOrder。ここは外部から信頼できないデータが入ってくる境界だ。pydanticの出番はここになる。

from pydantic import BaseModel

class UnvalidatedOrder(BaseModel):
    customer_email: str
    product_id: str
    quantity: int

pydanticのBaseModelは、インスタンス化するときに必ずバリデーションが走る。F#のprivate constructorが言語レベルでやっていることを、pydanticがライブラリレベルで実現している。

ただし正直に書くと、この保証はF#ほど堅くない。pydanticにはmodel_constructというバリデーションをスキップしてインスタンスを作るメソッドがある。パフォーマンス最適化のために実務で使われることもあるので、「pydanticなら迂回できない」とは言い切れない。結局チーム内で「model_constructは原則使わない」という規約に頼る部分は残る。それでも、素のPythonのコンストラクタよりは遥かにましだ。素のコンストラクタはそもそもバリデーションが走らないので、規約以前の問題になるから。

境界の変換を省略しない

pydanticのBaseModelから内部のドメイン型への変換は、記事の核心部分なのでちゃんと書いておく。

やり方はいくつかあるが、素直なのは「バリデーション専用のpydanticモデル」を中間に挟む方法だ。

from dataclasses import dataclass
from decimal import Decimal
from typing import NewType
from pydantic import BaseModel, EmailStr, PositiveInt
from returns.result import Result, Success, Failure

# --- 境界の入口:外部データをそのまま受け取る ---
class UnvalidatedOrder(BaseModel):
    customer_email: str
    product_id: str
    quantity: int

# --- 境界の中間:pydanticにバリデーションさせる ---
class ValidatedOrderInput(BaseModel):
    customer_email: EmailStr
    product_id: str
    quantity: PositiveInt

# --- ドメインの型 ---
Price = NewType("Price", Decimal)

@dataclass(frozen=True)
class ValidationError:
    message: str

@dataclass(frozen=True)
class PricingError:
    message: str

# エラー型もUnionで設計する。後述するassert_neverと組み合わせて網羅性を担保する
type PlaceOrderError = ValidationError | PricingError

@dataclass(frozen=True)
class ValidatedOrder:
    customer_email: str
    product_id: str
    quantity: int

# --- 境界:pydanticで検証 → ドメイン型に変換 ---
def validate_order(order: UnvalidatedOrder) -> Result[ValidatedOrder, ValidationError]:
    try:
        # pydanticのバリデーションモデルを経由して検証
        validated_input = ValidatedOrderInput(
            customer_email=order.customer_email,
            product_id=order.product_id,
            quantity=order.quantity,
        )
        # 検証済みデータからドメインのdataclassへ変換
        return Success(ValidatedOrder(
            customer_email=validated_input.customer_email,
            product_id=validated_input.product_id,
            quantity=validated_input.quantity,
        ))
    except Exception as e:
        return Failure(ValidationError(str(e)))

UnvalidatedOrderが外部データをそのまま受け取り、ValidatedOrderInputがpydanticの公開APIでバリデーションし、ValidatedOrderがドメインロジック用のdataclassになる。少し冗長に見えるかもしれないけど、pydanticの世界(BaseModel)とドメインの世界(dataclass)の境界が明示的になる。

ちなみに、EmailStr._validate()のような内部APIを直接呼ぶ方法もあるが、公開APIとしての安定性が保証されていないので避けたほうがいい。pydanticのバリデーションはpydanticモデルのインスタンス化に任せるのが正攻法だ。

内部のステップにはpydanticは要らない

ValidatedOrder → PricedOrderはどうか。

@dataclass(frozen=True)
class PricedOrder:
    customer_email: str
    product_id: str
    quantity: int
    total_price: Price  # floatではなくDecimalベースのPrice型

def price_order(order: ValidatedOrder) -> Result[PricedOrder, PricingError]:
    price_table: dict[str, Decimal] = {"PROD-001": Decimal("1000")}
    unit_price = price_table.get(order.product_id)
    if unit_price is None:
        return Failure(PricingError(f"Unknown product: {order.product_id}"))
    return Success(PricedOrder(
        customer_email=order.customer_email,
        product_id=order.product_id,
        quantity=order.quantity,
        total_price=Price(unit_price * order.quantity),
    ))

入力のValidatedOrderは既にパース済みで信頼できる。ここでの失敗は「商品が見つからない」というビジネスロジック上の問題であって、データの妥当性の問題ではない。pydanticの出番ではなく、dataclass + Result型で十分だ。

金額の型について補足しておくと、total_pricefloatを使うのはDMMFの思想と矛盾する。浮動小数点の丸め誤差は「型で不正な状態を防ぐ」という原則に反するし、そもそも金額はDecimalが定石だ。さらにNewType("Price", Decimal)でラップすることで、「この値は価格計算済みの金額である」という意味を型に持たせている。こういう細部でテーゼと整合を取らないと、記事で言ってることとコードが噛み合わなくなる。自戒を込めて。

pydanticを使わない場所を特定することで、逆にpydanticの本質的な役割がくっきりする。

ワークフロー全体をつなぐ

各ステップをdry-python/returnsのflowbind_resultで合成すると、DMMFのワークフローに近い形になる。

from returns.pipeline import flow
from returns.pointfree import bind_result

def place_order(raw: UnvalidatedOrder) -> Result[PricedOrder, PlaceOrderError]:
    return flow(
        raw,
        validate_order,
        bind_result(price_order),
    )

bind_resultは、直前のステップがSuccessなら中身を取り出して次の関数に渡し、Failureならそのままスキップする。いわゆるRailway Oriented Programming。手動で毎回if isinstance(result, Failure): return resultと書かなくて済む。

dry-python/returnsを選んだ理由と注意点

ここで正直に触れておくと、dry-python/returnsはPythonエコシステムではニッチなライブラリだ。チームに導入するなら、学習コストやメンテナンスの継続性は気にしたほうがいい。

代替としては、よりシンプルなresultパッケージもあるし、Python 3.10以降のmatch文を使って手動でパターンマッチする方法もある。

# match文で手動Railway
def place_order(raw: UnvalidatedOrder):
    match validate_order(raw):
        case Failure() as f:
            return f
        case Success(validated):
            return price_order(validated)

dry-python/returnsflow/bind_resultを使うか、match文で明示的に書くかは、チームの馴染み度や好みで判断すればいいと思う。returnsのほうがステップが増えたときに宣言的に書けるのが利点だが、マジカルに見えるのが嫌ならmatchのほうが素直だ。

Pythonの型システムの限界と、現在の到達点

F#のような型駆動設計をPythonで完全に再現するのは無理だ。ただ、「何が足りないか」を正確に把握しておくと、どこまで近づけるかも見えてくる。

判別共用体がネイティブにない。 ただしPython 3.10以降のmatch文とtyping.assert_neverを組み合わせると、網羅性チェックに近いことはできる。

先ほど定義したPlaceOrderErrorのハンドリングで見てみよう。

from typing import assert_never

def handle_error(error: PlaceOrderError) -> str:
    match error:
        case ValidationError(message=msg):
            return f"入力エラー: {msg}"
        case PricingError(message=msg):
            return f"価格エラー: {msg}"
        case _ as unreachable:
            assert_never(unreachable)  # ケース漏れがあればmypyがエラーを出す

assert_neverは、到達不能なはずの分岐に置くことで、PlaceOrderErrorのケースを処理し忘れていたらmypyが教えてくれる。たとえば後からShippingErrorをUnionに追加してhandle_errorを更新し忘れたら、mypyがエラーを出す。

DMMFではエラー型自体も判別共用体で設計する。Pythonではtype PlaceOrderError = ValidationError | PricingErrorという型エイリアスとassert_neverの組み合わせで、これに近いことが実現できる。F#のコンパイラほど自然ではないけれど、実用上はこれでかなり守れる。

静的な型の区別とランタイムの強制が別々。 typing.NewTypeを使えばmypyレベルでEmailstrを区別できるが、ランタイムでは何もしない。pydanticのEmailStrはランタイムで検証するが、mypyから見るとほぼstr。両方を一つの仕組みで満たす手段がまだない。

constructorのprivate化ができない。 F#のようにsmart constructorを言語レベルで強制できない。pydanticのBaseModelに頼るにしても、model_constructという抜け穴がある。最終的にはチーム規約に依存する部分が残る。

それでも、pydantic(境界での強制)+ dry-python/returnsまたはmatch文(Result型とworkflow合成)+ dataclass(内部のドメイン型)+ assert_never(網羅性チェック)を組み合わせれば、DMMFのスタイルにかなり近づける。完璧ではないけれど、「型が設計を表現し、不正な状態を防ぐ」恩恵は十分に感じられる。

まとめ

今回の整理で見えたことを並べておく。

  • DMMFのワークフローは「型の変換パイプライン」。各ステップで得た知識を型に保存し、以降の再検証を不要にする
  • pydanticの出番は境界のパース。信頼できない外部データに対する、ランタイム強制付きsmart constructor。ただしmodel_constructという穴があるので、F#のprivate constructorほどの堅さはない
  • 内部のステップ(ビジネスロジックによる型変換)にはpydanticは不要。dataclass + Resultで足りる
  • 金額にfloatを使うような細部の甘さは、型駆動設計のテーゼと矛盾する。Decimal + NewTypeで意味を持たせる
  • Pythonの型システムには限界があるが、match文 + assert_neverで網羅性チェックもかなりカバーできる。エラー型もUnionで設計してassert_neverと組み合わせれば、ケース漏れを静的に検出できる。ツールを組み合わせればDMMFの恩恵は十分に享受できる

zodを知っていたから「なぜpydanticはこの形なのか」と問えたし、F#まで遡ったから「言語の穴がライブラリの形を決めている」と気づけた。一つのライブラリを理解するのに別の言語が補助線になるというのは、ちょっと面白い体験だった。

処理遅延の原因と解決策: AWS環境でのPDF処理

Doclingを用いたPDF解析処理をECS Fargate環境に導入したところ、ローカル環境と比較して大幅な処理遅延が発生しました。ここでは、その原因分析と、GPUインスタンス移行による改善結果について記載します。

1. 発生した課題

Apple Silicon(M4 Pro)を搭載したローカル環境では約1分で完了する8ページのPDFの取り込み処理が、AWS Graviton2を採用したECS Fargate環境では約21分(1257秒)を要しました。

なお、対象のPDFの読み取りはOCR処理を含む比較的負荷の高いワークロードです。

2. 処理時間の内訳分析

ログを分析した結果、全処理時間の97%以上がPyTorchによる機械学習モデルの推論に費やされていることが分かりました。

経過時間 処理内容 全体比
4秒 パイプライン初期化、OCRモデルDL 0.3%
19分5秒 OCR + レイアウト解析 91%
1分22秒 TableFormerロード + 推論 6.5%
26秒 ドキュメント組み立て 2.2%

Doclingは複数のMLモデルを段階的に実行する構成となっており、特にOCRとレイアウト解析がボトルネックとなっていました。

3. 原因の整理

主な要因は、CPUベース(Graviton2)でのML推論性能が、今回のワークロードに対して十分でなかった点にあります。

  • ハードウェア特性の違い Graviton2(Neoverse N1)は汎用CPUとしては高性能ですが、Apple SiliconのAMXのようなML推論向け専用アクセラレーションは備えていません。そのため、画像OCRやレイアウト解析のような演算密度の高い処理では性能差が顕著に現れました。

  • キャッシュ・メモリ帯域の差 L2キャッシュ容量やメモリ帯域といった要素も、ML推論では無視できない差となります。

簡単にスペックもまとめておきます

Apple Silicon (M4 Pro) Graviton2 (Neoverse N1)
クロック 3.2〜4.5 GHz 2.5 GHz
L2キャッシュ 16〜32MB/クラスタ 1MB/コア
IPC (命令効率) 非常に高い 控えめ
メモリ帯域 統合メモリ 273GB/s DDR4 ~50GB/s
ML推論向け最適化 Apple AMX なし

4. 対策と改善結果

CPU環境での改善には限界があると判断し、NVIDIA A10G GPUを搭載したg5インスタンスへ移行しました。

  • 処理時間の大幅短縮 約21分かかっていた処理が、30秒〜1分程度まで短縮されました。これはローカルのM4 Pro環境と同等、もしくはそれ以上の性能です。

5. 結論

Doclingのように複数のMLモデルを順次実行する文書解析パイプラインでは、CPU(特にGraviton2)のみでの処理は、OCRを含むワークロードにおいて実用的な性能を得にくいことが分かりました。

リアルタイム性やスループットが求められる要件においては、GPUインスタンスの採用が最も効果的な選択肢となります。一方で、CPU環境を選択する場合は、モデル構成や推論ランタイムの最適化を前提に、処理時間とのトレードオフを十分に考慮する必要があります。

Specification as Prompt — AI時代の翻訳問題をSpecification by Exampleで解決する —

1. 翻訳としての実装

私は普段、要件が決まって実装を進めるとき、自分をある種の「翻訳者」だと思っています。 要件書という自然言語を、プログラミング言語に訳すのが仕事です。

たとえば「ユーザーがログインに5回失敗した場合、アカウントをロックする」という一文を、if文やループ、データベース操作に置き換えていきます。曖昧な部分は経験や常識で補い、技術的制約に合わせて形にしていきます。 要件書からコードへの翻訳は、従来は一度きりの変換でした。


2. AIがもたらした二重翻訳

AIの登場によって、この翻訳プロセスは変わりました。 ClaudeやCopilotに自然言語で指示すれば、コードが生成されます。しかし流れを整理すると、次のようになります。

要件(自然言語) → プロンプト(自然言語) → コード(プログラミング言語)

翻訳が1回から2回に増えているのです。 この中間の「要件→プロンプト」で情報が削られたり、解釈のバイアスが入り込みます。同じ要件でも人によってプロンプトが異なり、結果もばらつきます。

そこで、要件をそのままプロンプトとして使えるようにすればよいのではないかと考えました。 そうすれば中間翻訳を省き、情報の劣化を防げます。


3. ヒントは「Specification by Example」

この発想のヒントは、ATDDやBDDの考え方にあります。中でも**Specification by Example(SbE)**は有効だと感じました。 SbEは、仕様を抽象的に書かず、具体的な例で表現します。 「短いパスワードは拒否される」ではなく、「パスワードabc(3文字)は拒否される」といった具合です。

テストコードが具体的な値やインスタンスを必ず必要とするのと同じで、この「具体性」がAIにとっても理解しやすい指示になるのです。


4. 具体性がAIを動かす

抽象的な要件は、次のようになりがちです。

適切なセキュリティ対策を講じる ユーザビリティを考慮する

これらをそのままAIに投げても、曖昧すぎて期待通りの結果は得られません。 SbE形式なら、次のように書けます。

  • ユーザーが5回連続でパスワードを間違えたらアカウントを15分ロックする
  • ロック中は「アカウントがロックされています」と表示する
  • 15分後に自動解除する

この具体性によって、AIは仕様を忠実にコードへ変換できます。 実際に試したところ、失敗回数のカウントやロック時間の管理、エラーメッセージまで実装されました。


5. これから

要件作成の段階からAI活用を意識すれば、二重翻訳の非効率は解消できます。 AI時代において、要件はそのまま「AIへの指示文」になり得ます。 翻訳者だった私は、いまやプロンプト作家でもあります。そして、その原稿は具体的でなければならないのです。 まだ夢想の段階なので、来週から実践してみようと思います

DevinとKiroから見えた「壊した壁」と、「可視化した」壁

AIによる職能境界の再構築:DevinとKiroから見えた「壊した壁」と、「可視化した」壁

2025年7月、AWSから「Kiro」がリリースされました。同時期に話題のDevinも触ってみて、気づいたことがあります。どうやらAIは、職能間の境界線を別の場所に「移動」させているらしい。その感想をざっくばらんに。

チームで体験したDevinの衝撃

まず驚いたのは、今のチームでCS/Salesの非エンジニアがDevinを使って、簡単な文言やデザインの修正をし始めたことでした。また、バックエンド(Rails)の実装に詳しくないフロントエンドエンジニアも、Devinを使って簡単なRailsCRUD処理のPRを投げてくれるようになりました。

専門職でなくても、アウトプットが出せるようになってきています。これまで「プログラマでないと不可能」だった領域が、確実に民主化されています。つまり、プログラマ/非プログラマの壁が「壊された」と言えるでしょう。

しかし同時に見えてきた新しい境界線

ところが皮肉なことに、Devinが対応できない領域も同時に明確になりました。システムアーキテクチャの設計判断、セキュリティ要件の技術的実現方針、スケーラビリティを考慮したデータモデル設計、既存システムとの整合性確保、長期的な保守性を考慮した技術選択...。

つまり、「プログラマ」(コードが書ける人)と「ソフトウェアエンジニア」(システムが設計できる人)の境界線が、AI導入によって皮肉にも明確に「可視化」されたということです。

プログラマは、与えられた仕様をコードに変換する技術力を主軸とし、アルゴリズムやデータ構造、言語仕様に思考を集中させ、動作するコードを成果物として、現在のタスクに集中します。

一方でソフトウェアエンジニアは、問題全体を構造化し持続可能な解決策を設計することを主軸とし、要件分析、アーキテクチャ、運用、ビジネス価値まで思考範囲を広げ、ユーザーに価値を提供するシステムを成果物として、長期的な保守性・拡張性を考慮した時間軸で動いています。

動くコードと、動き続けるコードの境界線がはっきり意識されるようになったと言えるでしょう。

Kiroで見えた同じパターン

Kiroを触ってみると、さらに興味深い境界移動が起きていることがわかりました。自然言語でやりたいことを伝えると、要件の構造化から実装管理まで一貫してサポートしてくれます。

曖昧な要求をrequirements.mdに構造化し、ユーザーストーリーを作成し、非機能要件まで洗い出してくれる。アーキテクチャ図やデータフロー設計、API仕様の文書化から、プロジェクトタスクの分解、優先順位付けと工数見積、依存関係の可視化まで。

これらは従来、PdMの領域とされていた業務でした。Kiroは、エンジニアでもPdM的な要件管理・プロジェクト管理を効率的に行えるようにしています。

そしてまた現れる境界線

しかし、ここでもDevinの時と同じパターンが起きています。AI支援で効率化されるのは、決まった方針の文書化、既知の要件の構造化、プロジェクトタスクの分解・管理といった領域。

一方で、市場洞察に基づく戦略策定、ユーザー価値の本質的理解、競合分析と差別化戦略、「やらないこと」を決める判断力、ステークホルダー間の利害調整といった領域は、依然として人間固有の価値として残ります。

要件が書けるだけの人は、決まった方針を文書化・構造化する能力を主軸とし、既知の要件整理やプロジェクト管理手法に思考を集中させ、整理された文書とタスクリストを成果物として、現在のプロジェクトスコープという時間軸で動きます。

真のPdMは、市場とユーザーの本質的ニーズを理解し戦略を策定することを主軸とし、競合分析、価値創造、ROI判断まで思考を広げ、ユーザーと事業に価値をもたらすプロダクト戦略を成果物として、長期的な市場変化とプロダクト進化という時間軸で考えています。

つまり、「要件が書けるだけの人」と「真のPdM」(戦略が描ける人)の境界線が明確になったということです。

見えてきた「壁の玉突き現象」

この一連の体験から見えてきたのは、AIツールが職能間の境界を「移動」させているということです。

低自律度のGitHub Copilotは、リアルタイムのコード補完・提案により、開発者の入力に応じて逐次的にサポートしてくれます。単一ファイルや単一機能レベルでの支援が中心で、あくまで「手の延長」という感じです。

中自律度のCursor、Cline、Claude Codeになると、プロジェクト全体のコンテキストを理解し、マルチファイル編集やリファクタリングまでこなします。自然言語の指示からある程度自律的に実装してくれるので、「思考の拡張」といった印象です。

そして高自律度のDevinは、自然言語の仕様から設計・実装・テストまで、人間の介入なしに複雑なプロジェクトを完遂してしまいます。もはや「労働の代替」と言ってもいいでしょう。

興味深いのは、AIの自律度が高くなるほど、人間がエディタから遠のいていくということです。これまで当たり前だった「コードを書く」という行為そのものが、根本から変わろうとしているのでしょう。

プログラマからプログラマ、ソフトウェアエンジニア、PdM、Product Strategistへと続く職能の連鎖の中で、AIツールがそれぞれ異なる境界を薄化させ、同時により深い境界を可視化していることがわかります。

民主化パラドックス

最も興味深いのは、Devinによってプログラミングが民主化されることで、むしろ非ソフトウェアエンジニアとソフトウェアエンジニアの壁の存在が浮き彫りになるということです。

プログラミングができる人が増えることで、「コードが書ける」こと自体の価値は相対的に下がります。その結果、システム全体を俯瞰し、持続可能なソフトウェアを設計できる「真のソフトウェアエンジニア」の価値がより際立つことになるのです。

これは、AIツールによる民主化パラドックスといえるでしょう。技術的な敷居を下げることで、より本質的なスキルの重要性が強調される現象です。

Kiroの登場でも同じことが起きるはずです。要件を書けるだけの人は増えても、非PdMとPdMの壁の存在がより浮き彫りになるでしょう。ツールによって表面的なスキルが民主化されることで、戦略的思考や価値創造といった本質的な能力の重要性がかえって強調されるのです。

最後に

AIが一つの境界を薄くすると、より本質的な境界が浮き彫りになる。隣接する職能領域の境界が次々と再定義されていく。手を動かす能力より、頭で考える能力の価値が相対的に向上していく。

この壁を越えていくためには、コツコツ勉強せねばいかんなぁと思ったのでした。

Rich Hickeyの「Spec-ulation」とClaude Code Commandで変わった、チームのライブラリレビュー文化

Rich Hickeyの「Spec-ulation」とClaude Code Commandで変わった、チームのライブラリレビュー文化

ジュニアメンバーのレビューに対する不安

私たちのチームでは、メンバーの半数がジュニアエンジニアです。彼らがライブラリアップデートのPRレビューをする際、いつも同じような光景が繰り広げられていました。

「このレビューで本当に大丈夫でしょうか...」 「見落としがあったらどうしよう」 「どこを重点的にチェックすればいいのかわからない」

結果として、レビューに長時間かかったり、最終的に「よくわからないけどとりあえずApprove」という状況になったりしていました。シニアメンバーに頼りっぱなしで、チーム全体としてライブラリアップデートに対する不安が常にありました。

「何をチェックすべきか」からの脱却

最初は「ライブラリアップデート用のチェックリスト」を作ろうと考えていました。でも、よく考えてみると、ライブラリごとに注意すべき点が違うし、バージョンによって影響範囲も変わります。表面的なチェックリストでは根本的な解決にならないと気づきました。

そんな時に出会ったのが、Rich Hickeyの講演「Spec-ulation」でした。Clojureの作者である彼が語るソフトウェアの「変更」に対する考え方が、ライブラリアップデートの本質を理解する鍵になりそうでした。

www.youtube.com

Rich Hickeyが教えてくれた「変更」の本質

Hickey氏の講演で最も印象的だったのは、ソフトウェアの「変更」を明確に二分する考え方です。従来、私たちは「何が変わったか」ばかりに注目していましたが、本当に重要なのは「それが成長なのか、破壊なのか」を見極めることでした。

Growth(成長)には3つのパターンがあります。新しい機能や戻り値の追加(Accretion)、必須引数の削減など要求の軽減(Relaxation)、パフォーマンス改善やバグ修正(Fixation)。これらは基本的に既存コードを壊しません。

Breakage(破壊)も3つに分類されます。新しい必須引数の追加(Requiring more)、既存機能の削除(Providing less)、同名関数の動作変更(Changing semantics)。これらは既存コードに影響を与える可能性があります。

さらに重要だったのは、「真の依存関係は関数呼び出しにある」という指摘でした。ライブラリ名やバージョン番号は単なるパッケージングの仕組みで、実際にコードが動作するかどうかは個々の関数呼び出しレベルで決まるということです。

Claude Code Commandで知見を共有する仕組み作り

この考え方をチーム全体で活用するために、Claude Code Commandの知見共有機能を使って実装しました。具体的には、ライブラリアップデートの分析パターンを共有可能な形で標準化したのです。

まず、Rich Hickeyの分類を使ったレビューテンプレートを作成しました。チームメンバーが誰でもアクセスでき、ライブラリアップデート時に一貫した観点でチェックできるようにしました。

特に効果的だったのは、「自分たちが実際に使用している関数への影響」を重視する分析方法です。チェンジログの「新機能追加」という記述に惑わされず、本当に影響があるかを判断できるようになりました。

実際のレビューがどう変わったか

例えば、あるライブラリが1.2.3から1.3.0にアップデートされた場合、以前なら「マイナーバージョンアップだから大丈夫かな...でも不安」という状態でした。

今では、新しいオプションパラメータが追加されていれば「これはAccretion(追加)でGrowthパターン。既存コードには影響しない」と明確に判断できます。以前必須だった設定項目がオプショナルになっていれば「Relaxation(緩和)でGrowthパターン。むしろコードがシンプルになる」と分析できます。

一方、APIキーが新たに必須になった場合は「Requiring more(要求増加)でBreakageパターン。設定の見直しが必要」と即座に認識し、適切な対応策を検討できるようになりました。

チーム全体のレビュー文化の変化

運用開始後の最も大きな変化は、チーム全員がライブラリアップデートのレビューに自信を持って取り組めるようになったことです。

以前は「このレビューで十分だろうか」という不安を抱えていたジュニアメンバーも、明確な判断基準に基づいて確信を持ってApproveまたは変更要求を出せるようになりました。「Growth/Breakageのどちらに該当するか」という構造化された分析により、レビュー時間も大幅に短縮されました。

何より、個人が持っていた専門知識がチーム全体の共有財産となったことで、組織全体の技術レベルが底上げされました。ライブラリアップデートに対する不安や恐怖心が解消され、積極的なアップデート文化が醸成されています。

理論と実践を繋ぐ大切さ

Rich Hickeyの深い洞察とClaude Code Commandの実用的な機能を組み合わせることで、チームの課題を根本から解決できました。単なるチェックリストではなく、本質的な考え方を共有することの重要性を実感しています。

技術的な知見は、適切な仕組みで共有されて初めて組織の力になるのだと思います。

設計ナイト2022 「トランザクションスクリプト」に参加しました

 先日参加した、設計ナイト2022 「トランザクションスクリプト」というイベントが非常に良かった。
 
勝手にトランザクションスクリプトをこき下ろして、やっぱりドメインモデル最高だよねって話になると思ってた。
 
蓋を開けてみると、どんなコンテクストでもドメインモデルにしないといけないわけではなくて、トランザクションスクリプトを使った方がいいコンテクストもあるよね。
そもそも、トランザクションスクリプトドメインモデルは別レイヤーの話なので、二項対立ではないよね。という態度が通底していたように思う。

Ruby on Rails: validationが実行される仕組みを追う

 

Railsのvalidationは、バリデーションを宣言的に定義する便利な機能です。しかし、このシンプルな構文の裏には、ActiveRecordの強力なコールバックシステムが複雑に絡んでいます。本記事では、validateが実際にどのように動作するのか、ステップバイステップで確認します。

Railsvalidate :my_methodがどのように処理されるかを理解するために、シーケンス図を用いて具体的なフローを確認してみましょう。

 

sequenceDiagram
    participant User as User Class
    participant VM as ValidationsModule
    participant CB as Callbacks
    participant CC as CallbackChain
    participant Filter as Filters::Before
    participant Instance as User Instance

    Note over User,Instance: クラス定義時(登録フェーズ)
    User->>+VM: validate :my_method
    VM->>VM: options = args.extract_options!
    VM->>+CB: set_callback(:validate, :my_method, {})
    CB->>CB: normalize_callback_params<br/>[:before, [:my_method], {}]
    CB->>+CB: Callback.build(chain, :my_method, :before, {})
    CB->>CB: initialize(@filter = :my_method, @kind = :before)
    CB->>CB: compiled()
    CB->>CB: CallTemplate.build(:my_method) → MethodCall
    CB->>CB: MethodCall#make_lambda → lambda{target.send(:my_method)}
    CB->>+Filter: Filters::Before.new(lambda, [], config, :my_method, :validate)
    Filter-->>-CB: Beforeオブジェクト
    CB-->>-CB: Callbackオブジェクト
    CB->>+CC: chain.append(callback)
    CC-->>-CB: 
    CB-->>-VM: 
    VM-->>-User: 

    Note over User,Instance: 実行時フェーズ
    alt 明示的なvalid?呼び出し
        Instance->>+Instance: valid?()
    else save/save!/create/update
        Instance->>+Instance: save()
        Instance->>Instance: perform_validations(options)
        alt options[:validate] != false
            Instance->>Instance: valid?(options[:context])
        end
    end
    
    Instance->>Instance: run_validations!()
    Instance->>+CB: _run_validate_callbacks()
    CB->>CB: run_callbacks(:validate)
    CB->>CB: callbacks = __callbacks[:validate]
    CB->>CB: env = Environment.new(self, false, nil)
    CB->>+CC: callbacks.compile(nil)
    CC->>CC: コールバックシーケンス作成
    CC-->>-CB: next_sequence
    CB->>+CC: next_sequence.invoke_before(env)
    CC->>+Filter: call(env)
    Filter->>Filter: target = env.target<br/>user_conditions check
    Filter->>Filter: result_lambda = -> { user_callback.call target, value }
    Filter->>+Instance: target.send(:my_method)
    Instance->>Instance: my_method実行
    Instance-->>-Filter: 
    Filter-->>-CC: 
    CC-->>-CB: 
    CB-->>-Instance: 
    Instance-->>-Instance: バリデーション結果

```

ステップごとの詳細な処理

ステップ1: validate メソッドの呼び出し

まず、validate :my_methodの宣言により、Railsはコールバックを設定します。

class User
  include ActiveModel::Validations
  validate :my_method  # ← ここが呼ばれる
end

実際の処理:

def validate(*args, &block)
  options = args.extract_options!
  set_callback(:validate, *args, options, &block)
end

GitHubリンク: validateメソッド


ステップ2: set_callbackでの処理

次に、set_callbackメソッドでコールバックが設定されます。

def set_callback(name, *filter_list, &block)
  type, filters, options = normalize_callback_params(filter_list, block)
  self_chain = get_callbacks name
  mapped = filters.map do |filter|
    Callback.build(self_chain, filter, type, options)
  end
end

GitHubリンク: set_callbackメソッド


ステップ3: normalize_callback_paramsでの解析

normalize_callback_paramsメソッドでは、コールバックのフィルタやオプションを解析します。

def normalize_callback_params(filters, block)
  type = CALLBACK_FILTER_TYPES.include?(filters.first) ? filters.shift : :before
  options = filters.extract_options!
  filters.unshift(block) if block
  [type, filters, options.dup]
end

GitHubリンク: normalize_callback_paramsメソッド


ステップ4: Callbackオブジェクトの作成

次に、Callbackオブジェクトが作成され、コールバックの実行準備が整います。

def self.build(chain, filter, kind, options)
  new chain.name, filter, kind, options, chain.config
end

GitHubリンク: Callback.build


ステップ5: CallTemplate.buildでのMethodCall作成

コールバックの呼び出し方法を定義するMethodCallオブジェクトが作成されます。

def self.build(filter, callback)
  MethodCall.new(filter)
end

GitHubリンク: CallTemplate.build


ステップ6: Filters::Beforeの作成

最後に、Filters::Beforeオブジェクトが生成され、my_methodが実行される準備が整います。

def compiled
  user_callback = CallTemplate.build(@filter, self)
  Filters::Before.new(user_callback.make_lambda, user_conditions, chain_config, @filter, name)
end

GitHubリンク: Filters::Before


ステップ7: コールバックの実行

run_callbacks

を使って、登録したコールバックが実行され、最終的にmy_methodが呼び出されます。

def run_callbacks(kind, type = nil)
  callbacks = __callbacks[kind.to_sym]
  env = Filters::Environment.new(self, false, nil)
  next_sequence = callbacks.compile(type)
  next_sequence.invoke_before(env)
end

GitHubリンク: run_callbacks


最終的な実行フロー

  1. クラス定義時: validate :my_method → コールバックオブジェクトが作成され、登録される

  2. 実行時:

    • 明示的: user.valid?_run_validate_callbacksrun_callbacks(:validate)target.send(:my_method)

    • 暗黙的: user.saveperform_validationsvalid? → 同様のフローでmy_methodが実行される

このように、validate :my_methodというシンプルなコードが、Railsの強力なコールバックシステムによってバリデーション処理を実行するまでに変換されます。