Elixir向け「CLAUDE.md」:AIがイディオムに沿いOTPを意識したElixirを書くための13のルール

Dev.to / 2026/5/13

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical UsageModels & Research

要点

  • この記事は、AIがコンパイルやテストは通しても、OTPに基づく並行処理の型や監督(スーパービジョン)、関数のディスパッチ規約など、Elixirの主要なイディオムに沿わないコードを出しがちだと主張しています。
  • プロジェクトのルートにCLAUDE.mdを置き、影響の大きい13のルールを記載することで、AIにイディオム準拠かつOTPを意識したElixirを書かせることを提案しています。
  • ルール1では、共有ミュータブル状態やグローバル変数ではなく、GenServer・Supervisor・Task・AgentといったOTPの振る舞いで並行性をモデル化することを強調しています。
  • ルール2では、関数内の分岐にトップレベルのcond/caseを書くのではなく、関数ヘッドでのパターンマッチングを使うべきだと示しています。
  • ルール3では、|>(パイプ演算子)の使い方について、いつ使うべきか、各パイプ段を単一の操作として組み立てることなどの指針を提示しています。

Elixirには、ほとんどの言語よりも「動くコード」と「慣用的なコード」の間に、より鋭いギャップがあります。AIアシスタントはコンパイルしてテストも通るElixirを書けますが、経験豊富なElixir開発者が当然のこととして使うOTPパターン、関数ヘッドのディスパッチ、スーパビジョンツリー、パイプの慣習を見落としがちです。

その結果、開発環境では動くのに負荷がかかると壊れ、Elixirのエコシステムが期待する挙動になりません。

プロジェクトルートにあるCLAUDE.mdは、このギャップを埋めます。影響が最も大きい13のルールを紹介します。

Rule 1: OTP first — オブジェクトではなくプロセス

これはOTPアプリケーションです。並行動作を次でモデル化します:
- 状態を持つプロセスにはGenServer
- 障害許容と再起動戦略にはSupervisor
- 一度限りの並行作業にはTask
- 簡単な共有状態にはAgent(非自明なものはGenServerを優先すること)

共有ミュータブル状態、ミューテックス、グローバル変数で並行性をモデル化しないでください。
並行性と隔離の単位はプロセスです。

Elixirの並行性モデルは、OTPを通じてActorモデルに基づいて構築されています。この文脈なしのAIは、他の言語の抽象化に頼ってしまう傾向があります。ElixirのシステムはGenServer + Supervisorで作るものです。これは明示する必要があります。

Rule 2: 関数ヘッドのディスパッチ — 先頭でcondやcaseを使わない

分岐には関数ヘッドでのパターンマッチを使います:

  def process(%{status: :active} = user), do: ...
  def process(%{status: :inactive} = user), do: ...
  def process(%{status: status}), do: {:error, "unknown status: #{status}"}

ダメな例:
  def process(user) do
    cond do
      user.status == :active -> ...
      user.status == :inactive -> ...
    end
  end

`case`は関数内でのローカルな分岐にだけ使ってください。`cond`は一致する値がない(マッチングせず) boolean 式のために使ってください。

これは最も一般的な「慣用イディオムのギャップ」です。AIは、関数ヘッドのディスパッチの方がよりクリーンで慣用的であるにもかかわらず、関数レベルではデフォルトでcondcaseを選んでしまいがちです。Elixir開発者は、関数ヘッドのディスパッチを主要なディスパッチ機構として読み取ります。

Rule 3: パイプ演算子の規律

パイプのルール:
- 3ステップ以上でデータを変換するときは`|>`を使う
- 各パイプのステップは1つの操作にする(必要がない限りパイプ内に匿名関数を入れない)
- パイプされた関数の最初の引数は、変換されているデータである必要がある
- 本番コードで`IO.inspect/2`にパイプしない — Loggerを使う
- 可読性が落ちるなら、長いパイプを名前付きの中間変数に分解する

避ける:
  result = transform(filter(validate(data)))  # ネスト形式

優先する:
  result = data
    |> validate()
    |> filter()
    |> transform()

パイプ演算子はElixirの可読性の中心です。AIがネストした関数呼び出しを生成したり、途中で追加の引数を入れて「単一のデータフロー」というルールを壊してしまうことがあります。

Rule 4: with でのパターンマッチ — ネストしたcaseではなく

どのステップでも失敗しうる、連続した操作には`with`を使います:

  with {:ok, user} <- fetch_user(id),
       {:ok, account} <- fetch_account(user),
       {:ok, _} <- charge(account, amount) do
    {:ok, "charged"}
  else
    {:error, :not_found} -> {:error, "user not found"}
    {:error, reason} -> {:error, reason}
  end

ネストしたcase/ifチェーンはダメ。
失敗しうるすべての関数から`{:ok, value}``{:error, reason}`のタプルを返してください。

