じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

ドメイン規約を lint で表現する — `Order` に `is_paid + payment_id` を共存させない

はじめに

長年、動いた DB を覗くと、たいていこういうレコードが眠っています。

{ "order_id": 12345, "is_paid": true, "payment_id": null }

is_paid = true なのに payment_id は null。 ドメイン上、 同時に成立してはいけない組み合わせ が静かに残り、数ヶ月後の返金処理で例外を出します。

今後 「型で illegal state を表現不能にする」みたいな発表をします 。 そしてその前の記事で書いた 「rowan で自家製 lint を書く」 は、 cargo check に乗らない規約をコードで強制する話でした。 この 2 つを 組み合わせて実プロジェクトに入れるとどうなるか が今回のテーマです。

型は壁、Rustでもバグを直すな、表現できなくせよ by nwiizo | トーク | 関数型まつり2026 #fp_matsuri - fortee.jp

syu-m-5151.hatenablog.com

順番:

  1. ドメインを enum で型の壁にする (= 新規コードへのガード)
  2. 既存コードベースに残る古いパターンを rowan の lint で網を張る (= 後戻りのガード)
  3. lint を build.rs / xtask で常時走らせて CI で機械化する
  4. 古いコードを少しずつ enum 化しながら lint を緩めていく

題材は Order ドメイン。 「未払い / 支払い済み」 を is_paid: bool + payment_id: Option<_> で表現していたコードベースに、型と lint の 2 段の壁を入れていきます。

第一の壁 — 型で illegal state を消す

問題のある struct はこうでした。

pub struct Order {
    pub id: u64,
    pub is_paid: bool,
    pub payment_id: Option<u64>,
}

このとき作れる組み合わせは 4 通り。 「未払い・決済 ID なし」 と 「支払い済み・決済 ID あり」 の正常 2 通りに加えて、 「未払いだが決済 ID がある」 「支払い済みだが決済 ID がない」 の 不正 2 通りも書けてしまう 。 4 通り全部にコメント書いてレビューで潰す、では運用できません。

正常な状態だけを enum で並べ直します。

#[derive(Debug)]
pub struct OrderId(u64);

#[derive(Debug)]
pub struct PaymentId(u64);

#[derive(Debug)]
#[non_exhaustive]
pub enum PaymentStatus {
    Unpaid,
    Paid {
        payment_id: PaymentId,
        paid_at: chrono::DateTime<chrono::Utc>,
    },
}

pub struct Order {
    pub id: OrderId,
    pub status: PaymentStatus,
}

これで 「未払いだが決済 ID あり」 は 書こうとしても書けません 。 Unpaid には payment_id フィールドがそもそも存在しない。 match で書けば、 将来 Refunded を追加した瞬間に 未対応の関数が全部コンパイルエラーになり、 修正漏れが起こせなくなります。

ここまでが前の記事 「illegal state を表現不可能にする」 の振り返り。 ここから先が今回の本題です。

なぜ型だけでは止まらないのか

問題は 既存コードベース です。 5 年動いてきたサービスのコードは、 こうなっています。

  • ハンドラは Order { id, is_paid, payment_id } を受け取って serde::Deserialize でビルドする
  • repository 層は DB の is_paid BOOLEAN, payment_id BIGINT NULL をそのまま Option にする
  • 業務コードのあちこちで if order.is_paid && order.payment_id.is_some() { ... } のガードが書かれている

ここに 「Order を enum にしましょう」 を一気に投げると、 ハンドラ・リポジトリ・ガード・テストが 全ファイル巻き込まれて 数日仕事になります。 そして、 その間も誰かが古い形で pub struct Order { is_paid: bool, payment_id: Option<u64> } を別ドメインに書き足す可能性が消えません。

新規の侵入を コンパイラで止める のが lint の役目です。 cargo check は 「型として正しいか」 しか見てくれません。 「同じ struct に is_paid: bool と payment_id: Option を共存させたら警告」 のような規約は、 自分で書く必要があります。

