2026/05/13

C# async/awaitの落とし穴と使いこなしコツ

C# async/awaitの落とし穴と使いこなしコツ——「待たない」設計で変わること

async/awaitはC# 5から使えるようになって久しいけど、「とりあえず付ければ非同期になる」という理解のまま書くと、同期コードより遅くなったり、デッドロックで詰んだりする。自分もそういうハマり方を何度かやった。

この記事では、async/awaitで実際に踏みやすい罠と、「待たない」設計のコツを整理する。.NET 10(C# 14)を前提にしているが、考え方自体は.NET 6以降であれば共通だ。

罠その1——awaitを直列に並べると順次実行になる

いちばんよくあるミスはこれだ。複数の非同期処理を「なんとなく」awaitで並べると、前の処理が完全に終わるまで次が始まらない。

// ダメな例: 合計300ms かかる
var user = await GetUserAsync(userId);       // 100ms
var orders = await GetOrdersAsync(userId);   // 100ms
var address = await GetAddressAsync(userId); // 100ms

3つのAPIコールに依存関係がないのに、直列に待つだけで300msかかる。Task.WhenAllを使えば並列実行できて、合計は最大の100msに近づく。

// 正しい例: 約100ms で終わる
var userTask    = GetUserAsync(userId);
var ordersTask  = GetOrdersAsync(userId);
var addressTask = GetAddressAsync(userId);

await Task.WhenAll(userTask, ordersTask, addressTask);

var user    = userTask.Result;    // WhenAll後なので完了済み
var orders  = ordersTask.Result;
var address = addressTask.Result;

Task.WhenAllのあとで.Resultを使っているのは、この時点では既にタスクが完了しているから安全だ(デッドロックの心配はない)。もちろん各タスクをawaitしてもよい。

例外が複数ある場合の注意

Task.WhenAllはいずれかのタスクが例外を投げるとAggregateExceptionとして集約する。awaitするとその中の最初の例外がアンラップされて投げられる。複数タスクすべての例外を取り出したい場合は、task.Exceptionを個別に確認する必要がある。

罠その2——.Result/.Waitで同期的に待つとデッドロックする

ASP.NET Core以前の旧来のASP.NETや、WPF/WinFormsのUIスレッドで.Result.Wait()を呼ぶと、デッドロックが起きることがある。

// UIスレッドや旧ASP.NETコンテキストでは危険
var result = GetDataAsync().Result;   // デッドロックの可能性
var result = GetDataAsync().Wait();   // 同上

仕組みを簡単に言うと、awaitは元のSynchronizationContextに戻ろうとするが、.Resultでそのスレッドがブロックされているため、お互いが待ち合って詰む。

ASP.NET Core(.NET 5以降)はSynchronizationContextがないためこの問題は発生しない。ただし、ライブラリコードとして書く場合や古い環境をサポートする場合は要注意だ。回避策は2つある。

// 方法1: ConfigureAwait(false) でコンテキストへの復帰を抑制
var data = await GetDataAsync().ConfigureAwait(false);

// 方法2: 非同期メソッドチェーンを維持してawaitで待つ(本質的な解決)
public async Task ProcessAsync()
{
    return await GetDataAsync();
}

ライブラリを書く場合はConfigureAwait(false)を付けるのがベストプラクティスとされている。呼び出し元のコンテキストに縛られないため、デッドロックのリスクを下げられる。

罠その3——async voidで例外が消える

async voidはイベントハンドラ以外では使うべきじゃない。メソッドが例外を投げてもキャッチする手段がなく、プロセスがクラッシュするか、例外が無視される。

// ダメな例
async void LoadDataAsync()
{
    var data = await FetchAsync();  // 例外が飛んでも呼び元でキャッチできない
    Process(data);
}

// 正しい例: async Task にする
async Task LoadDataAsync()
{
    var data = await FetchAsync();
    Process(data);
}

// イベントハンドラだけはasync voidが許容される
private async void Button_Click(object sender, EventArgs e)
{
    await LoadDataAsync();  // ここでキャッチできる形にする
}

async voidを書きたくなったら、async Taskに変えて、呼び出し元でawaitするように設計を直すのが先だ。

fire and forget——意図的に「待たない」パターン

ログ送信や通知など、「結果は要らないし失敗してもいい」処理を意図的に待たないことがある。async voidに似ているが、明示的に非同期タスクを手放す書き方を使う。

// awaitしないとコンパイラ警告が出る場合は _ で受ける
_ = SendTelemetryAsync(eventData);

// または Task.Run でスレッドプールに委ねる
_ = Task.Run(() => SendTelemetryAsync(eventData));

fire and forgetは例外が握りつぶされるリスクがある。本番コードで使う場合は、タスク内でtry-catchして例外をログに残す処理を入れておくことが多い。

private static async Task FireAndForgetAsync(Func action)
{
    try
    {
        await action();
    }
    catch (Exception ex)
    {
        // ログに残して握りつぶす
        logger.LogError(ex, "Fire-and-forget task failed");
    }
}

// 使い方
_ = FireAndForgetAsync(() => SendTelemetryAsync(eventData));

CancellationTokenを渡す

非同期メソッドを書くならCancellationTokenを受け取れる設計にしておく。HTTPリクエストのキャンセル・タイムアウト・アプリシャットダウン時のクリーンアップが、これがないと一切効かない。

// キャンセルトークンを受け取るシグネチャ
public async Task GetOrderAsync(Guid id, CancellationToken cancellationToken = default)
{
    return await _repository.FindAsync(id, cancellationToken);
}

// ASP.NET Coreではコントローラから自動で渡される
[HttpGet("{id}")]
public async Task Get(Guid id, CancellationToken cancellationToken)
{
    var order = await _service.GetOrderAsync(id, cancellationToken);
    return Ok(order);
}

引数の末尾にCancellationToken cancellationToken = defaultとデフォルト値付きで追加しておくと、既存の呼び出しコードを壊さずに後から対応できる。

まとめ

  • 依存関係のない複数の非同期処理はTask.WhenAllで並列化する。直列awaitは遅い
  • .Result/.Wait()による同期ブロックはデッドロックの原因になる。ライブラリコードではConfigureAwait(false)を付ける
  • async voidはイベントハンドラ以外では使わない。例外が捕捉できなくなる
  • fire and forgetは_ =で明示し、内部でtry-catchしてログを残す
  • 非同期メソッドにはCancellationTokenを通しておく

C#のプロパティ宣言の最新事情(fieldキーワード・initrequired)についてはこちら。→ C# 2026年のプロパティ宣言まとめ

2026/05/11

C# 2026年のプロパティ宣言——fieldキーワードとget setの書き方まとめ

C# 2026年のプロパティ宣言——fieldキーワードとget/setの書き方まとめ

C#のプロパティ宣言は、バージョンを追うごとにどんどん短く書けるようになっている。古いコードを読んでいると、バッキングフィールドを自分で宣言してgetとsetでそれを返すだけ、という10行近いボイラープレートが普通に出てくる。2026年時点の書き方と比べると、同じことをやっているのに別の言語みたいに見える。

この記事では、C# 14(.NET 10)までの機能を踏まえて、2026年時点でプロパティをどう書くべきかを整理した。特にfieldキーワードは実際に使い始めたら手放せなくなったので、そこを中心に書いている。

古い書き方——バッキングフィールドを自分で管理する

昔からあるパターンはこれだ。バリデーションや副作用が必要なプロパティは、プライベートフィールドを自分で宣言して、getとsetの中で操作する。

public class User
{
    private string _name;

    public string Name
    {
        get => _name;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("名前は空にできません");
            _name = value;
        }
    }
}

