0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

K27:【鍛錬】依存が連鎖するその前に! 参照地獄の終わらせ方 ― 向き/境界/配布単位

Last updated at Posted at 2026-01-18

連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index

ビルドは通る。レビューも通る。
なのに本番だけ落ちる。しかも自分の端末では再現しない。

考えるのを後回しにして、csprojにプロジェクト参照を追加し、共通ライブラリのCommonに便利メソッドを増やす。
次の改修で、そこが火種になる。

抜けやすいのは「依存の向き」「境界」「配布単位」が揃っていないこと。
このページでは、.NET 8 / WinFormsで依存管理を壊れにくくする型をまとめる。

まず言葉を置く(ここで詰まらない)

  • 依存の向き: どのプロジェクトが、どのプロジェクトを参照してよいかの方向
  • 境界(Contracts): 役割の境目。Interface/DTO/Resultなど「約束」だけを置く場所
  • 配布単位: 端末に展開する単位。フォルダ丸ごと入替できる形が強い

このページのゴール

  • 参照トラブルを「どこで死んでいるか」で分類し、調査対象を最短で確定する
  • 依存の向き(UI -> Application -> Domain)を決め、相互参照を消す
  • 境界を「契約(Interface/DTO/Result)」で切り、共有プロジェクトで泥団子を作らない
  • 参照方式とバージョン管理を揃え、端末差/人差を減らす
  • 配布単位を決め、残骸と欠落を潰す
  • 起動時に「何がロードされたか」を記録し、切り分けを速くする

結論: 依存は「向き」と「境界」と「配布単位」で決まる

依存管理が運用で壊れる原因は、実装の巧拙よりも「依存の向き」「境界の曖昧さ」「配布単位の曖昧さ」に寄る。

観点 壊れやすい状態 壊れにくい状態
依存の向き UIが何でも参照する/相互参照がある UI -> Application -> Domain の片方向
境界 共有プロジェクトに何でも入る 契約(Interface/DTO)を境界にする
配布単位 exeフォルダにdllを雑に同梱/手作業で足し引き 「同梱するもの」を決めて検証する
バージョン ローカル参照が混在/参照元不明 PackageReference等で一貫管理
実行時解決 何がロードされたか不明 起動時に Name/Version/Location を残す

依存解決は地雷原。踏むと実行時に落ちる。


似ているトラブルを先に切り分ける

参照の崩れ方は大きく2系統に分かれる。

  • 構築時(restore/build)で死ぬ: NuGet競合/依存解決経路/SDK差
  • 実行時(run)で死ぬ: 配布残骸/同名別dll/動的ロード

構築時の止血はE02にまとめてある。
E02: 参照が壊れる NuGet競合の止血と恒久対策

ここでは「構造(向き/境界/配布単位)」を揃えて、そもそも運用トラブルを起こしにくくする型を扱う。


まず分類: 参照の崩れは3つの層で起きる

どこに出る 代表的な症状 確定情報(これだけ取る) 一撃の止血
restore(依存解決) ビルド出力/CIログ NUxxxx, 端末差で依存解決結果が変わる dotnet list package --include-transitive / obj/project.assets.json lock導入/中央管理/キャッシュ掃除
build(コンパイル) ビルド出力/CIログ CS1705等の参照Version差 失敗プロジェクトと参照先Version Version統一/参照方式統一
run(実行時) アプリログ/イベントログ FileLoadException/MissingMethod/特定端末だけ落ちる ロードdllの Name/Version/Location 配布フォルダ全消し→成果物丸ごと展開

FileLoadException は「dllは見つかったが、ロード時に条件(署名/バージョン/依存関係など)が合わず読み込めない」系の例外。
MissingMethodException は「コンパイル時に見えていたメソッドが、実行時にロードされたdllには存在しない」時に出る。
どちらも「ビルドは通るのに実行時だけ壊れる」代表で、配布残骸/同名別dll/Version差が原因になりやすい。

「どこで死んでいるか」と「何が選ばれたか」を先に確定させると、迷子にならない。


掟: 今回守るルール

  1. 参照の向きを決め、相互参照を作らない
    理由: 依存が循環すると、原因と影響が入れ替わり、修正が別の場所へ波及しやすい。
  2. 境界は「契約」で切り、共有プロジェクトで泥団子を作らない
    理由: 共通化は速いが、運用トラブルが出た時の震源地になりやすい。
  3. 参照方式とバージョン管理を揃え、参照元不明を消す
    理由: 同名別dllや端末差は「混在」から始まる。
  4. 配布単位を決め、配布は丸ごと入替を原則にする(上書き禁止)
    理由: 古いdll残骸と欠落が、実行時トラブルの主犯になりやすい。
  5. 起動時にロード情報を記録し、切り分けを速くする
    理由: 証拠が残るだけで、再現しないが再現可能になる。

