| name | dotnet-modern-csharp-coding-standards |
| description | モダン C#(12+)で record、パターンマッチング、合成、Result 型エラーハンドリングを使った 慣用的で高性能なコードを書く。新規 C# コードの作成、API 設計、 または C# 12+ イディオムへのリファクタリング。
|
モダン C# コーディング標準
モダン C#(12+)で慣用的なコードを書くための簡潔なガードレール。record によるデータモデリング、パターンマッチング、合成優先の設計、Railway 指向のエラーハンドリングをカバーします。.NET 8+ と C# 12+ を前提にし、外部依存は使いません。
ゴール駆動で使うため、最初に達成したいゴール、成功条件、確認手段を短く固定します。
略語: DTO (Data Transfer Object), API (Application Programming Interface), DI (Dependency Injection), BCL (Base Class Library), GC (Garbage Collector).
こんなときに使う
- 新規 C# コードの作成、または既存コードのモダンなイディオムへのリファクタリング
- 強い型付けと不変性を持つドメインモデルの設計
record、record struct、class、struct の使い分け判断
- パターンマッチングによる制御フローの簡潔化
- 例外の代わりに
Result<T, TError> でエラーハンドリング
- C# コードのアンチパターン(可変 DTO、深い継承、リフレクション マッピング)のレビュー
関連スキル
| Skill | Scope |
|---|
dotnet-type-design-performance | Span<T>、Memory<T>、ArrayPool、ゼロアロケーションパターン |
dotnet | API 設計や新規基盤 skill の入口 |
dotnet-csharp-concurrency-patterns | async/await ベストプラクティス、CancellationToken、IAsyncEnumerable |
基本原則
- Immutability by Default —
record 型と init 専用プロパティを使用。可変状態は並行処理バグとロジックバグの最大の原因。
- Type Safety — nullable 参照型、
readonly record struct による値オブジェクト、強く型付けされた ID を活用し、コンパイル時にエラーを検出。
- Modern Pattern Matching —
if/else チェーンを switch 式とリレーショナル/プロパティ/リストパターンに置き換え、表現力豊かで網羅的な分岐を実現。
- Composition Over Inheritance — 抽象基底クラスよりインターフェース+合成を優先。フラットな構造はテスト、拡張、理解が容易。
- Railway Error Handling — 予期されるエラー(バリデーション、ビジネスルール)には
Result<T, TError> を使用。例外は本当に予期しない障害にのみ。
- Explicit Over Magic — リフレクションベースのライブラリ(AutoMapper、Mapster)よりコンパイル時チェック可能な明示的マッピングを優先。可視性は利便性に勝る。
Values: 基礎と型の追求(最小形式で最大可能性を生む設計思想), 温故知新(C# の進化を活かしつつ堅実な原則を守る)
ワークフロー: モダン C# を書く
Step 1: レコードでデータを表現する
DTO、メッセージ、ドメインエンティティには record を使用。値オブジェクトには readonly record struct を使用。
public record CustomerDto(string Id, string Name, string Email);
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative");
if (currency is not { Length: 3 })
throw new ArgumentException("Currency must be 3-letter code");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
}
使い分けガイド:
| 型 | 使用場面 |
|---|
record class | エンティティ、DTO、複数プロパティを持つ集約 |
readonly record struct | 値オブジェクト、強く型付けされた ID、小さな不変値 |
class | 可変サービス、フレームワーク要求の基底クラス |
struct | パフォーマンスクリティカル、小データ(≤16 バイト)、ID なし |
⚠️ 値オブジェクトに暗黙的変換は禁止 — コンパイル時の型安全性を無効化します。詳細は language-patterns.md を参照。
Values: 基礎と型の追求(型で不変条件を守り、コンパイラを味方にする)
Step 2: パターンマッチングと Nullable 型を適用する
分岐ロジックには switch 式を使用。プロジェクト全体で <Nullable>enable</Nullable> を有効化。
public decimal CalculateDiscount(Order order) => order switch
{
{ Total: > 1000m } => order.Total * 0.15m,
{ Total: > 500m } => order.Total * 0.10m,
{ Total: > 100m } => order.Total * 0.05m,
_ => 0m
};
public string ClassifyTemperature(int temp) => temp switch
{
< 0 => "Freezing",
>= 0 and < 20 => "Cool",
>= 20 and < 30 => "Warm",
>= 30 => "Hot"
};
public decimal GetDiscount(Customer? customer) => customer switch
{
null => 0m,
{ IsVip: true } => 0.20m,
{ OrderCount: > 10 } => 0.10m,
_ => 0.05m
};
リストパターン、タプルパターン、nullable 処理の詳細は language-patterns.md を参照。
Values: 成長の複利(パターンマッチングの習得が、あらゆる分岐ロジックの品質を底上げする)
Step 3: 継承より合成を優先する
public abstract class PaymentProcessor
{
public abstract Task<PaymentResult> ProcessAsync(Money amount);
protected async Task<bool> ValidateAsync(Money amount) { }
}
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessAsync(Money amount, CancellationToken ct);
}
public sealed class CreditCardProcessor(
IPaymentValidator validator,
ICreditCardGateway gateway) : IPaymentProcessor
{
public async Task<PaymentResult> ProcessAsync(Money amount, CancellationToken ct)
{
var validation = await validator.ValidateAsync(amount, ct);
if (!validation.IsValid)
return PaymentResult.Failed(validation.Error);
return await gateway.ChargeAsync(amount, ct);
}
}
継承が許容される場面:
- フレームワーク要件(例:ASP.NET Core の
ControllerBase)
- ライブラリ統合(例:
Exception からのカスタム例外)
- アプリケーションコードでは まれ であるべき
Values: 余白の設計(合成可能な小さな部品が、将来の変化に対応する余白を生む)
Step 4: Result 型でエラーを扱う
予期されるエラーには Result<T, TError> を使用。予期しない障害には例外を使用。
public readonly record struct OrderError(string Code, string Message);
public async Task<Result<Order, OrderError>> CreateOrderAsync(
CreateOrderRequest request, CancellationToken ct)
{
var validation = ValidateRequest(request);
if (validation.IsFailure)
return Result<Order, OrderError>.Failure(validation.Error);
var order = new Order(OrderId.New(), new CustomerId(request.CustomerId), request.Items);
await _repository.SaveAsync(order, ct);
return Result<Order, OrderError>.Success(order);
}
return result.Match(
onSuccess: order => new OkObjectResult(order),
onFailure: error => error.Code switch
{
"VALIDATION_ERROR" => new BadRequestObjectResult(error.Message),
"NOT_FOUND" => new NotFoundObjectResult(error.Message),
_ => new ObjectResult(error.Message) { StatusCode = 500 }
});
| 状況 | 使用するもの |
|---|
| バリデーション失敗、ビジネスルール違反、見つからない | Result<T, TError> |
| ネットワーク障害、null 参照、OOM、プログラミングバグ | Exception |
完全な Result<T, TError> 実装と Railway 合成は error-handling-patterns.md を参照。
Values: ニュートラルな視点(例外と Result を状況に応じて使い分け、偏りのない設計を保つ)
Step 5: コードファイルを整理する
一貫した名前空間とファイルレイアウトに従います:
Domain/
Orders/
Order.cs # 主要ドメイン型 + 関連 record
OrderService.cs # ドメインロジック
IOrderRepository.cs
型ファイル内の順序:
- 主要ドメイン型(record/class)
- 状態の enum
- 関連 record(アイテム、イベント)
- 値オブジェクト(readonly record struct)
- エラー型
namespace MyApp.Domain.Orders;
public record Order(OrderId Id, CustomerId CustomerId, OrderStatus Status, Money Total, IReadOnlyList<OrderItem> Items)
{
public bool IsCompleted => Status is OrderStatus.Completed;
}
public enum OrderStatus { Draft, Submitted, Processing, Completed, Cancelled }
public record OrderItem(ProductId ProductId, Quantity Quantity, Money UnitPrice)
{
public Money Total => new(UnitPrice.Amount * Quantity.Value, UnitPrice.Currency);
}
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
public readonly record struct OrderError(string Code, string Message);
Values: 継続は力(一貫したファイル構成が、日々のコードリーディングを高速化する)
良い実践
- ✅ DTO、メッセージ、ドメインエンティティには
record を使用
- ✅ 値オブジェクトと強く型付けされた ID には
readonly record struct を使用
- ✅
if/else より switch 式によるパターンマッチングを活用
- ✅ nullable 参照型を有効にし、警告を尊重(
<Nullable>enable</Nullable>)
- ✅ すべての非同期メソッドで
CancellationToken を受け取る
- ✅ API からは
List<T> ではなく IReadOnlyList<T> を返す
- ✅ 予期されるエラーには
Result<T, TError> を使用
- ✅ 継承階層よりインターフェースと合成を優先
- ✅ リフレクションベースのマッパーではなく明示的なマッピングメソッドを使用
- ✅ シンプルなサービスクラスにはプライマリコンストラクタ(C# 12+)を使用
注意点
- async のブロッキング —
.Result や .Wait() の呼び出しはデッドロックを引き起こす。最後まで async を貫く。
- 可変 DTO —
record の代わりに { get; set; } を持つ class を使用。意図しない変更につながる。
- 値オブジェクトの暗黙的変換 —
implicit operator はコンパイル時の型安全性を無効化する。
- 深い継承 —
Entity → AggregateRoot → Order → CustomerOrder。フラットな合成を使用する。
- null の無視 — パターンマッチングで処理する代わりに nullable 警告を無視する。
- 予期されるエラーへの throw — バリデーション/見つからないケースに
Result<T, TError> ではなく例外を使用。
アンチパターン
❌ Mutable DTOs → ✅ Immutable Records
public class CustomerDto { public string Id { get; set; } public string Name { get; set; } }
public record CustomerDto(string Id, string Name);
❌ Class Value Objects → ✅ Readonly Record Structs
public class OrderId { public string Value { get; } }
public readonly record struct OrderId(string Value);
❌ Deep Inheritance → ✅ Flat Composition
public abstract class Entity { }
public abstract class AggregateRoot : Entity { }
public class CustomerOrder : AggregateRoot { }
public interface IEntity { Guid Id { get; } }
public record Order(OrderId Id, CustomerId CustomerId) : IEntity { Guid IEntity.Id => Id.Value; }
❌ Reflection Mapping → ✅ Explicit Methods
var dto = _mapper.Map<UserDto>(entity);
public static UserDto ToDto(this UserEntity e) => new(e.Id.ToString(), e.FullName, e.EmailAddress);
詳細はソースジェネレータと UnsafeAccessor について anti-reflection-patterns.md を参照。
❌ Returning Mutable Collections
public List<Order> GetOrders() => _orders;
public IReadOnlyList<Order> GetOrders() => _orders;
❌ Blocking on Async
public Order GetOrder(OrderId id) => GetOrderAsync(id).Result;
public async Task<Order> GetOrderAsync(OrderId id, CancellationToken ct = default)
=> await _repository.GetAsync(id, ct);
早見表
When to Use record vs class vs struct
| 必要なもの | 型 | 理由 |
|---|
| DTO / メッセージ / イベント | record | 不変、値等価、with サポート |
| ドメインエンティティ | record | 同上 + 計算プロパティ |
| 値オブジェクト / 型付き ID | readonly record struct | スタック割り当て、値セマンティクス |
| DI 付き可変サービス | class(sealed) | 可変状態 / ライフサイクルが必要 |
| 小さな数学データ(≤16 バイト) | struct | パフォーマンスクリティカル、ID なし |
When to Use Result vs Exception
| 状況 | 仕組み | 理由 |
|---|
| バリデーション失敗 | Result<T, TError> | 予期される、呼び出し元が処理すべき |
| ビジネスルール違反 | Result<T, TError> | 通常のフローの一部 |
| エンティティが見つからない | Result<T, TError> | 予期されるクエリ結果 |
| ネットワーク / I/O 障害 | Exception | 予期しないインフラエラー |
| null 参照 / OOM | Exception | プログラミングバグ / システムエラー |
File Ordering in a Type File
1. 主要ドメイン型(record/class)
2. Enum
3. 関連 record
4. 値オブジェクト(readonly record struct)
5. エラー型
リソース