動くし読めるけど、_nameという変数がNameプロパティのためだけに存在していて、それが当たり前のように積み重なっていく。クラスが大きくなると、どのフィールドがどのプロパティに対応しているかを目で追う必要が出てくる。

C# 14のfieldキーワード——バッキングフィールドが不要になる

.NET 10(C# 14)で追加されたfieldキーワードを使うと、コンパイラが生成するバッキングフィールドにアクセサの中から直接アクセスできる。上のコードは次のように書き直せる。

public class User
{
    public string Name
    {
        get => field;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("名前は空にできません");
            field = value;
        }
    }
}

_nameの宣言が消えた。fieldはコンパイラが自動生成するバッキングフィールドへの参照で、外から直接触れない。オートプロパティとカスタムアクセサの中間点みたいな位置づけで、「単純に値を返すだけじゃないけど、専用のフィールドを宣言するほどでもない」ケースにちょうど合う。

片方だけカスタムにすることもできる

setだけロジックを入れて、getはそのまま返す、という使い方が実際には多い。

public string Email
{
    get;
    set
    {
        if (!value.Contains('@'))
            throw new FormatException("メールアドレスの形式が不正です");
        field = value.ToLowerInvariant();
    }
}

getはオートプロパティのまま、setだけカスタムにできる。これがfieldキーワードの使い方としていちばん出番が多いんじゃないかと思っている。

initアクセサ——オブジェクト初期化時だけ書き込みを許可する

C# 9で追加されたinitは、コンストラクタやオブジェクト初期化子から設定できるけど、その後は変更できないプロパティを作れる。setの代わりにinitと書くだけだ。

public class Order
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
    public string CustomerId { get; init; }
}

// 初期化子でセット可能
var order = new Order { CustomerId = "C001" };

// これはコンパイルエラー
order.CustomerId = "C002";

レコード型と組み合わせることが多いけど、普通のクラスで「作ったあとは変えない値」を持ちたいときにも使える。

requiredモディファイア——初期化を強制する

C# 11で入ったrequiredは、オブジェクト初期化子でそのプロパティを設定しないとコンパイルエラーにしてくれる。

public class Product
{
    public required string Name { get; init; }
    public required decimal Price { get; init; }
    public string Description { get; init; }  // 任意
}

// Nameを省略するとコンパイルエラー
var p = new Product { Price = 1000 };  // エラー: Nameが設定されていない

// 正しい使い方
var p = new Product { Name = "コーヒー豆", Price = 1000 };

コンストラクタを書かなくてもプロパティの初期化漏れを防げる。DTO・モデルクラスで特に重宝する。

プライマリコンストラクタ——C# 12で普通のクラスにも

C# 12でレコード型だけでなく通常のクラスにもプライマリコンストラクタが使えるようになった。DIでよくある「コンストラクタでサービスを受け取ってフィールドに代入する」パターンが短く書ける。

// 以前
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly ILogger _logger;

    public OrderService(IOrderRepository repository, ILogger logger)
    {
        _repository = repository;
        _logger = logger;
    }
}

// C# 12以降
public class OrderService(IOrderRepository repository, ILogger logger)
{
    public async Task GetAsync(Guid id)
    {
        logger.LogInformation("Getting order {Id}", id);
        return await repository.GetByIdAsync(id);
    }
}

フィールド宣言が消えてコンストラクタも消えた。パラメータはクラス全体のスコープで使える。ただし、プライマリコンストラクタのパラメータはプロパティではないので、外から参照させたい場合は別途プロパティとして定義する必要がある点は注意だ。

2026年時点での使い分け

整理するとこうなる。

パターン使う場面
{ get; set; }単純なプロパティ。バリデーションなし
{ get; init; }初期化後は変更させたくないプロパティ
required { get; init; }初期化必須 + 変更不可。DTOやモデル
fieldキーワードsetにバリデーション・変換が必要。バッキングフィールドは不要
バッキングフィールド手動宣言フィールドを他のメソッドからも参照する場合

fieldキーワードが追加されたことで、手動バッキングフィールドが必要なケースはかなり限られてきた。大半のケースはfieldで書けるし、その方が宣言が少なくてクラスがスッキリする。