第二の壁 — rowan で bool-option-pair lint を書く

rowan は rust-analyzer 内部の構文木ライブラリで、 ra_ap_syntax を経由すれば Rust grammar をそのまま借りられます。 構文木を歩いて、規約違反を見つけたら診断を吐くだけ。

最小実装はこうなります。

use ra_ap_syntax::ast::{self, AstNode, HasName, HasVisibility};

pub struct BoolOptionPair;

#[derive(Debug)]
pub struct Diagnostic {
    pub rule: &'static str,
    pub message: String,
    pub line: u32,
}

impl BoolOptionPair {
    pub fn check(&self, file: &ast::SourceFile, source: &str) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        for node in file.syntax().descendants() {
            let Some(strukt) = ast::Struct::cast(node) else { continue };
            let Some(ast::FieldList::RecordFieldList(fields)) = strukt.field_list() else {
                continue;
            };

            // public な bool フィールドと Option フィールドを集める
            let mut bool_flags: Vec<(String, ra_ap_syntax::TextRange)> = Vec::new();
            let mut options: Vec<String> = Vec::new();
            for field in fields.fields() {
                if field.visibility().is_none() { continue }
                let Some(name) = field.name() else { continue };
                let Some(ty)   = field.ty()   else { continue };
                let nm = name.text().to_string();
                let tt = ty.syntax().text().to_string();
                if tt == "bool" {
                    bool_flags.push((nm, field.syntax().text_range()));
                } else if tt.starts_with("Option<") {
                    options.push(nm);
                }
            }

            // ドメイン規約: `is_*` / `has_*` / `was_*` のフラグと
            //               `_id` / `_at` で終わる Option が同じ struct にいるとき警告
            let suspicious_option = options.iter().any(|n| {
                n.ends_with("_id") || n.ends_with("_at") || n.ends_with("_by")
            });
            for (name, range) in &bool_flags {
                let prefixed = name.starts_with("is_")
                    || name.starts_with("has_")
                    || name.starts_with("was_");
                if prefixed && suspicious_option {
                    let line = line_of(source, range.start());
                    diags.push(Diagnostic {
                        rule: "bool-option-pair",
                        message: format!(
                            "`{name}: bool` と隣接する `Option<_>` フィールドは相関状態を許す。\
                             enum で variant に揃える"
                        ),
                        line,
                    });
                }
            }
        }
        diags
    }
}

fn line_of(source: &str, offset: ra_ap_syntax::TextSize) -> u32 {
    let target: usize = offset.into();
    source[..target.min(source.len())].matches('\n').count() as u32 + 1
}

100 行弱。 ヒューリスティックは荒いですが、 これでさっきの Order struct はちゃんと引っかかります。

let src = r#"
    pub struct Order {
        pub id: u64,
        pub is_paid: bool,
        pub payment_id: Option<u64>,
    }
"#;
let parse = ra_ap_syntax::SourceFile::parse(src, ra_ap_syntax::Edition::Edition2024);
for d in BoolOptionPair.check(&parse.tree(), src) {
    println!("L{}: [{}] {}", d.line, d.rule, d.message);
}
// L4: [bool-option-pair] `is_paid: bool` と隣接する `Option<_>` フィールドは相関状態を許す。enum で variant に揃える

cargo check も clippy もこの違反を見ません。 自分で書いてはじめて、 ドメイン語彙が壁になります。

ヒューリスティックを育てる

最初の lint は荒くて構いません。 偽陽性 (false positive) が出たら、 そのコードを見て妥当な反論 を書き、 ヒューリスティックを絞ります。 たとえば:

  • is_premium: bool + subscription_url: Option<Url> のような 完全独立な 2 値 は、 stem を要求して回避できる (is_paid ↔ payment_id のように 語幹が部分一致 する場合のみ警告)
  • #[serde(default)] の DTO は外部入力を一旦受けるための型なので、 内側に拡散しない限り許可する → struct に #[derive(Deserialize)] がついていたらスキップする

