【C#】LINQ GroupBy・OrderBy・Join完全ガイド|ThenBy・複合キー・GroupJoin・外部結合・ToLookup・実践例まで

LINQ の GroupByOrderByJoin は、データ処理の要です。基本的な使い方はすぐ覚えられますが、多段ソート複合キーのグループ化LEFT OUTER JOINToLookup の使いどころなど、実務で必要になる応用知識は案外まとまった情報が少ないです。

本記事では各演算子を仕様レベルで掘り下げ、実践的なサンプルとともに解説します。基本的な Where/Select/DistinctLINQ完全ガイドを、GroupBy 後の Sum/Average などの集約処理はLINQでグルーピング・集計を行う方法を参照してください。

スポンサーリンク

本記事で使うサンプルデータ

サンプルデータ定義
// 本記事で使うサンプルデータ
public record Employee(int Id, string Name, string Department, int Age, decimal Salary);
public record Department(int Id, string Name, string Location);
public record SalesRecord(string Product, string Region, int Month, decimal Amount);

var employees = new List<Employee>
{
    new(1, "Alice",   "開発",   28, 550_000),
    new(2, "Bob",     "開発",   35, 680_000),
    new(3, "Carol",   "営業",   24, 420_000),
    new(4, "Dave",    "営業",   31, 510_000),
    new(5, "Eve",     "人事",   29, 460_000),
    new(6, "Frank",   "開発",   26, 530_000),
    new(7, "Grace",   "営業",   38, 590_000),
    new(8, "Hiro",    "人事",   33, 490_000),
};

var departments = new List<Department>
{
    new(1, "開発",     "東京"),
    new(2, "営業",     "大阪"),
    new(3, "人事",     "東京"),
    new(4, "マーケ",   "名古屋"),   // 該当従業員なし(LEFT JOIN テスト用)
};

var sales = new List<SalesRecord>
{
    new("製品A", "東京", 1, 1_200_000),
    new("製品B", "東京", 1,   800_000),
    new("製品A", "大阪", 1,   950_000),
    new("製品A", "東京", 2, 1_350_000),
    new("製品B", "大阪", 2,   620_000),
    new("製品B", "東京", 2,   750_000),
    new("製品A", "大阪", 3, 1_100_000),
    new("製品B", "大阪", 3,   580_000),
};

OrderBy — ソートの完全解説

基本と OrderByDescending

OrderBy / OrderByDescending
// 昇順ソート
var byAge = employees.OrderBy(e => e.Age);
// Alice(28), Frank(26)... → 年齢の若い順

// 降順ソート
var bySalaryDesc = employees.OrderByDescending(e => e.Salary);
// Bob(680_000), Grace(590_000)... → 給与の高い順

// ソート後は IOrderedEnumerable<T> になる(ThenBy をチェーンできる)

ThenBy / ThenByDescending — 多段ソート

第1キーが同じ場合の第2・第3ソートは ThenBy/ThenByDescending をチェーンします。OrderBy を複数並べると前のソートが捨てられるため注意が必要です。

ThenBy による多段ソート
// NG: OrderBy を複数並べると最後の OrderBy しか効かない
var wrong = employees
    .OrderBy(e => e.Department)      // 無視される!
    .OrderBy(e => e.Salary);         // これだけ有効

// OK: ThenBy でチェーン
var correct = employees
    .OrderBy(e => e.Department)      // 第1キー: 部門名(昇順)
    .ThenBy(e => e.Age)              // 第2キー: 年齢(昇順)
    .ThenByDescending(e => e.Salary);// 第3キー: 給与(降順)

foreach (var e in correct)
    Console.WriteLine($"{e.Department,-6} {e.Age,2}歳  ¥{e.Salary:N0}  {e.Name}");
// 開発   26歳  ¥530,000  Frank
// 開発   28歳  ¥550,000  Alice
// 開発   35歳  ¥680,000  Bob
// 人事   29歳  ¥460,000  Eve
// 人事   33歳  ¥490,000  Hiro
// 営業   24歳  ¥420,000  Carol
// 営業   31歳  ¥510,000  Dave
// 営業   38歳  ¥590,000  Grace

カスタム比較 — IComparer と StringComparer

カスタム比較でソート
\
// 文字列を「大文字小文字を無視してソート」
var names = new[] { "banana", "Apple", "cherry", "AVOCADO" };
var sorted = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
// → Apple, AVOCADO, banana, cherry