実装の要点

1) 依存の向きを決める「最小分割」

最小でも次の3層に分ける。WinFormsでも効く。

プロジェクト 役割 参照してよいもの
App.UI WinForms/画面/入力 App.Application のみ
App.Application ユースケース/手続き App.Domain + App.Infrastructure(必要最小限)
App.Domain 業務ルール/モデル 原則何も参照しない

失敗例はここで起きる。

  • UIがInfrastructureを直参照してDB/HTTPを叩く
  • Common/Utilsに業務と技術が混ざり、全員が参照する

依存が連鎖する前に、参照してよい方向を決める。

2) 境界は「契約(Contracts)」で切る

境界に置くのは契約だけにする。

  • Interface(Repository/Serviceの抽象)
  • DTO(画面とUseCase間の入力/出力)
  • Result型(成功/失敗の表現)

逆に、境界に置くと崩れやすいもの。

  • ロガー実装
  • 設定読み込み
  • DB接続
  • WinFormsの型(Control/Form)

契約は薄く、実装は奥へ。後でDLLに切り出せる境界になる。

3) 参照方式とバージョン管理を揃える

参照の崩れは、参照方式の混在で再現性が消える。

  • PackageReferenceを基本に寄せる
  • ローカル参照(HintPath)は最終手段にする
  • Version指定は散らさず、中央に寄せる

中央管理(例: Directory.Packages.props)

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>

  <ItemGroup>
    <PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
    <PackageVersion Include="System.Text.Json" Version="8.0.0" />
  </ItemGroup>
</Project>

各プロジェクトはVersionを書かずに参照する。

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Logging" />
  <PackageReference Include="System.Text.Json" />
</ItemGroup>

依存解決結果を揺らさない(例: packages.lock.json)

<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

CIはlocked-modeで依存解決結果の揺れを拒否する。

# 例: CI
dotnet restore --locked-mode

中央管理とlockは、端末差の起点を塞ぐ。

4) 配布単位を決めて、残骸と欠落を潰す

運用で壊れる多くは「配布物の揺れ」。

  • 配布物はCIで作った成果物だけを基準にする
  • 配布は「フォルダ丸ごと入替」を原則にする(上書き禁止)
  • 例外的に上書きになるなら、入替前に配布フォルダを全消しする

dotnet publishの出力フォルダは、そのまま配布単位にできる。

dotnet publish -c Release -r win-x64 --self-contained false

「配布物に含めるdll一覧」を決めたいなら、成果物にmanifest(ファイル一覧)を同梱しておくと強い。

  • 欠落: 起動前に検知できる
  • 残骸: 入替運用が崩れていることが分かる

5) 起動時にロード情報を記録する(切り分けを速くする証拠)

実行時トラブルの切り分けは、最後はこれに収束する。

  • Name
  • Version
  • Location(どこからロードされたか)
using System.Diagnostics;
using System.Reflection;
using System.Text;

static class AssemblyProbe
{
    public static string DumpLoadedAssemblies()
    {
        var asmList = AppDomain.CurrentDomain.GetAssemblies()
            .OrderBy(a => a.GetName().Name, StringComparer.OrdinalIgnoreCase);

        var sb = new StringBuilder();
        foreach (var a in asmList)
        {
            var n = a.GetName();
            var loc = SafeLocation(a);
            var fv = SafeFileVersion(loc);
            sb.AppendLine($"{n.Name}\t{n.Version}\t{fv}\t{loc}");
        }
        return sb.ToString();
    }

    private static string SafeLocation(Assembly a)
    {
        try { return a.Location; }
        catch { return "(dynamic)"; }
    }

    private static string SafeFileVersion(string location)
    {
        if (string.IsNullOrWhiteSpace(location) || location == "(dynamic)") return "-";
        try { return FileVersionInfo.GetVersionInfo(location).FileVersion ?? "-"; }
        catch { return "-"; }
    }
}

起動時に1回だけ出す。
ログに「Name/Version/Location」が揃うだけで、トラブル端末の切り分け速度が上がる。

6) 止血テンプレ(証拠取り -> 復旧 -> 恒久)

手順 目的 具体策
証拠取り 誤ったdllを拾っていないか確定 起動時ログで Location/Version を採取
止血 トラブル端末を復旧 配布フォルダを全消し→成果物を丸ごと展開
恒久 運用で揺れない仕組み CI成果物固定/配布スクリプト化/manifest検証

採用判断基準: いつ使う/使わない