「正しい lint」 は最初から書けません。 ドメインの理解が深まるごとに、 ヒューリスティックを足したり緩めたりする のが運用です。 lint も育てる対象です。

lint と test の棲み分け

「ドメイン規約を lint で書ける」 と言うと、 ほぼ必ず 「テストでよくないか?」 と問われます。 答えは どちらも要る、 ただし担当が違う です。 自分の samples/rust-types-as-walls で cargo test --tests を回すと、 9 suites で 22 件が並走します。 内訳を見ると、 道具を揃えた意図がはっきりします。

守りたいこと 道具 具体例
型として正しいか (網羅性、所有権、取り違え) コンパイラ match PaymentStatus の variant 網羅、UserId と OrderId の混合禁止
「同じ struct に bool + Option を共存させない」 ような構造規約 rowan lint bool-option-pair, raw-id-field, non-exhaustive-pub-error
「このコードはコンパイルエラーであるべき」 という API 契約 trybuild UI test non_exhaustive_match_fail, sealed_trait_external_impl_fail (tests/fixtures/)
パース成功 → 値の不変条件を満たす property test (proptest) Password::new が返す値が長さ・文字種を必ず満たす
ドメインの振る舞い integration test 「未払い注文を ship しようとしたら 4xx」

線の引き方は明快です。 構造的・静的なルールはコンパイラ + lint、 値・振る舞いのルールは property + integration test 。 is_paid: bool + payment_id: Option<_> を弾くのは前者なので lint、 「Order::pay() を 2 回呼んだら error」 は後者なのでテスト。 互いに領域が重ならず、 置換できません。

4 段で何が止まるかを揃える

CI で 4 段全部回すと、 PR が どの壁を破ろうとしているか をレビュー前に分けて見られます。

ステップ 通る条件 落ちたとき直す場所
cargo build 型として正しい プロダクトコードの型
cargo xtask lint ドメイン規約に違反しない 構造を直すか、 lint を緩める判断
cargo test --tests (trybuild) 「コンパイルエラーになるべき」 が壊れていない API 契約のレグレッション
cargo test --tests (proptest / integration) 値・振る舞いが期待通り 実装のバグ

samples/rust-types-as-walls の test 構成は、 この 4 段の 3 段目と 4 段目 を担当しています。 tests/fixtures/non_exhaustive_match_fail/ は 「#[non_exhaustive] enum を _ => 無しで match するとコンパイルエラーになる」 を契約として固定し、 tests/password_props.rs は 「Password::new が返す全候補が不変条件を満たす」 を property で固定する。 lint (2 段目) を加えると、 「そもそも password: String のような raw 型を field に晒さない」 という 構造の話 がここに刺さります。

lint に寄せすぎてはいけないもの

逆方向の境界線も同じくらい重要です。 lint で書けてしまうが、 書くべきでない ものがあります。

  • 値の中身に依存するチェック (例: 「due_at は created_at より後」)。 値が走らないと判定できないので property test の領域。 lint で正規表現を書き始めたら警報。
  • 複雑なドメイン式 (例: 税計算、 割引)。 ユニットテストで十分。 lint に持ち込むと、 荒い AST マッチングで偽陽性が増えます。
  • 言語仕様レベルの一般則 (例: 「Result を返す関数で ? を使うべき」)。 clippy が既に持っているなら、 自家製で書き直さない。

「コンパイラが見逃す構造的な規約」 だけが、 自家製 lint の領域です。 ここを越えるとテストや clippy と仕事が被って、 メンテナンスコストだけが増えます。

偽陽性が出たときの逃がし方

運用していると、 ドメイン的に正当な struct がたまたま lint に引っかかる事故が必ず起きます。 そのときの逃がし方を最初から決めておきます。 自家製 lint は 2 段階で逃がせるように作っておくのが、 実運用に耐えるための最小要件 です。

1. コメントで局所的に逃がす — 違反箇所だけ・直前のコメント行で抑制。