withは、失敗しうる処理を連結するための慣用的な方法です。ネストしたcaseブロックはすぐに膨らみ、拡張もしづらくなります。AIは「より一般的に見覚えがある」パターンとして、しばしばネストしたcaseを生成します。

Rule 5: スーパビジョンツリー — 守りで防ぐのではなく、クラッシュして復旧する

スーパビジョン戦略:
- 独立したワーカーには`:one_for_one`
- クラッシュ時に状態が不整合になる共有状態を持つワーカーには`:one_for_all`
- ワーカーに順序依存がある場合は`:rest_for_one`
- supervised(監督下)プロセスはapplication.ex、または専用のSupervisorモジュールで起動する
- クラッシュを避けるためにGenServerのコールバックで例外をrescueしないでください — クラッシュさせてください。スーパー バイザが再起動します

スーパーバイザこそがエラーハンドラです。ワーカープロセスで防御的なrescue句を書くのは設計に反します。

"Let it crash" は、Elixir/Erlangの中核となる哲学です。指針なしのAIはこれに逆らいがちです。AIはいたるところにrescueブロックを追加しがちです。OTPパターンは、プロセスがクラッシュし、スーパビジョンによって回復することを許容することです。

Rule 6: Ecto — changesets とクエリ

DBアクセス:Ectoのみ。
- すべてのデータ検証はchangesetsで行う:`cast``validate_required``validate_format`
- クエリはEcto.Query DSLで構築する — 複雑なクエリで`fragment/1`を使う場合を除き、生SQLは使わない
- ビジネスロジックで`Repo.get!`を使わない — `Repo.get`を使い、nilは明示的に扱う
- アソシエーションのプリロードを明示的に行う:`Repo.preload(user, [:posts])` — 遅延ロードをしない
- 原子的である必要がある操作には`Repo.transaction`を使う
- スキーマchangesetsは唯一の検証ポイント — コントローラやcontext関数ではない

Ectoの設計は考え方が強いです。指針なしのAIはいたるところでget!を使い(nilで例外を投げる)、プリロードを忘れ(N+1相当の問題を引き起こす)、検証を間違った層に置いてしまいます。

Rule 7: Contexts — 1スキーマ1モジュールではなく、境界づけられたモジュール

Phoenixのcontextsで整理する(Phoenixの外でも):
- ドメインごとに1つのcontextモジュール:`Accounts``Payments``Inventory`
- context関数が公開API:`Accounts.get_user(id)``Accounts.create_user(attrs)`
- スキーマモジュールはcontextの内部に閉じ込める — コントローラや他のcontextから直接呼び出さない
- context間での直接的なスキーマアクセスはしない — 所有するcontextのAPI経由でのみ行う

これはPhoenix 1.3+のアーキテクチャです。古い「1スキーマ1コントローラ」パターンは使わないでください。

Phoenix Contextsは、「fat model」問題を解決するために特に導入されました。古いPhoenixの資料で学習したAIは、contextを使わない前のパターンを生成します。バージョンは明示してください。

Rule 8: Atoms — 安全な使い方

返却形式: {"translated": "翻訳されたHTML"}
Atom discipline:
- ユーザーが提供した文字列を、`String.to_atom/1` でアトムに変換しない
  (アトムはガー
tbage collected されない — 無制限の生成はメモリリーク / DoS のベクター)
- 既知の文字列をアトムに変換するときは、`String.to_existing_atom/1` を使う
- モジュール名、関数名、自分のコード内でのマップキー:アトムで問題ない
- JSON のパース:基本は文字列キーを使う。アトムキーにしない(Jason のデフォルトは正しい)

String.to_atom/1 をユーザー入力で使うのは、Erlang/Elixir に固有のセキュリティおよび安定性上の脆弱性です。AI はこの文脈なしでそれを生成します。このルールは明示的に必要です。

ルール 9: メッセージパッシング — send、receive、そして GenServer の呼び出し

プロセス間通信:
- 同期リクエストには `GenServer.call/3` を使う(値を返す)
- 非同期の fire-and-forget には `GenServer.cast/2` を使う(戻り値なし)
- OTP によって管理されない、アドホックなメッセージ受け渡しに限り `send/2` + `receive` を使う
- `call` には常にタイムアウトを設定する:`GenServer.call(pid, msg, 5_000)`
- `handle_info/2` で想定外のメッセージを扱う — 受信ボックスに溜め込ませない

調整(coordination)のために `Process.sleep/1` を使うことは決してしない — `GenServer.call``Task.async/await` を使う。

プロセス間通信のパターンは Elixir 固有です。AI はガイダンスなしだと、GenServer が正しい場面でも send/receive を使ったり、システムメッセージ用の handle_info を忘れたりします。

ルール 10: ExUnit でのテスト — async とアイソレーション