// 数字入り文字列を「自然順(Natural Sort)」でソート
// "Item2" < "Item10"(辞書順だと "Item10" < "Item2" になる)
var items = new[] { "Item10", "Item2", "Item1", "Item20", "Item3" };

var naturalSorted = items.OrderBy(
    s => System.Text.RegularExpressions.Regex.Replace(s, @"\d+", m => m.Value.PadLeft(10, '0'))
);
// → Item1, Item2, Item3, Item10, Item20

// 独自 IComparer を使う例(長さ優先、同じ長さなら辞書順)
public class LengthThenAlpha : IComparer<string>
{
    public int Compare(string? x, string? y)
    {
        if (x is null || y is null) return Comparer<string>.Default.Compare(x, y);
        int lenDiff = x.Length - y.Length;
        return lenDiff != 0 ? lenDiff : string.Compare(x, y, StringComparison.Ordinal);
    }
}

var customSorted = names.OrderBy(n => n, new LengthThenAlpha());
// → Apple, banana, AVOCADO, cherry(長さ昇順 → 辞書順)

OrderBy の安定性

LINQ の OrderBy は安定ソートです
同じキー値を持つ要素は、元のシーケンスでの順序が保持されます(.NET の実装は TimSort ベース)。複数回ソートしても前のソート結果が壊れないため、ThenBy を使わなくても段階的にソートを構築できます(ただし ThenBy の方が明示的で推奨)。

GroupBy — グループ化の完全解説

4つのオーバーロード

オーバーロード 説明
GroupBy(keySelector) 最もシンプル。要素はそのまま IGrouping<TKey,TSource>
GroupBy(keySelector, elementSelector) グループ内の要素を変換してから格納
GroupBy(keySelector, resultSelector) 各グループをまとめて別の型に変換
GroupBy(keySelector, elementSelector, resultSelector) 要素変換 + グループ変換の両方
GroupBy の各オーバーロード
// ① keySelector のみ
var byDept1 = employees.GroupBy(e => e.Department);
foreach (var g in byDept1)
    Console.WriteLine($"{g.Key}: {string.Join(", ", g.Select(e => e.Name))}");
// 開発: Alice, Bob, Frank
// 営業: Carol, Dave, Grace
// 人事: Eve, Hiro

// ② keySelector + elementSelector(要素を変換して格納)
var byDept2 = employees.GroupBy(
    e => e.Department,
    e => e.Name           // 名前だけ格納
);
// g.Key = "開発", g = ["Alice", "Bob", "Frank"]

// ③ keySelector + resultSelector(グループを集約して新しい型に)
var summary = employees.GroupBy(
    e => e.Department,
    (dept, members) => new
    {
        Department  = dept,
        Count       = members.Count(),
        AvgSalary   = members.Average(e => e.Salary),
        MaxSalary   = members.Max(e => e.Salary),
    }
);
foreach (var s in summary.OrderByDescending(s => s.AvgSalary))
    Console.WriteLine($"{s.Department}: {s.Count}名, 平均¥{s.AvgSalary:N0}, 最高¥{s.MaxSalary:N0}");
// 開発: 3名, 平均¥586,667, 最高¥680,000
// 営業: 3名, 平均¥506,667, 最高¥590,000
// 人事: 2名, 平均¥475,000, 最高¥490,000

複合キーでグループ化

複数のプロパティをキーにしたい場合は、匿名型または ValueTuple をキーにします。

複合キーの GroupBy
// 匿名型をキーに(部門 × 30代以上かどうか)
var grouped = employees.GroupBy(e => new
{
    e.Department,
    IsSenior = e.Age >= 30
});

foreach (var g in grouped.OrderBy(g => g.Key.Department).ThenBy(g => g.Key.IsSenior))
{
    string label = g.Key.IsSenior ? "30代以上" : "20代";
    Console.WriteLine($"{g.Key.Department} [{label}]: {string.Join(", ", g.Select(e => e.Name))}");
}
// 人事 [20代]: Eve
// 人事 [30代以上]: Hiro
// 営業 [20代]: Carol
// 営業 [30代以上]: Dave, Grace
// 開発 [20代]: Alice, Frank
// 開発 [30代以上]: Bob

// ValueTuple をキーにする方法
var grouped2 = sales.GroupBy(s => (s.Product, s.Region));
foreach (var g in grouped2)
    Console.WriteLine($"{g.Key.Product} × {g.Key.Region}: 合計 ¥{g.Sum(s => s.Amount):N0}");

GroupBy vs ToLookup — 遅延評価 vs 即時評価