まとめ

  • C# 14のfieldキーワードでバッキングフィールド宣言が不要になった。setのバリデーションにfield = valueと書くだけ
  • init(C# 9)で初期化後に変更不可のプロパティを宣言できる
  • required(C# 11)でプロパティの初期化漏れをコンパイル時に検出できる
  • プライマリコンストラクタ(C# 12)でDIのボイラープレートを大幅に削減できる

2026/05/08

C# WPFのデータバインディングで日本語プロパティ名を使ったら何が起きるか

C# WPFのデータバインディングで日本語プロパティ名を使ったら何が起きるか

「C#って日本語の変数名が使えるらしい」という話は聞いたことがあった。でも実際にWPFのデータバインディングで試したことはなかった。{Binding 名前}みたいに書いて本当に動くのか。動いたとして、実用に耐えるのか。半分好奇心、半分「どうせ落とし穴があるだろう」という気持ちで検証してみた。

そもそもC#は日本語識別子を正式にサポートしている

C#の言語仕様(ECMA-334)では、識別子にUnicode文字を使えると明記されている。つまり変数名・プロパティ名・クラス名に漢字・ひらがな・カタカナをそのまま使えるのは仕様として保証された挙動で、バグでも裏技でもない。

// これは正式に有効なC#コード
public class 利用者
{
    public string 名前 { get; set; }
    public int 年齢 { get; set; }
    public string メールアドレス { get; set; }
}

Visual Studio 2022でも普通にIntelliSenseが効く。補完候補に「名前」「年齢」が出てくる様子はなかなかシュールだけど、ビルドは通る。

WPFのXAMLバインディングでも使えるか

ViewModelに日本語プロパティを持たせてXAMLからバインドしてみた。

// ViewModel
public class 利用者ViewModel : INotifyPropertyChanged
{
    private string _名前;
    public string 名前
    {
        get => _名前;
        set
        {
            _名前 = value;
            OnPropertyChanged(nameof(名前));
        }
    }

    private int _年齢;
    public int 年齢
    {
        get => _年齢;
        set
        {
            _年齢 = value;
            OnPropertyChanged(nameof(年齢));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string name)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
<!-- XAML -->
<StackPanel>
    <TextBox Text="{Binding 名前, UpdateSourceTrigger=PropertyChanged}" />
    <TextBox Text="{Binding 年齢, UpdateSourceTrigger=PropertyChanged}" />
    <TextBlock Text="{Binding 名前}" />
</StackPanel>

結果は——動く。{Binding 名前}でちゃんとViewModelの名前プロパティにバインドされて、TextBoxに入力した値がリアルタイムで反映される。WPFのバインディングは内部でリフレクションを使ってプロパティ名を文字列で解決しているので、Unicode名でも問題ない。

nameof()も日本語プロパティで機能する

INotifyPropertyChangedの実装でnameof(名前)と書けるのが地味に重要で、プロパティ名のタイポをコンパイル時に検出できる。ハードコードで"名前"と文字列を渡す実装より安全だ。

// nameof()は日本語プロパティでも正しく機能する
OnPropertyChanged(nameof(名前)); // → "名前" という文字列になる

実際に困った点

IME切り替えのストレス

コードを書くとき、英字と日本語の間で頻繁にIMEを切り替えることになる。public string まで英字で書いて、プロパティ名を入力するために日本語入力に切り替えて、また { get; set; }に戻す、という繰り返しが思ったより煩わしい。慣れの問題かもしれないけど、英語コードの中に日本語が挟まるリズムが崩れる感覚は否めない。

バックエンドとの連携がつらい

APIのレスポンスをViewModelにマッピングするとき、JSONのキーは英語("name""age")でプロパティは日本語、という状況になる。System.Text.JsonやNewtonsoft.Jsonで属性を付ければ対応できるけど、一段階手間が増える。

using System.Text.Json.Serialization;

public class 利用者
{
    [JsonPropertyName("name")]
    public string 名前 { get; set; }

    [JsonPropertyName("age")]
    public int 年齢 { get; set; }
}

StackOverflowやGitHub Copilotとの相性

英語のプロパティ名なら、同じ構造のコードがStackOverflowにいくらでもある。日本語プロパティ名のコードは検索してもほぼ出てこないし、Copilotの補完も精度が落ちる。サンプルコードをそのまま流用しにくい。

使ってみた正直な感想

「動く」と「実用的」は別の話だと改めて思った。一人で書く小さなツールで、日本語のビジネスドメインを扱う場合は選択肢としてアリかもしれない。「顧客番号」「請求金額」「納期」みたいな用語が英訳すると意味が曖昧になるドメインでは、日本語のまま書いた方がコードと仕様書の対応がはっきりする、という考え方はわかる。

ただチームで使う場合や、外部のAPIやライブラリとの連携が多い場合は苦労の方が大きいと思う。英語識別子のコードベースに日本語が混ざると、どちらのルールで書けばいいか迷う人が出てくる。「愚行」と言い切るほどではないけど、採用するなら明確な理由と運用ルールが必要だ。

まとめ

  • C#は仕様としてUnicode識別子をサポートしており、日本語プロパティ名は正式に有効
  • WPFのデータバインディング({Binding 名前}形式)も日本語プロパティで正常に動作する
  • nameof()INotifyPropertyChanged・XAMLバインディングすべて問題なく機能した
  • 実用上の課題はIME切り替え、JSON属性の追加、英語サンプルコードとの乖離
  • ドメイン用語が日本語で完結する一人プロジェクトなら選択肢になりうる。チーム開発では要検討

GolangよりC#記事が伸びた話——技術ブログのアクセス分析

GolangよりC#記事が伸びた——技術ブログのアクセスを分析してみたら意外な結果が出た

技術ブログを書き始めたとき、Golang関連の記事が一番読まれると思っていた。モダンで注目度が高くて、自分もよく書く言語だったから、ネタとしても自然だと思っていたんだけど、実際にSearch Consoleを見てみたら全然そうじゃなかった。C#の記事の方がずっとアクセスを集めていた。しかもGolang記事との差が思ったより大きかった。

なぜかが気になって調べ始めたのがこの記事のきっかけだ。

Golang記事は伸びなかった——数字で見るとはっきりわかる

Golang関連の記事をいくつか書いた後、Google Search Consoleで表示回数とクリック数を確認した。フォルダ構成の話やGin + GORMを使ったAPIの構成など、自分が実際に使った内容で書いたつもりだったけど、表示回数がそもそも少ない。検索結果に出ていないというより、そもそも検索しているユーザーの絶対数が少ないんだと思う。

一方でC#の記事は、書いてからそれほど時間が経っていないのにSearch Consoleの表示回数がGolangの記事より多かった。クリック率はどちらも似たようなものだったので、単純に検索ボリュームの差が出ている形だ。

なぜC#の方が検索ボリュームが大きいのか

日本市場を考えると、C#の根強さは納得がいく。以前、受託の案件で.NETのシステムを触ったことがあるんだけど、そのあたりの現場ではC#がごく当たり前に使われていた。日本のSIerはJavaが主流とはいえ、.NET/C#も業務系では広く生き残っていて、「C# ○○のやり方」という実務検索がコンスタントに発生し続けている。

Unityも大きい。ゲーム開発でC#を使う人が多くて、「Unity C# スクリプト」系の検索はボリュームがかなりある。ゲーム開発系の読者は技術ブログをよく読む層で、検索行動も活発だ。

Golangはというと、採用している企業はメルカリやCyberAgentなどのWeb系大手が多く、その層はQiitaや英語圏の記事を読む傾向がある。日本語でGolangを検索するユーザーは相対的に少ないかもしれない。公式ドキュメントやGitHub上の情報が英語で十分そろっているので、日本語記事を探すモチベーションが下がるのかもしれない。

Google Trendsで比べると差は明確

Google Trendsで「C#」と「Go 言語」または「Golang」を日本向けで比較すると、C#の方が継続的に検索されているのがわかる。Golangの検索量が急増したタイミングはあるものの、継続的なベースラインで見るとC#の方が安定して多い。

これはGolangが悪い技術だということじゃなくて、単純にユーザー数と歴史の差だと思っている。C#は2002年のリリース以来20年以上の蓄積がある。検索キーワードの種類も多いし、「困った人が検索する」シナリオがそれだけ多い。Golangは普及し始めたのも最近で、コミュニティの規模感がまだ全然違う。

技術ブログで「伸びるネタ」を選ぶ考え方

この経験から、記事ネタを選ぶときの判断軸を少し変えた。「自分が書きたいこと」だけで選ぶんじゃなくて、「その言語・技術を日本語で検索しているユーザーがどれくらいいるか」を先に確認するようになった。

確認する方法は簡単で、Google Trendsで気になる技術名を比較する、あるいはGoogle検索でキーワードを打ってみてサジェストにどんなキーワードが出てくるかを見る。サジェストが豊富なキーワードは検索ボリュームが大きいことが多い。

ただ、「検索ボリュームがあるテーマだけ書く」というのも違う気がしている。自分が実際に使っていない技術を書いても内容が薄くなるし、読んでいてすぐわかる。ボリュームがある技術を扱いつつ、自分の実務経験を乗せることで情報に厚みを出す、というのが今の方針だ。

C#でも書けるネタを探す

自分はGolangをメインに使っているけど、C#を完全に触ったことがないわけじゃない。過去に.NETの案件を触ったことがあるし、Unityも少しさわっている。そういう経験を引っ張り出して、C#記事のネタにしてみたら思ったより素直に書けた。「Go使いが初めてC#を触ったときの話」という切り口で書いたら、Goユーザー向けの比較記事になって意外とハマった。

「自分のメイン技術じゃないから書けない」という思い込みがあったけど、ちょっとさわった経験でも「初心者目線で書く」という切り口なら価値が出る。C#をゴリゴリ使っているベテランが書く記事と、「Golangメインで初めてC#を触ったエンジニアが書く記事」は読者が違う。

Golangを諦めるわけじゃない

C#が伸びたからといって、Golang記事をやめるつもりはない。Golangは自分が一番詳しい技術で、書ける内容の深さが違う。アクセスの絶対数は少なくても、ニッチな検索キーワードで上位を取れる可能性はある。ロングテールで拾う戦略だ。

それにGolang記事はこれから増える可能性も十分ある。国内でのGoの採用は増えていて、日本語で検索する人の数は少しずつ増えているはずだ。今のうちに記事を積み上げておくことで、ボリュームが増えたときに上位表示できる状態を作っておく、という考え方もできる。

結局のところ、自分の経験でいちばん腹落ちしたのは「書いてみないとわからない」という当たり前のことだった。想定と結果が違うことはよくあって、そのギャップから次の方針が見えてくる。Search Consoleは定期的に見る価値があると改めて思っている。

まとめ

  • 日本語での検索ボリュームはC#がGolangを上回る。記事を書く前にGoogle TrendsとGoogleサジェストで確認する価値がある
  • C#はSIer業務システム・Unityの層が厚く、「困って検索する人」が継続的に発生している
  • メイン技術でなくても、実体験があれば「初心者目線の記事」として書ける。ベテランとは読者層が違う
  • Golang記事はロングテール狙いで積み上げ続ける。検索ボリュームは少しずつ増えているはずだ

2026/05/07

GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM)

GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM)

Ginで新しいAPIを作り始めるとき、最初に迷うのがフォルダ構成だった。とりあえず動かすだけならmain.goに全部書いてしまえばいいんだけど、それをやると後で必ず後悔する。外部APIとの連携が増えてきたり、ハンドラが10本を超えたあたりで一気に読めなくなる。

今はClean Architectureの考え方をベースにpkg/以下をレイヤーごとに切る構成に落ち着いている。handler → usecase → repository/gatewayという一方向の依存で、内側の層が外側を知らない設計だ。DB操作にはGORM、外部サービスへの接続はgatewayパターンで抽象化している。これはPorts and Adaptersとも呼ばれる考え方で、ローカル環境ではgatewaymockに差し替えることで外部サービスなしに動かせる。

全体のディレクトリ構成

まず全体像から。

myapp/
├── main.go
├── Makefile
├── docker/
│   ├── docker-compose.yml
│   └── docker-compose.test.yml
├── .env.local
├── .env.staging
├── .env.production
├── pkg/
│   ├── handler/
│   │   ├── user_handler.go
│   │   └── item_handler.go
│   ├── gateway/
│   │   ├── db.go
│   │   ├── secrets.go
│   │   ├── payment_gateway.go
│   │   ├── payment_gateway_mock.go
│   │   ├── payment_gateway_test.go
│   │   ├── notification_gateway.go
│   │   ├── notification_gateway_mock.go
│   │   └── notification_gateway_test.go
│   ├── usecase/
│   │   └── user_usecase.go
│   ├── repository/
│   │   └── user_repository.go
│   ├── domain/
│   │   └── user.go
│   └── config/
│       └── config.go
├── migrations/
│   ├── 000001_create_users.up.sql
│   ├── 000001_create_users.down.sql
│   └── 000002_create_items.up.sql
│   └── 000002_create_items.down.sql
├── go.mod
└── go.sum

ルートのmain.goがエントリポイントで、ここでDIをまとめてGinを起動する。pkg/以下に全てのロジックが収まる構成で、DBも「外部リソースへの接続」としてgateway/に置く。Stripeや通知サービスへの接続と同じ扱いで、gateway/db.goがGORMの接続を返す。

pkg/handler — ルーティングとリクエスト処理

handler/はリクエスト・レスポンスの変換を担う。ルーティングはmain.goに書くので、ハンドラは処理だけに集中できる。ビジネスロジックはusecaseに渡すだけにする。

// pkg/handler/user_handler.go
package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "myapp/pkg/usecase"
)

type UserHandler struct {
    userUsecase usecase.UserUsecase
}

func NewUserHandler(uu usecase.UserUsecase) *UserHandler {
    return &UserHandler{userUsecase: uu}
}

func (h *UserHandler) GetUser(c *gin.Context) {
    id := c.Param("id")
    user, err := h.userUsecase.GetByID(c.Request.Context(), id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, user)
}

ハンドラごとにファイルを分けているので、user_handler.goが肥大化してきたらitem_handler.goのように追加するだけで済む。ルーティングはmain.goに集めているので、エンドポイントの一覧をそこで把握できる。

pkg/gateway — 外部サービスとの連携

外部APIや外部サービスとのやり取りはgateway/に閉じ込める。決済サービス、通知サービス、外部のマスターデータAPIなど、自分たちが管理していないシステムとの境界がここだ。

gatewayの中身はインターフェースと実装に分けておくと、テスト時にモックに差し替えやすい。

// pkg/gateway/payment_gateway.go
package gateway

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type PaymentGateway interface {
    Charge(ctx context.Context, amount int, token string) error
}

type stripeGateway struct {
    apiKey  string
    baseURL string
    client  *http.Client
}

func NewStripeGateway(apiKey string) PaymentGateway {
    return &stripeGateway{
        apiKey:  apiKey,
        baseURL: "https://api.stripe.com/v1",
        client:  &http.Client{},
    }
}

func (g *stripeGateway) Charge(ctx context.Context, amount int, token string) error {
    // Stripe APIへのリクエスト処理
    _ = json.NewEncoder
    _ = fmt.Sprintf
    return nil
}

ここがgatewayと呼ぶ理由は、外の世界への「玄関口」として責務を明確にするためだ。usecaseはgatewayのインターフェースだけを知っていればよくて、裏でStripeを使おうがPayPayを使おうがusecaseは気にしない。外部サービスの乗り換えや追加がしやすい構造になる。

ローカル用のmock実装(_mockファイル)

ローカル環境では外部サービスを呼ばず、同じインターフェースを実装したmockが固定値を返す。ファイル名はpayment_gateway_mock.goのように_mockサフィックスをつけて本実装と並べておく。

// pkg/gateway/payment_gateway_mock.go
package gateway

import (
    "context"
    "log"
)

type mockPaymentGateway struct{}

func NewMockPaymentGateway() PaymentGateway {
    return &mockPaymentGateway{}
}

func (m *mockPaymentGateway) Charge(ctx context.Context, amount int, token string) error {
    log.Printf("[mock] PaymentGateway.Charge: amount=%d token=%s → success", amount, token)
    return nil
}

本実装と同じパッケージに置くのでインターフェースをそのまま参照できる。main.goでenv値を見て差し替えるだけで、usecaseもhandlerも変更不要になる。

単体テストはGoの標準規則に従って_test.goサフィックスをつける。payment_gateway_test.goのように本実装ファイルと対応した名前にしておくと、どのファイルのテストか迷わない。テスト内でmockを使いたい場合は同パッケージのpayment_gateway_mock.goをそのまま参照できる。

// pkg/gateway/payment_gateway_test.go
package gateway_test

import (
    "context"
    "testing"

    "myapp/pkg/gateway"
)

func TestMockPaymentGateway_Charge(t *testing.T) {
    gw := gateway.NewMockPaymentGateway()
    if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}
// main.go — gatewayの差し替え箇所
var paymentGw gateway.PaymentGateway
if cfg.Env == "local" {
    paymentGw = gateway.NewMockPaymentGateway()
} else {
    paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
}

pkg/domain — GORMモデルの定義

domain/にはGORMのモデル定義を置く。gorm.Modelを埋め込むとIDCreatedAtUpdatedAtDeletedAt(ソフトデリート用)が自動で付いてくるので、基本的にはこれを使っている。

// pkg/domain/user.go
package domain

import (
    "context"

    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Name  string `gorm:"not null"`
    Email string `gorm:"uniqueIndex;not null"`
}

type UserRepository interface {
    FindByID(ctx context.Context, id uint) (*User, error)
    Create(ctx context.Context, user *User) error
}

リポジトリのインターフェースもここに定義しておく。これによってusecase/domain.UserRepositoryだけを知っていればよく、GORMの実装の詳細と切り離せる。

pkg/repository — GORMによるDB操作の実装

repository/がGORMを直接使う唯一の層で、ここ以外でGORMのコードは書かない。*gorm.DBを受け取って各メソッドを実装する。

// pkg/repository/user_repository.go
package repository

import (
    "context"
    "errors"

    "gorm.io/gorm"
    "myapp/pkg/domain"
)

type userRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) domain.UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) FindByID(ctx context.Context, id uint) (*domain.User, error) {
    var user domain.User
    result := r.db.WithContext(ctx).First(&user, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return &user, result.Error
}

func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
    return r.db.WithContext(ctx).Create(user).Error
}