// rbp-lint-allow: bool-option-pair (reason: 旧スキーマ互換のため正しく相関しない)
pub struct LegacyOrder {
    pub is_paid: bool,           // ← この struct 内では bool-option-pair を抑制
    pub payment_id: Option<u64>,
}

let raw = std::fs::read_to_string("/etc/passwd").unwrap(); // rbp-lint-allow: no-unwrap

行末・直前数行・ファイル先頭の // コメントを lint 側でスキャンします。 同一行末尾は その行の違反だけ、 直前の連続コメント行は 次の違反 1 件、 ファイル先頭 (//! 含む) は そのファイル全体 に効きます。

2. プロジェクト単位は .rbp-lint.toml で逃がす — ルールごとの severity 上書き、 パスごとの除外。

# .rbp-lint.toml (リポジトリルートに置く)
[rules]
no-unwrap          = "error"      # 既定どおり強制
bool-option-pair   = "warning"    # 段階導入の途中は warning に下げる
debug-print        = "off"        # CLI バイナリでは println! を許す

[paths]
exclude = ["**/examples/**", "vendor/**", "**/*_generated.rs"]

off にすると lint そのものを実行しない。 既存違反が大量に残るプロジェクトに lint を入れるとき、 まず全部 warning で着地して、 1 つずつ消えたら error に上げる、 という運用が成り立ちます。

ここで揃えたいのが、 sample の test ファイル冒頭で見える Rust 1.81+ 流儀です。

#![allow(
    clippy::expect_used,
    clippy::panic,
    clippy::unwrap_used,
    reason = "tests keep assertions and fixture setup direct"
)]

reason = "..." を必須にする慣習を、 自家製 lint の suppression にも持ち込みます。 // rbp-lint-allow: rule (reason: ...) という書式を コードレビューで強制 すれば、 「許す」 ことは許すが 理由なしの許可は許さない 状態になります。 「ここは bool-option-pair を抑制してるが、 なぜ?」 がコードに残る。 lint が厳しすぎても緩すぎても運用が壊れる中で、 この第三の道だけが続きます。

まだ実装していない逃がし方: 「baseline」 — git diff main で新規追加された違反だけ報告する機能です。 巨大コードベースに lint を一気に入れるとき、 既存違反を「いまある分は放置、 新規だけ警告」 にできれば段階導入が一気に楽になります。 --baseline=main.json でスナップショットを許可リストとして渡す設計が定石ですが、 これは別記事で。 当面は .rbp-lint.toml の warning 段階と、 違反箇所への suppression コメントで代替できます。

CI に組み込む — 違反コードを commit させない

lint を CLI で書いただけでは、 走らせ忘れた瞬間に違反が混ざります。 自家製 lint を cargo build か cargo xtask lint で常時走らせる 仕組みを必ずセットで作ります。

xtask パターン (推奨)

workspace に xtask/ を切って、 cargo xtask lint で起動します。 開発中の cargo check を遅くしないので、 普段使いに向いています。

# .cargo/config.toml
[alias]
xtask = "run --package xtask --release --"
// xtask/src/main.rs
use std::process::ExitCode;

fn main() -> ExitCode {
    let cmd = std::env::args().nth(1).unwrap_or_default();
    if cmd != "lint" {
        eprintln!("usage: cargo xtask lint");
        return ExitCode::from(2);
    }
    let mut errors = 0;
    for entry in walkdir::WalkDir::new("crates") {
        let entry = entry.expect("walkdir");
        if entry.path().extension().is_some_and(|e| e == "rs") {
            for d in my_lint::lint_file(entry.path()).expect("lint") {
                println!("{}:{}: [{}] {}", d.file.display(), d.line, d.rule, d.message);
                if matches!(d.severity, my_lint::Severity::Error) {
                    errors += 1;
                }
            }
        }
    }
    if errors > 0 { ExitCode::from(1) } else { ExitCode::SUCCESS }
}

build.rs で cargo build 時にも走らせる