テスト:
- 全テスト:グローバル状態を共有しない限り `use ExUnit.Case, async: true`(データベース)
- データベースのテスト:トランザクションによるアイソレーションのために `Ecto.Adapters.SQL.Sandbox` を使う
- テストのセットアップ:モジュール属性ではなく `setup` ブロックを使う
- `assert {:ok, _} = MyModule.function(args)` を使う — 構造にマッチさせる
- ファクトリ:テストデータには ExMachina を使う。手作りのマップではない
- テスト内で `Process.sleep` を使わない — async なアサーションにはタイムアウト付きの `assert_receive` を使う
- 外部サービスは `Mox` でモックする — 振る舞いを定義し、期待を検証する

Elixir の非同期テストと Ecto サンドボックスには、特定のセットアップが必要です。AI はガイダンスなしだと、遅くなったりアイソレーションを壊したりする直列(逐次)のテストを作成します。

ルール 11: Telemetry と構造化ログ

可観測性(Observability):
- ライブラリ/アプリケーションコードからメトリクスを発行するには `:telemetry` を使う
- ログエントリにコンテキストを付与するには `Logger.metadata/1` を使う:`Logger.metadata(user_id: id, request_id: req_id)`
- ログレベル:debug(開発で冗長に)、info(通常の運用)、warning(劣化)、error(失敗)
- 本番コードで `IO.puts``IO.inspect` をむき出しで使わない — Logger を使う
- Telemetry のハンドラはアプリケーションの起動時にアタッチする。インラインではなく

Telemetry は Elixir エコシステムにおける計測用の標準ライブラリです。AI は本番コード用に Logger に切り替えずにデバッグ目的で IO.inspect をよく使います。

ルール 12: ドメインデータには裸のマップより Struct

ドメインデータ:
- すべてのドメインエンティティに対して struct を定義する:`defstruct [:id, :name, :email]`
- 必須フィールドには `@enforce_keys` を使う:`@enforce_keys [:id, :name]`
- Struct はコンパイル時のキー検査を提供する — 裸のマップにはそれがない
- 形(shape)が分かっているときは `%User{}` を使う。`%{id: ..., name: ...}` ではない
- パブリック関数すべてに typespec を書く:`@spec create_user(attrs :: map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}`

typespec 付きの struct は dialyzer を有用にします。AI はドメインデータに対して裸のマップを生成しがちで、ドキュメントと静的解析の恩恵を失います。

ルール 13: Mix タスクとリリース

デプロイ:
- 本番デプロイには `mix release` を使う — `mix run` ではない
- 設定:ランタイム設定には `config/runtime.exs` を使う(起動時に読み取る env vars)
  コンパイル時のみは `config/config.exs`
- 秘密情報をハードコードしない — `config/runtime.exs` では `System.fetch_env!/1` を使う
- ヘルスチェック:Mix タスクのシェルアウトではなく、シンプルな HTTP エンドポイントを実装する
- 移行(ミグレーション):CI では常に `--no-start` を付けて実行する:`mix ecto.migrate --no-start`

リリース設定はよくつまずくポイントです。AI はガイダンスなしだと、ランタイムの値に対して config/config.exs を提案しがちです。そうすると env vars はコンパイル時に読み込まれてリリースに組み込まれてしまい、Docker デプロイで意図している挙動ではなくなります。

あなたの CLAUDE.md の出発点

# Elixir Project — AI Coding Rules

## アーキテクチャ
OTP アプリケーション。並行性のために GenServer + Supervisor を使う。落ちさせよ — supervisor が復旧を扱う。
ドメイン境界のためのコンテキスト。スキーマモジュールはそれぞれのコンテキストに private にする。

## パターン
関数レベルで cond/case よりも関数ヘッドディスパッチを使う。
with は逐次のフォールible 操作のために使う — どこでも {:ok, val} / {:error, reason} のタプル。
3 ステップ以上のデータ変換には pipe 演算子を使う。

## Ecto
すべてのバリデーションに Changeset を使う。明示的な preloads。Repo.get は Repo.get! ではない。
生 SQL は fragment/1 経由のみ。原子的な操作にはトランザクションを使う。

## アトム
ユーザー入力で String.to_atom/1 を使わない。既知の文字列に限り String.to_existing_atom/1 を使う。

## テスティング
可能な限り async: true。DB テストには Ecto.Adapters.SQL.Sandbox を使う。
外部サービスのモックには Mox を使う。async の検証には assert_receive を使う。テスト内で Process.sleep を使わない。

## 可観測性
メタデータ付きの Logger。メトリクスには Telemetry。本番環境で IO.puts/IO.inspect を使わない。

## デプロイ
mix release。env vars には runtime.exs。秘密情報には System.fetch_env!/1。

なぜ Elixir では特にこれが必要なのか

Elixirには、OTPパターン、パイプ演算子、with、コンテキストといった、命令型言語が同じ問題を解く方法から大きく逸脱する、非常に強い規約があります。幅広いコーパスで学習したAIは、より一般的なパターンをデフォルトにします。

CLAUDE.md は、AIに対して「どの10年期(年代)で、どのパラダイムで作業しているのか」を伝える方法です。

13以上の言語にまたがる完全なルールパックは gumroad — $27。