観点 GroupBy ToLookup
評価タイミング 遅延評価(foreach 時に実行) 即時評価(呼び出し時点でグループ化完了)
アクセス方法 foreach でのみ走査 キーで直接アクセス可(lookup["開発"]
存在しないキー グループなし(列挙されない) 空シーケンスが返る(KeyNotFoundException なし)
複数回の利用 毎回グループ化処理が走る 1回だけ処理して再利用できる
用途 一度だけ走査する集計処理 キーで繰り返し参照する場合・静的なルックアップ表
ToLookup の使い方
// ToLookup: 即時評価でグループ化した Lookup<TKey, TElement> を生成
ILookup<string, Employee> lookup = employees.ToLookup(e => e.Department);

// キーで直接アクセスできる
foreach (var emp in lookup["開発"])
    Console.WriteLine(emp.Name);   // Alice, Bob, Frank

// 存在しないキーは KeyNotFoundException ではなく空シーケンスを返す
foreach (var emp in lookup["広報"])   // キーなし → 空シーケンス、例外なし
    Console.WriteLine(emp.Name);      // 何も出力されない

// ContainsKey 相当の確認
Console.WriteLine(lookup.Contains("人事"));   // True
Console.WriteLine(lookup.Contains("広報"));   // False

// GroupBy との差: 複数回参照するなら ToLookup の方が効率的
// GroupBy を複数回 foreach するとグループ化処理が毎回実行される
var byDept = employees.GroupBy(e => e.Department);
// 下の2回のforeachで2回グループ化処理が走る(遅延評価のため)
var devCount  = byDept.First(g => g.Key == "開発").Count();
var salesList = byDept.First(g => g.Key == "営業").ToList();
// ToLookup なら1回だけ処理されてキャッシュされる

Join — 結合の完全解説

INNER JOIN(基本)

Join の基本(INNER JOIN)
// departments: Id 1=開発, 2=営業, 3=人事, 4=マーケ(従業員なし)
// employees: Department は名前文字列で持つ(Id と一致させるには別途参照)

// 部門 Id × 従業員の Department 名を突き合わせる例
var deptById = departments.ToDictionary(d => d.Name);

// employees × departments を結合(Department 名がキー)
var empWithLocation = employees.Join(
    departments,
    emp  => emp.Department,    // 外部シーケンスのキー(従業員の部門名)
    dept => dept.Name,         // 内部シーケンスのキー(部門の名前)
    (emp, dept) => new         // 結果セレクタ
    {
        emp.Name,
        emp.Department,
        dept.Location,
        emp.Salary,
    }
);

foreach (var r in empWithLocation.OrderBy(r => r.Department))
    Console.WriteLine($"{r.Name,-8} {r.Department,-6} {r.Location}  ¥{r.Salary:N0}");
// Alice    開発     東京  ¥550,000
// Bob      開発     東京  ¥680,000
// ...
// ※ "マーケ" の部門は従業員がいないため結果に含まれない(INNER JOIN)

複合キーの Join

複合キーで JOIN する
// 製品 × 地域ごとの月次予算データ
var budgets = new[]
{
    new { Product = "製品A", Region = "東京", Budget = 1_500_000m },
    new { Product = "製品A", Region = "大阪", Budget = 1_000_000m },
    new { Product = "製品B", Region = "東京", Budget =   900_000m },
    new { Product = "製品B", Region = "大阪", Budget =   700_000m },
};

// sales × budgets を 製品 × 地域 の複合キーで結合
var comparison = sales.GroupBy(s => (s.Product, s.Region))
    .Join(
        budgets,
        g      => (g.Key.Product, g.Key.Region),   // GroupBy 後のキー
        b      => (b.Product,     b.Region),        // budgets のキー
        (g, b) => new
        {
            g.Key.Product,
            g.Key.Region,
            TotalSales = g.Sum(s => s.Amount),
            b.Budget,
            AchievementRate = g.Sum(s => s.Amount) / b.Budget * 100,
        }
    );

foreach (var r in comparison.OrderBy(r => r.Product).ThenBy(r => r.Region))
    Console.WriteLine($"{r.Product} × {r.Region}: 実績 ¥{r.TotalSales:N0} / 予算 ¥{r.Budget:N0} ({r.AchievementRate:F1}%)");
// 製品A × 大阪: 実績 ¥2,050,000 / 予算 ¥1,000,000 (205.0%)
// 製品A × 東京: 実績 ¥2,550,000 / 予算 ¥1,500,000 (170.0%)
// 製品B × 大阪: 実績 ¥1,200,000 / 予算 ¥700,000 (171.4%)
// 製品B × 東京: 実績 ¥1,550,000 / 予算 ¥900,000 (172.2%)

GroupJoin — LEFT OUTER JOIN(全件維持)

INNER JOIN では片方にしかないレコードが除外されます。左側(外部)のすべての要素を維持したい場合は GroupJoin を使います。

GroupJoin の基本
// departments(左)の全件と、対応する employees(右)を結合
// 「マーケ」のように従業員がいない部門も結果に含める

var deptWithEmps = departments.GroupJoin(
    employees,
    dept => dept.Name,           // 外側(departments)のキー
    emp  => emp.Department,      // 内側(employees)のキー
    (dept, emps) => new          // emps は IEnumerable<Employee>(0件の場合もある)
    {
        DeptName   = dept.Name,
        Location   = dept.Location,
        Employees  = emps.ToList(),
        HeadCount  = emps.Count(),
        TotalSalary = emps.Sum(e => e.Salary),
    }
);

foreach (var d in deptWithEmps.OrderBy(d => d.DeptName))
{
    Console.WriteLine($"{d.DeptName} ({d.Location}): {d.HeadCount}名, 合計¥{d.TotalSalary:N0}");
    foreach (var e in d.Employees)
        Console.WriteLine($"  └ {e.Name} ¥{e.Salary:N0}");
}
// 営業 (大阪): 3名, 合計¥1,520,000
//   └ Carol ¥420,000
//   └ Dave ¥510,000
//   └ Grace ¥590,000
// 人事 (東京): 2名, 合計¥950,000
//   └ ...
// マーケ (名古屋): 0名, 合計¥0    ← 従業員なしでも行が出る
// 開発 (東京): 3名, 合計¥1,760,000

GroupJoin + SelectMany + DefaultIfEmpty — フラットな LEFT OUTER JOIN

GroupJoin の結果はネスト構造です。SQL の LEFT JOIN のようにフラットなレコードが欲しい場合は SelectMany + DefaultIfEmpty() を組み合わせます。

フラットな LEFT OUTER JOIN
// SQL の LEFT JOIN 相当: 全部門 × 各従業員(従業員なし部門は null になる)
var leftJoinResult = departments.GroupJoin(
    employees,
    dept => dept.Name,
    emp  => emp.Department,
    (dept, emps) => (dept, emps)          // タプルで保持
)
.SelectMany(
    t => t.emps.DefaultIfEmpty(),         // 従業員が0件なら null を1件挿入
    (t, emp) => new
    {
        DeptName  = t.dept.Name,
        Location  = t.dept.Location,
        EmpName   = emp?.Name ?? "(なし)",  // null なら "(なし)"
        Salary    = emp?.Salary ?? 0m,
    }
);

foreach (var r in leftJoinResult.OrderBy(r => r.DeptName).ThenBy(r => r.EmpName))
    Console.WriteLine($"{r.DeptName,-6} {r.Location}  {r.EmpName,-8} ¥{r.Salary:N0}");
// 営業   大阪  Carol    ¥420,000
// 営業   大阪  Dave     ¥510,000
// 営業   大阪  Grace    ¥590,000
// 人事   東京  Eve      ¥460,000
// 人事   東京  Hiro     ¥490,000
// マーケ 名古屋(なし)  ¥0       ← LEFT JOIN で保持
// 開発   東京  Alice    ¥550,000
// 開発   東京  Bob      ¥680,000
// 開発   東京  Frank    ¥530,000
結合種類 LINQ での実装 SQL 相当
INNER JOIN Join INNER JOIN(両方に存在するものだけ)
LEFT OUTER JOIN(ネスト) GroupJoin 左側全件 + 右側グループ
LEFT OUTER JOIN(フラット) GroupJoin + SelectMany + DefaultIfEmpty LEFT JOIN(左側全件 + 右側 nullable)
CROSS JOIN from a in A from b in B select ... CROSS JOIN(全組み合わせ)
自己結合 同一コレクションを2回 Join self JOIN

クエリ構文 vs メソッド構文

GroupBy と Join はメソッド構文で書くとラムダのネストが深くなります。クエリ構文(SQL ライク)の方が読みやすい場面があります。

GroupBy — クエリ構文とメソッド構文
// メソッド構文
var method = employees
    .GroupBy(e => e.Department)
    .Select(g => new { Department = g.Key, Count = g.Count(), Avg = g.Average(e => e.Salary) });

// クエリ構文(SQL ライク)
var query =
    from e in employees
    group e by e.Department into g
    select new { Department = g.Key, Count = g.Count(), Avg = g.Average(e => e.Salary) };

// 両者は同一の結果を生成する
Join — クエリ構文とメソッド構文
// メソッド構文(ラムダが3段になり読みにくい)
var methodJoin = employees.Join(
    departments,
    e  => e.Department,
    d  => d.Name,
    (e, d) => new { e.Name, e.Salary, d.Location }
);

// クエリ構文(SQL の JOIN 構文と対応するので直感的)
var queryJoin =
    from e in employees
    join d in departments on e.Department equals d.Name
    select new { e.Name, e.Salary, d.Location };

// GroupJoin のクエリ構文(LEFT JOIN)
var queryLeft =
    from d in departments
    join e in employees on d.Name equals e.Department into empGroup
    from e in empGroup.DefaultIfEmpty()
    select new
    {
        DeptName = d.Name,
        EmpName  = e?.Name ?? "(なし)",
    };
構文の使い分けガイド
Where/Select/OrderBy のみ → メソッド構文が簡潔
GroupBy / Join / GroupJoin が絡む → クエリ構文の方が読みやすい場合が多い
・混在も可能:from ... join ... select の後に .OrderBy(...) をチェーン

実践例

実践例1: 月次売上レポート(GroupBy + OrderBy + 集計)

月次売上レポート生成
// 製品 × 地域 の全組み合わせ集計 → 製品別合計でソート
var report = sales
    .GroupBy(s => s.Product)
    .Select(g => new
    {
        Product     = g.Key,
        TotalAmount = g.Sum(s => s.Amount),
        ByRegion    = g.GroupBy(s => s.Region)
                       .Select(rg => new { Region = rg.Key, Amount = rg.Sum(s => s.Amount) })
                       .OrderByDescending(rg => rg.Amount)
                       .ToList(),
        BestMonth   = g.GroupBy(s => s.Month)
                       .OrderByDescending(mg => mg.Sum(s => s.Amount))
                       .First().Key,
    })
    .OrderByDescending(r => r.TotalAmount);

foreach (var r in report)
{
    Console.WriteLine($"
【{r.Product}】合計 ¥{r.TotalAmount:N0}  最好調月: {r.BestMonth}月");
    foreach (var rg in r.ByRegion)
        Console.WriteLine($"  {rg.Region}: ¥{rg.Amount:N0}");
}
// 【製品A】合計 ¥5,600,000  最好調月: 2月
//   東京: ¥2,550,000
//   大阪: ¥2,050,000
//   ...(以下略)

実践例2: 部門別スキルマップ(GroupJoin + 集計)

部門別人員・年齢・給与ダッシュボード
// 全部門(従業員なし部門も含む)の統計サマリーを生成
var dashboard = departments
    .GroupJoin(employees, d => d.Name, e => e.Department, (d, emps) =>
    {
        var empList = emps.ToList();
        return new
        {
            d.Name,
            d.Location,
            Count      = empList.Count,
            AvgAge     = empList.Count > 0 ? empList.Average(e => e.Age) : (double?)null,
            TotalSalary = empList.Sum(e => e.Salary),
            SeniorCount = empList.Count(e => e.Age >= 30),
        };
    })
    .OrderByDescending(d => d.TotalSalary);

Console.WriteLine($"{"部門",-8} {"拠点",-6} {"人数",4} {"平均年齢",8} {"合計給与",14} {"30代以上",8}");
Console.WriteLine(new string('-', 55));
foreach (var d in dashboard)
{
    string avgAge = d.AvgAge.HasValue ? $"{d.AvgAge:F1}歳" : " ----";
    Console.WriteLine($"{d.Name,-8} {d.Location,-6} {d.Count,4} {avgAge,8} ¥{d.TotalSalary,12:N0} {d.SeniorCount,4}名");
}

実践例3: 同一グループ内のランキング生成

部門内給与ランキング
// 各部門内で給与が高い順に順位を付ける
var ranked = employees
    .GroupBy(e => e.Department)
    .SelectMany(g =>
        g.OrderByDescending(e => e.Salary)
         .Select((e, i) => new
         {
             e.Department,
             e.Name,
             e.Salary,
             Rank = i + 1,   // 0始まりのインデックスを1始まりに
         })
    )
    .OrderBy(r => r.Department)
    .ThenBy(r => r.Rank);

foreach (var r in ranked)
    Console.WriteLine($"{r.Department} #{r.Rank}: {r.Name} ¥{r.Salary:N0}");
// 人事 #1: Hiro ¥490,000
// 人事 #2: Eve ¥460,000
// 営業 #1: Grace ¥590,000
// ...

よくある落とし穴と注意点

GroupBy の結果を複数回 foreach すると処理が重複する

GroupBy の遅延評価に注意
// NG: GroupBy の結果を複数回 foreach すると毎回グループ化処理が走る
var grouped = employees.GroupBy(e => e.Department);   // まだ実行されない

var devCount = grouped.First(g => g.Key == "開発").Count();   // 1回目の実行
var devNames = grouped.First(g => g.Key == "開発").Select(e => e.Name).ToList(); // 2回目の実行
// → employees リストを2回スキャンしてグループ化している!

// OK: ToLookup か ToList()/ToArray() で実体化してから使う
var lookup = employees.ToLookup(e => e.Department);   // 1回だけ実行
var devCount2 = lookup["開発"].Count();   // O(1) アクセス
var devNames2 = lookup["開発"].Select(e => e.Name).ToList();

Join に null が含まれると一致しない

null キーの扱い
var data = new[]
{
    new { Id = 1, Category = "A" },
    new { Id = 2, Category = (string?)null },   // null
};
var categories = new[]
{
    new { Name = "A", Label = "カテゴリA" },
    new { Name = (string?)null, Label = "未分類" },  // null
};

// Join では null == null が一致しない(EqualityComparer の仕様)
var joined = data.Join(categories, d => d.Category, c => c.Name, (d, c) => (d.Id, c.Label));
// → Id=1 のみ結果に含まれる。Id=2(null)は一致しない!

// null を含む場合の対策: 事前にデフォルト値に変換する
var joined2 = data.Join(
    categories,
    d => d.Category ?? "",
    c => c.Name     ?? "",
    (d, c) => (d.Id, c.Label)
);

よくある質問

QOrderBy と Sort/Array.Sort はどちらが速いですか?
AArray.Sort はインプレース(元の配列を変更)で高速です。OrderBy は新しいシーケンスを返し、安定ソートです。配列をインプレースでソートして良い場合は Array.Sort の方が高速ですが、IEnumerable 全般に使えて安定性が保証されるという意味で OrderBy の方が汎用的です。.NET 7 以降は list.Order()/list.OrderDescending() という LINQ メソッドも追加されています。
QGroupBy の結果から特定のグループだけ取り出すには?
AFirst(g => g.Key == "開発") で取り出せますが、前述のとおり複数回参照するなら ToLookup が効率的です。lookup["開発"] で直接アクセスでき、存在しないキーでも KeyNotFoundException が発生せず空シーケンスが返ります。
QJoin と GroupJoin はどう使い分けますか?
A左側(外部)のすべての要素を結果に含めたいなら GroupJoin(LEFT OUTER JOIN)を使います。両方のコレクションに存在するものだけが必要なら Join(INNER JOIN)で十分です。「部門一覧で従業員がいない部門も表示したい」→ GroupJoin、「従業員の部門名を表示したいだけ」→ Join が適切です。
Qクエリ構文とメソッド構文、どちらを使うべきですか?
Aプロジェクトで統一されていればどちらでも構いません。ただし Join/GroupJoin はクエリ構文の方が意図が読み取りやすく、複数の結合を連鎖させるときも見通しが良いです。Where/Select/OrderBy だけならメソッド構文が短く書けます。

まとめ

演算子 ポイント
OrderBy/OrderByDescending 安定ソート。複数 OrderBy 連鎖は最後だけ有効
ThenBy/ThenByDescending 多段ソートは ThenBy をチェーン。IOrderedEnumerable に対して使う
GroupBy(4オーバーロード) 遅延評価。複合キーは匿名型か ValueTuple。結果は IGrouping<K,V>
ToLookup 即時評価。複数回参照・キー検索が多い場合に GroupBy より効率的
Join(INNER JOIN) 両コレクションに存在するもの同士を結合。null キーは一致しない
GroupJoin(LEFT JOIN ネスト) 左側全件 + 右側グループ。従業員なし部門も含む集計に
GroupJoin + SelectMany + DefaultIfEmpty フラットな LEFT OUTER JOIN。SQL の LEFT JOIN に相当

集約関数(Sum/Average/Max/Min)と GroupBy の組み合わせはLINQでグルーピング・集計を行う方法を、IEnumerableIQueryable(Entity Framework での LINQ)の違いはIEnumerableとIQueryableの違いを参照してください。