db.WithContext(ctx)でコンテキストを必ず渡す。タイムアウトやキャンセルをDB層まで伝播させるためで、省略するとリクエストがキャンセルされてもクエリが走り続ける。gorm.ErrRecordNotFoundはレコードが見つからない場合のエラーなので、errors.Isで判定してnilを返すか上位層に任せるかを決める。

ローカル環境のDB — docker-compose で用意する

ローカルではDBをDockerで立てる。docker/フォルダにdocker-compose.ymlをまとめて、ルートから-fで指定して起動する。

docker compose -f docker/docker-compose.yml up -d
# docker/docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"

.env.localDATABASE_DSNはこのコンテナに向ける。

# .env.local の DATABASE_DSN
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable

pkg/gateway/db.go — GORM接続の初期化

DBは「外部リソースへの接続」なので、Stripeや通知サービスと同じくgateway/に収める。db.goがGORMの接続を生成して返すだけのシンプルな役割を持つ。

// pkg/gateway/db.go
package gateway

import (
    "fmt"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func NewDB(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        return nil, fmt.Errorf("db connection failed: %w", err)
    }
    return db, nil
}

DB接続の生成だけに絞る。テーブル作成はgolang-migrateで管理するのでAutoMigrateは使わない。

migrations — golang-migrateでテーブル管理