「絶対 commit させない」 を強制したいなら、 build.rs で同じ lint を呼んで panic!() で止めます。

// build.rs
fn main() {
    println!("cargo:rerun-if-changed=src");
    let mut errors = 0;
    for e in walkdir::WalkDir::new("src") {
        let e = e.expect("walkdir");
        if e.path().extension().is_some_and(|x| x == "rs") {
            println!("cargo:rerun-if-changed={}", e.path().display());
            for d in my_lint::lint_file(e.path()).expect("lint") {
                println!("cargo:warning={}:{}: [{}] {}",
                    d.file.display(), d.line, d.rule, d.message);
                if matches!(d.severity, my_lint::Severity::Error) { errors += 1; }
            }
        }
    }
    if errors > 0 {
        panic!("ドメイン lint: {errors} 件の error を解消するまでビルドできません");
    }
}

build.rs は 強い ですが、 cargo check が遅くなる副作用があるので、 開発体験を見ながら判断します。 個人的には xtask + CI step で十分なケースが多く、 build.rs は致命的なドメイン規約だけに絞ることが多いです。

CI step

GitHub Actions ならこれだけ。

- name: domain lint
  run: cargo xtask lint

PR に lint 結果が必ず出るので、 レビュー前に弾けます。

段階導入のロードマップ

bool-option-pair を一気に error にすると、 既存違反が大量に出てビルドが通らない状況になりがちです。 段階を踏みます。 すべて .rbp-lint.toml のルール severity を切り替えるだけで実現できます 。

フェーズ .rbp-lint.toml の設定 目標
1. 着地 bool-option-pair = "note" 既存違反を可視化する。 ビルドは止めない
2. 新規ガード bool-option-pair = "warning" + 既存違反箇所に // rbp-lint-allow: を貼る 新規違反だけ警告。 既存はコメントで明示的に「保留」と記録
3. 全違反禁止 bool-option-pair = "error" + suppression コメントは reason 必須 違反 = ビルド不可。 suppression は理由付きで残す

phase 2 の発想は 「--baseline=main で git diff から新規違反だけ抜く」 と同じことを、 suppression コメントを許可リストとして使って静的に表現する ことで実現します。 既存違反 1 件ごとに // rbp-lint-allow: bool-option-pair (reason: 旧スキーマ互換) を貼る作業は、 コードレビュー上 「ここは技術的負債である」 が永続化される副産物が大きい。 純粋な baseline ファイル方式と違って、 負債の理由がコードに残ります 。

phase 3 に到達したら、 bool-option-pair は 書けないドメイン規約 になります。 そこで初めて、 古いコードの enum 化を 1 ファイルずつ進められます。

実運用上の落とし穴 — 数値で測る

理論だけで終わらせないために、 手元の tools/rbp-lint (今回紹介した 21 ルール) を実コードに当てた数字を出します。

対象 files 実時間 (release ビルド) 診断数 コメント
tools/rbp-lint/src (lint 自身) 28 0.38s 15 うち 9 件は lint 自身のパターン定義文字列 が hardcoded-secret に引っかかった偽陽性
samples/rust-types-as-walls 48 1.09s 135 examples/ の println! で debug-print が大量発火
samples/idiomatic-rust-2024 17 <0.01s 44 キャッシュ後再実行

学べることは 3 つ。

性能は CI 用途には十分。 cold で 1 ファイル 13–22ms。 千ファイルでも 20 秒程度です。 ただし rust-analyzer のリアルタイム保存ループ (300ms 想定) に混ぜるには重いので、 build.rs で全部走らせるのは避ける のが無難。 致命級だけ build.rs、 通常は xtask + CI、 が現実解です。

lint 自身がいちばん最初の偽陽性源。 hardcoded_secret.rs には "sk-", "AKIA" のような検出対象パターンを文字列リテラルとして書く必要がありますが、 これが 自分の lint で error 級違反として検出される という鏡像が起きました。 解決は 1 行: その lint ファイルの先頭に // rbp-lint-allow: hardcoded-secret (reason: パターン定義そのもの) を入れるだけ。 他の偽陽性 (CLI の println!, 学習用 example の panic! など) も同じく .rbp-lint.toml の [paths] exclude か [rules] debug-print = "off" で潰せます。 suppression と config が無いと lint は実運用で死にます。