判断 使う 使わない/注意
3層分割(UI/Application/Domain) 長寿命の業務アプリ、改修が続く 小規模PoCでは過剰になり得る
中央管理 + lock 複数端末/複数メンバー/CI運用 単独開発でも後で効く(導入コストは小)
丸ごと入替配布 手作業配布が残る/端末差が出る 自動更新があるなら更新機構側で担保
起動時アセンブリログ 実行時だけ落ちる事象が出た/出そう ログ出力制約が強い環境では代替(イベントログ等)
プラグイン/動的ロード 機能追加頻度が高い/差し替えが必要 依存解決が複雑化する(隔離設計が必要)
.NET Framework 4.8で差が出やすい所(必要な場合のみ)
  • bindingRedirect(app.config)が絡むと、参照Version差は「設定」で発生し得る
  • GACや既存の探査パスの影響で、想定外のdllを拾うケースがある
  • 動的解決は AppDomain.AssemblyResolve が主戦場になる

判例(OK/NG)

観点 OK例 NG例 理由(困ること) レビューで見る所
参照の向き UI -> UseCase -> Domain UI -> Infrastructure直参照 UI変更や環境差が業務ロジックへ波及 UIプロジェクトの参照一覧
境界 ContractsにInterface/DTOだけ Commonに業務/技術/設定が混在 依存が全方向に広がり震源地化 Common/Sharedの中身
参照方式 PackageReferenceで統一 HintPath/ローカルdll混在 参照元不明、同名別dll混入 csprojのReference記述
Version管理 Directory.Packages.propsで中央管理 各csprojでVersionがバラバラ 端末差と依存解決差が起きる Version指定の分散
配布 publish成果物を丸ごと入替 上書きコピーで運用 古いdll残骸が残る 配布手順/スクリプト
観測性 起動時にName/Version/Locationを記録 本番で拾ったdllが不明 再現しないで時間が溶ける 起動ログの有無

レビュー観点

観点 ありがちな見落とし 困り方 指摘コメント例(直球禁止)
参照の向き 画面からInfraのクラスをnewしている 特定端末差で動作が変わる 依存方向が逆転しており、差し替え余地が無い構造に見える
共通化 Utils/Commonに何でも入れる 小変更で全体が崩れる 共通に寄せた結果、境界が消えて依存が拡散しているように見える
参照方式 1プロジェクトだけローカルdll参照 同名別dll混入、実行時だけ落ちる 参照元が追えず、配布時に混在が起きる懸念がある
Version管理 Version指定が散在 依存解決結果が端末で揺れる Versionの宣言が分散しており、解決結果が環境差で変わり得る
配布 手作業でdllを足し引き 欠落/残骸で端末差が出る 配布単位が揺れるため、再現性が担保できないように見える
観測性 例外はあるがロード元が無い 原因特定が遅れる 実行時に拾った実体(Location/Version)が残らず、切り分けが難しくなる

禁書庫A: 逆引き(症状→原因→対策)

症状 ありがちな原因 切り分け(見る場所) 最短の対処 再発防止(ルール化)
ある端末だけ起動直後に落ちる 配布残骸(古いdllが残る) 起動時のName/Version/Location 配布フォルダ全消し→成果物丸ごと展開 丸ごと入替を原則化(上書き禁止)
特定画面だけMissingMethod 同名別dllを拾う/Version差 ロードdllのLocationとFileVersion 正しいdllだけに揃える(混在排除) 参照方式統一(ローカル参照排除)
社内配布手順変更後に一部機能だけ壊れる 配布単位が揺れる(欠落) publish成果物と配布先の差分 欠落dllを復旧 manifest検証/配布スクリプト化
restoreは通るが端末差が出る nuget.config/source/キャッシュ差 dotnet list package / assets.json キャッシュ掃除→restore 中央管理 + lockで固定
CIは通るが手元だけ落ちる SDK/VS差/ローカル設定差 global.json/SDKバージョン SDK固定/ビルド環境統一 CI成果物を基準にする運用
プラグイン追加後に落ちる 依存解決経路が複数 AssemblyLoadContext/Resolving 依存を同梱ルールに揃える Contracts分離 + プラグイン隔離

迷った時の一手(決め打ちで証拠を取る)

  • restore/buildで死ぬ: まず「どこで死んでいるか」と「何が選ばれたか」(E02)
  • runで死ぬ: Location/Versionを記録して、配布残骸を疑う

禁書庫B: 早見表(決め打ちで読む)

状況 先に確定するもの 次にやる
端末差で落ちる 起動時のName/Version/Location 配布フォルダ全消し→成果物丸ごと展開
ビルドが通らない NUxxxx/CS1705の種別 restore/buildログから分岐(E02)
何が入っているか不明 publish成果物一覧(=配布単位) CI成果物固定 + 丸ごと入替
依存が増え続ける 依存の向き/境界の契約 3層分割 + Contracts化

関連トピック

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?