テーブルのDDLはバージョン管理されたSQLファイルで管理する。golang-migrateはup/downのペアで変更を管理でき、適用済みのバージョンをDBに記録してくれる。CLIのインストール方法はREADMEに記載する。

ファイル名は連番_説明.up.sql/連番_説明.down.sqlの形式でmigrations/に置く。

-- migrations/000001_create_users.up.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    email      TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);
-- migrations/000001_create_users.down.sql
DROP TABLE IF EXISTS users;

マイグレーションはアプリ起動時に自動実行しない。Makefileのターゲットで明示的に実行する。migrate-up-testはテスト用コンテナ(port 5433)向けのターゲットで、テスト実行前に1回だけ手動で叩く。

# Makefile
DSN      ?= $(DATABASE_DSN)
TEST_DSN ?= postgres://user:password@localhost:5433/testdb?sslmode=disable

migrate-up:
	migrate -path migrations -database "$(DSN)" up

migrate-up-test:
	migrate -path migrations -database "$(TEST_DSN)" up

migrate-down:
	migrate -path migrations -database "$(DSN)" down 1

migrate-version:
	migrate -path migrations -database "$(DSN)" version

migrate-create:
	migrate create -ext sql -dir migrations -seq $(name)

テーブル定義を本番・開発・テストで一致させるために、どの環境でも同じSQLファイルをgolang-migrateで適用する。AutoMigrateはGORMのstructから推測してDDLを生成するので、意図しないカラム変更が起きる可能性がある。SQLを自分で書いて管理する方が変更の意図が明確になる。