ドメインに合わせて lint 数を絞ること。 21 ルール全部 on で example 48 ファイルから 135 件の診断は、 シグナル/ノイズ比が悪い。 プロダクト本体ではなく example だから出てるノイズです。 自分のコードベースで意味のあるルールを 5–10 個に絞り、 残りは off、 が運用の落とし所。 「全部入れる」 は誰も喜びません。

ra_ap_syntax のバージョン揺れ

ブログの例は ra_ap_syntax = "0.0.331" で書いてあります。 rust-analyzer に追従する関係で minor で API が変わる ので、 lint プロジェクトでは:

  • Cargo.lock を 必ずコミット する (binary crate 扱い)
  • 上げるときはまず cargo test で AST cast の壊れを検出
  • CI に「依存固定検査」 (cargo update --dry-run で意図しない上昇を弾く) を入れる

これを忘れると、 ある日 PR が落ちたとき 「自分のコードは何も変えてないのに lint が通らない」 という事故になります。

cargo:warning= の落とし穴

build.rs で cargo:warning=... だけ書くと 黄色く出るだけで cargo build は止まりません。 「絶対 commit させない」 を実現したいなら必ず panic!() を呼ぶ必要があります。 ブログの build.rs サンプルにも入れていますが、 ここを忘れて運用 1 ヶ月後に発覚するパターンが定番なので、 統合した直後に わざと違反を入れて build が落ちることをテスト で確認するところまでセットです。 phase 1 と phase 2 (clean) の双方向検証を、 cargo build --release を CI で回す形で残しておくと安全です。

ドメイン語彙を lint に育てる

bool-option-pair を入れて運用していると、 似たパターンの違反が見えてきます。

  • pub *_id: String / pub *_id: u64 の raw identifier (UserId / OrderId の取り違え予備軍)
  • pub status: String の stringly typed status (enum で潰すべき)
  • pub enum *Error で #[non_exhaustive] がない (SemVer 互換が壊れる)

これらを 1 つずつ lint にすると、 コードレビューの目視チェックリストがコードに昇格 します。 レビューで毎回同じ指摘を書いている、と思ったら lint 化のタイミングです。

ドメインに長く居るほど、 「このパターンが出たら危ない」 という嗅覚が増えます。 その嗅覚を コードに固定する のが、 自家製 lint の最大の価値です。 メンバー交代でも腐らない、 AI が書いた PR でも止まる、 type system の続きにある防衛線として動きます。

手元の tools/rbp-lint には bool-option-pair / raw-id-field / status-string-field / non-exhaustive-pub-error の 4 つを実装してあります。 全部この記事と同じパターンで書けます。

まとめ

  • 型で illegal state を消した上に、 lint で 「規約違反」 もコンパイルエラーにする
  • rowan (ra_ap_syntax) で書けば、 cargo check に乗らない規約も静的検出できる
  • xtask + CI step で常時走らせる。 致命なら build.rs で cargo build も止める
  • suppression コメント + .rbp-lint.toml を最初から入れる。 偽陽性の逃げ場が無いと lint は実運用で死ぬ
  • いきなり error にせず、 note → warning + suppression コメント → error で段階導入する
  • レビューで毎回出る指摘は lint に昇格させる。 ドメイン語彙が そのまま壁になる

型と lint は、 同じ 「壁を築く」 行為の表裏です。 型で書けないものを止め、 lint で書けるが書いてはいけないものを止める。 両方を持つと、 ドメインモデルは AI が書いた PR にも壊されません。 そして 「許す」 ことを reason: ... 付きで残せる第三の道を持っておけば、 厳しすぎず緩すぎずの実運用に着地できます。

関連