pkg/config — 環境別の設定読み込み

機密情報(DB接続文字列・APIキーなど)はAWS Secrets Managerに登録して取得する。ポート番号やGinのモードなどの非機密値は環境ごとに1ファイルで管理する。各ファイルはその環境の非機密値を全て持ち、gitにコミットして問題ない内容だけ書く。

# .env.local  ※gitにコミットしない(機密値を含む)
PORT=:8080
GIN_MODE=debug
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable
STRIPE_KEY=sk_test_xxxxxxxxxxxx
# .env.staging  ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=debug
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/staging/secrets
# .env.production  ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=release
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/production/secrets

AWS Secrets Managerへの接続は他の外部連携と同じくgateway/に置く。シークレットはJSON形式で登録しておき、取得後にパースして使う。

go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/secretsmanager
// pkg/gateway/secrets.go
package gateway

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/aws"
    awsconfig "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

type Secrets struct {
    DatabaseDSN string `json:"DATABASE_DSN"`
    StripeKey   string `json:"STRIPE_KEY"`
}

func LoadSecrets(ctx context.Context, region, secretName string) (*Secrets, error) {
    cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
    if err != nil {
        return nil, fmt.Errorf("failed to load AWS config: %w", err)
    }

    client := secretsmanager.NewFromConfig(cfg)
    result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId: aws.String(secretName),
    })
    if err != nil {
        return nil, fmt.Errorf("failed to get secret: %w", err)
    }

    var secrets Secrets
    if err := json.Unmarshal([]byte(*result.SecretString), &secrets); err != nil {
        return nil, fmt.Errorf("failed to parse secret: %w", err)
    }

    return &secrets, nil
}

configはこのgatewayを呼び出してシークレットを取得し、Configに詰めて返す。Loadはcontextを受け取る形にして、タイムアウトを外から制御できるようにしておく。

// pkg/config/config.go
package config

import (
    "context"
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "myapp/pkg/gateway"
)

type Config struct {
    Env       string
    DSN       string
    StripeKey string
    Port      string
}

func Load(ctx context.Context) (*Config, error) {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "local"
    }

    envFile := fmt.Sprintf(".env.%s", env)
    _ = godotenv.Load(envFile)

    // local は .env.local から直接読む(Docker上のDBを使うため AWS SM 不要)
    if env == "local" {
        return &Config{
            Env:       env,
            DSN:       os.Getenv("DATABASE_DSN"),
            StripeKey: os.Getenv("STRIPE_KEY"),
            Port:      getEnvOrDefault("PORT", ":8080"),
        }, nil
    }

    // staging / production は AWS Secrets Manager から機密値を取得
    secrets, err := gateway.LoadSecrets(
        ctx,
        os.Getenv("AWS_REGION"),
        os.Getenv("SECRET_NAME"),
    )
    if err != nil {
        return nil, fmt.Errorf("failed to load secrets: %w", err)
    }

    return &Config{
        Env:       env,
        DSN:       secrets.DatabaseDSN,
        StripeKey: secrets.StripeKey,
        Port:      getEnvOrDefault("PORT", ":8080"),
    }, nil
}

func getEnvOrDefault(key, defaultVal string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return defaultVal
}

localのときはAWS SMを呼ばずに.env.localから直接読む。DBなどのミドルウェアはDockerで用意するので、DATABASE_DSNにはDockerコンテナのホストを書けばいい。.env.localは機密値を含むので.gitignoreに追加してリポジトリに含めない。staging/productionは非機密値だけを各envファイルに書いてgitで管理し、機密値はAWS SMから取得する。シークレット名を環境ごとに分けておくと(myapp/staging/secretsmyapp/production/secrets)、環境を間違えてもシークレットが混ざらない。

pkg/usecase — ビジネスロジック

usecase/はビジネスロジックの層で、handlerからの入力を受けてrepositoryやgatewayを組み合わせて処理を行う。「ユーザーを作成する」という操作がバリデーション→DB保存→通知送信という手順なら、その流れをusecaseに書く。GORMの*gorm.DBはここには渡さない。repositoryのインターフェースだけに依存させる。

main.go でDIをまとめる

各レイヤーの依存関係はmain.goで一か所に集めて組み立てる。DIコンテナは使わず、素直にコンストラクタを呼んでいく方が小〜中規模のAPIでは読みやすいと感じている。

// main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
    "myapp/pkg/config"
    "myapp/pkg/gateway"
    "myapp/pkg/handler"
    "myapp/pkg/repository"
    "myapp/pkg/usecase"
)

func main() {
    ctx := context.Background()

    cfg, err := config.Load(ctx)
    if err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    gormDB, err := gateway.NewDB(cfg.DSN)
    if err != nil {
        log.Fatalf("failed to connect db: %v", err)
    }

    sqlDB, err := gormDB.DB()
    if err != nil {
        log.Fatalf("failed to get sql.DB: %v", err)
    }
    defer sqlDB.Close()

    userRepo := repository.NewUserRepository(gormDB)

    var paymentGw gateway.PaymentGateway
    if cfg.Env == "local" {
        paymentGw = gateway.NewMockPaymentGateway()
    } else {
        paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
    }

    userUsecase := usecase.NewUserUsecase(userRepo, paymentGw)
    userHandler := handler.NewUserHandler(userUsecase)

    r := gin.Default()
    v1 := r.Group("/api/v1")
    {
        v1.GET("/users/:id", userHandler.GetUser)
        v1.POST("/users", userHandler.CreateUser)
    }

    srv := &http.Server{
        Addr:    cfg.Port,
        Handler: r,
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("shutting down server...")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("server forced to shutdown: %v", err)
    }
}

ルーティングをmain.goに書くことで、エンドポイントの一覧がここに集まる。依存の向きはhandler → usecase → repository/gatewayという一方向を保っている。

r.Run()ではなくhttp.Serverを使ってgoroutineで起動しているのは、graceful shutdownのためだ。SIGINT/SIGTERMを受け取ったらsrv.Shutdown()を呼ぶことで、処理中のリクエストが終わるのを待ってからサーバーを止める。タイムアウトは5秒にしているが、DBトランザクションが長い処理がある場合は調整が必要だ。sqlDB.Close()deferに積んでいるので、Shutdown()でリクエストが全て終わった後にDB接続が閉じられる。

テスト戦略 — DBは本物、外部APIはmock

層によってテストの方針を分けている。

repository層はテスト用の共通コンテナを使う。 DBをmockにすると「コードは通るけどSQLが壊れてる」という状況が発生する。カラム名のミス、トランザクションのバグ、マイグレーションとのズレはmockでは検出できない。テストごとにコンテナを起動するとGoの並列テストで大量のコンテナが立ち上がって負荷になるので、テスト専用コンテナを1つ起動して使い回す方が現実的だ。

# docker/docker-compose.test.yml
services:
  db-test:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: testdb
    ports:
      - "5433:5432"  # 開発用5432と競合しないようにずらす
# テスト前に一度だけ起動
docker compose -f docker/docker-compose.test.yml up -d

テストコードは TestMain でDB接続を1回だけ確立し、パッケージ内の全テストで使い回す。並列テスト間のデータ干渉はトランザクションのロールバックで防ぐ。

テストコンテナへのマイグレーションはアプリ起動時と同じ方針でAutoMigrateは使わない。テスト実行前にmake migrate-up-testを手動で実行してスキーマを整えておく。テーブル定義が本番と一致した状態でテストできるのが利点だ。

// pkg/repository/testmain_test.go
package repository_test

import (
    "fmt"
    "os"
    "testing"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var testDB *gorm.DB

func TestMain(m *testing.M) {
    dsn := "postgres://user:password@localhost:5433/testdb?sslmode=disable"
    var err error
    testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("failed to connect test db:", err)
        os.Exit(1)
    }
    os.Exit(m.Run())
}
// pkg/repository/user_repository_test.go
package repository_test

import (
    "context"
    "testing"
)

func TestUserRepository_Create(t *testing.T) {
    t.Parallel()

    tx := testDB.Begin()
    defer tx.Rollback() // テスト終了後にロールバックしてデータを残さない

    repo := NewUserRepository(tx)
    err := repo.Create(context.Background(), &domain.User{Name: "test", Email: "test@example.com"})
    if err != nil {
        t.Fatal(err)
    }
}

gateway層(外部API)は_mock.goで固定値を返す。 単体テストで本物のStripeやSlackを叩くと、テストの実行頻度によってはレートリミットに引っかかったりAPIキーを消費したりする。最悪の場合アカウントがブロックされる。外部サービスはこちらがコントロールできないので、テスト時はmockを使って正常系・エラー系のレスポンスを固定値で再現する。

// pkg/gateway/payment_gateway_test.go
package gateway_test

import (
    "context"
    "testing"

    "myapp/pkg/gateway"
)

func TestMockPaymentGateway_Charge(t *testing.T) {
    t.Parallel()
    gw := gateway.NewMockPaymentGateway()
    if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

この方針を一言で言うと「自分たちが管理しているものは本物でテストし、管理できないものはmockで代替する」になる。

まとめ

  • ローカルDBはdocker/docker-compose.ymlで管理。docker compose -f docker/docker-compose.yml up -dで起動
  • local.env.localから直接読み込み、staging/productionはAWS SMから機密値を取得
  • ルーティングはmain.goに集約。エンドポイント一覧をひとつの場所で把握できる
  • pkg/handler/はリクエスト/レスポンス変換のみ、ビジネスロジックは持たない
  • pkg/domain/にGORMモデルとリポジトリインターフェースを定義、実装はrepositoryに分離する
  • pkg/repository/がGORMを直接使う唯一の層。db.WithContext(ctx)は必ず通す
  • pkg/gateway/は外部サービスとの境界。インターフェースで抽象化するとテストが楽になる
  • repositoryのテストはdocker-compose.test.ymlの共通コンテナを使う。並列テストの負荷を避けつつ本物のDBで検証
  • gateway(外部API)のテストは_mock.goで固定値を返す。レートリミット・APIブロック対策
  • pkg/gateway/db.goでGORM接続のみ管理。AutoMigrateは使わず、DDLはgolang-migrateのSQLファイルで管理する
  • テスト実行前にmake migrate-up-testでテストDBにスキーマを適用する。TestMain内でのマイグレーション実行はしない
  • ルートのmain.goで依存を組み立てるシンプルなDIにする
  • 依存の向きはhandler → usecase → repository/gatewayの一方向を保つ(Clean Architecture / Ports and Adapters)

2026/05/01

C# LINQを日本語変数で書いたらクエリが仕様書みたいになった話

C# の LINQ を日本語変数で書いたら、クエリが仕様書みたいになった話

業務計算ツールで日本語変数名を使い始めてから、LINQ のラムダ式にも日本語を使うようになった。最初は「どうせ慣れてる英語のほうが書きやすい」と思っていたんだけど、やってみたら予想外に読みやすかった。仕様書に書いてあった条件式がそのままコードになる感覚があって、これはなかなかいい発見だった。

英語変数名の LINQ と比べてみる

まず典型的な業務クエリを英語で書いた場合。

// 英語変数名
var result = employees
    .Where(e => e.Department == "営業" && e.IsActive)
    .OrderBy(e => e.HireDate)
    .Select(e => new { e.Name, e.HireDate });

e が何を指しているか、最初の一行を読んで確認しないとわからない。複数の LINQ チェーンが絡むと、どの e がどのコレクションの要素なのかを都度追いかける必要がある。

同じクエリを日本語で書くとこうなる。

// 日本語変数名
var 結果 = 社員一覧
    .Where(社員 => 社員.部署 == "営業" && 社員.在籍中)
    .OrderBy(社員 => 社員.入社日)
    .Select(社員 => new { 社員.氏名, 社員.入社日 });

ラムダ変数が 社員 になっただけで、文章として読める密度が上がる。「社員一覧から、営業部署に在籍中の社員を、入社日順に並べて、氏名と入社日を取り出す」というビジネスルールが、そのままコードになっている感じだ。

クエリ構文(query syntax)だとさらに読みやすい

LINQ には whereselectorderby を使う SQL ライクなクエリ構文もある。日本語変数名と組み合わせると、仕様書に近い表現になる。

var 結果 = from 社員 in 社員一覧
           where 社員.部署 == "営業" && 社員.在籍中
           orderby 社員.入社日
           select new { 社員.氏名, 社員.入社日 };

「社員 in 社員一覧」「社員.部署 == 営業」という部分は、ほぼ日本語の箇条書きと同じ構造になっている。仕様書に「社員一覧の中で営業部に在籍中のものを…」と書いてあれば、それを見ながらそのまま書き写せる感覚があった。

クエリ構文とメソッド構文、どちらで使うか

クエリ構文は可読性が高いけど、AggregateSkipTakeDistinct のような操作はクエリ構文に対応するキーワードがなく、メソッド構文でしか書けない。実際の業務コードでは「シンプルなフィルタリング・ソートはクエリ構文、ページングや重複除去はメソッド構文」という使い分けが多くなった。なお GroupJoin はクエリ構文の join ... into として書けるので、グループ結合もクエリ構文で表現できる。

集計・グループ化でも効果が出る

単純なフィルタリングだけじゃなく、集計処理でも日本語ラムダ変数は読みやすさを上げてくれる。

// 部署ごとの残業時間合計
var 部署別残業集計 = 勤怠一覧
    .GroupBy(勤怠 => 勤怠.部署)
    .Select(グループ => new
    {
        部署名 = グループ.Key,
        残業時間合計 = グループ.Sum(勤怠 => 勤怠.残業時間),
        対象人数 = グループ.Count()
    })
    .OrderByDescending(集計 => 集計.残業時間合計);

グループ集計勤怠 と変数名が変わるたびに「何を操作しているか」が明示される。英語だと gx のような1文字変数になりがちで、複数のラムダが重なると何が何だかわからなくなる。

Join でも意味が通りやすくなる

複数テーブルの結合になると、英語変数名では特に読みにくくなる。

// 英語
var result = orders
    .Join(customers,
          o => o.CustomerId,
          c => c.Id,
          (o, c) => new { o.OrderDate, c.Name, o.Amount });

// 日本語
var 注文明細 = 注文一覧
    .Join(顧客一覧,
          注文 => 注文.顧客ID,
          顧客 => 顧客.ID,
          (注文, 顧客) => new { 注文.注文日, 顧客.氏名, 注文.金額 });

oc が何を表すか覚えておかなくていい。注文顧客 という名前が都度文脈を説明してくれる。

気になった点と対処法

IME の干渉

LINQ の式を書いているとき、=> の直後に日本語入力が始まってしまうことがある。Visual Studio の IME 連携で、ラムダの矢印を打った後に日本語入力に切り替わってしまうケースだ。自分の場合は「=> を打ったら手動で IME をオンにする」という習慣にしたら気にならなくなった。

チェーンが長くなると行が長くなる

英語の1文字変数(ex)は短いので、横に長い式でも収まりやすい。日本語変数名は文字幅があるため、チェーンが長くなると1行が長くなりがちだ。.Select() の前で改行を入れるなど、縦に展開する書き方を意識するようになった。

// 改行を入れて整理する
var 対象社員 = 社員一覧
    .Where(社員 => 社員.在籍中)
    .Where(社員 => 社員.入社日 < 基準日)
    .Select(社員 => 社員.氏名)
    .ToList();

汎用メソッドへの切り出し時は英語に戻す

ドメイン固有の LINQ クエリは日本語変数で書くけど、複数の場所から呼ばれる汎用的なフィルタ関数を切り出すときは英語に戻している。日本語変数名は「この文脈でこの操作をする」が明確な場所にほど効果が出やすい。

まとめ

  • LINQ のラムダ変数を日本語にすると「何のコレクションの要素を操作しているか」がコードから直接読める
  • クエリ構文(from 社員 in 社員一覧 where ...)は仕様書の条件式に近い構造になり、照合コストが下がる
  • Join・GroupBy など複数コレクションが絡む処理で特に可読性向上の恩恵が出やすい
  • IME の干渉と行長には慣れが必要。縦展開する書き方を意識するといい

日本語変数名を業務計算ロジックに使い始めた経緯についてはこちらの記事、コメントと変数名の非対称についての考察はこちらの記事もあわせてどうぞ。