.NET 開発パターン
堅牢でパフォーマンスが高く、保守性の高いアプリケーションを構築するための、イディオマティックな C# および .NET パターン。
アクティベーション条件
- 新しい C# コードを記述する場合
- C# コードをレビューする場合
- 既存の .NET アプリケーションをリファクタリングする場合
- ASP.NET Core を使用したサービスアーキテクチャを設計する場合
基本原則
1. 不変性を優先する
データモデルにはレコードと init 専用プロパティを使用します。可変性は明示的で正当な選択であるべきです。
// 良い: 不変な値オブジェクト
public sealed record Money(decimal Amount, string Currency);
// 良い: init セッターを使用した不変な DTO
public sealed class CreateOrderRequest
{
public required string CustomerId { get; init; }
public required IReadOnlyList<OrderItem> Items { get; init; }
}
// 悪い: パブリックセッターを持つ可変モデル
public class Order
{
public string CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
}
2. 明示性を暗黙性よりも優先する
null 許容性、アクセス修飾子、および意図について明確にします。
// 良い: 明示的なアクセス修飾子と null 許容性
public sealed class UserService
{
private readonly IUserRepository _repository;
private readonly ILogger<UserService> _logger;
public UserService(IUserRepository repository, ILogger<UserService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<User?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _repository.FindByIdAsync(id, cancellationToken);
}
}
3. 抽象化に依存する
サービスの境界にはインターフェイスを使用します。DI コンテナを介して登録します。
// 良い: インターフェイスベースの依存関係
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyList<Order>> FindByCustomerAsync(string customerId, CancellationToken cancellationToken);
Task AddAsync(Order order, CancellationToken cancellationToken);
}
// 登録
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
Async/Await パターン
適切な非同期の使用
// 良い: CancellationToken を使用して常に非同期
public async Task<OrderSummary> GetOrderSummaryAsync(
Guid orderId,
CancellationToken cancellationToken)
{
var order = await _repository.FindByIdAsync(orderId, cancellationToken)
?? throw new NotFoundException($"注文 {orderId} が見つかりません");
var customer = await _customerService.GetAsync(order.CustomerId, cancellationToken);
return new OrderSummary(order, customer);
}
// 悪い: 非同期処理をブロックする
public OrderSummary GetOrderSummary(Guid orderId)
{
var order = _repository.FindByIdAsync(orderId, CancellationToken.None).Result; // デッドロックのリスク
return new OrderSummary(order);
}
並列非同期処理
// 良い: 独立した処理の同時実行
public async Task<DashboardData> LoadDashboardAsync(CancellationToken cancellationToken)
{
var ordersTask = _orderService.GetRecentAsync(cancellationToken);
var metricsTask = _metricsService.GetCurrentAsync(cancellationToken);
var alertsTask = _alertService.GetActiveAsync(cancellationToken);
await Task.WhenAll(ordersTask, metricsTask, alertsTask);
return new DashboardData(
Orders: await ordersTask,
Metrics: await metricsTask,
Alerts: await alertsTask);
}
オプションパターン
構成セクションを厳密に型指定されたオブジェクトにバインドします。
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
public required string Host { get; init; }
public required int Port { get; init; }
public required string Username { get; init; }
public bool UseSsl { get; init; } = true;
}
// 登録
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection(SmtpOptions.SectionName));
// 注入による使用
public class EmailService(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _smtp = options.Value;
}
リザルトパターン
想定されるエラーに対して例外をスローする代わりに、明示的な成功/失敗を返します。
public sealed record Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(T value) { IsSuccess = true; Value = value; }
private Result(string error) { IsSuccess = false; Error = error; }
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error) => new(error);
}
// 使用例
public async Task<Result<Order>> PlaceOrderAsync(CreateOrderRequest request)
{
if (request.Items.Count == 0)
return Result<Order>.Failure("注文には少なくとも1つのアイテムが必要です");
var order = Order.Create(request);
await _repository.AddAsync(order, CancellationToken.None);
return Result<Order>.Success(order);
}
EF Core を使用したリポジトリパターン
public sealed class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public SqlOrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _db.Orders
.Include(o => o.Items)
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Order>> FindByCustomerAsync(
string customerId,
CancellationToken cancellationToken)
{
return await _db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task AddAsync(Order order, CancellationToken cancellationToken)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
}
}
ミドルウェアとパイプライン
// カスタムミドルウェア
public sealed class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"リクエスト {Method} {Path} が {ElapsedMs}ms で完了しました。ステータスコード: {StatusCode}",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
ミニマル API パターン
// ルートグループで整理
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders");
orders.MapGet("/{id:guid}", async (
Guid id,
IOrderRepository repository,
CancellationToken cancellationToken) =>
{
var order = await repository.FindByIdAsync(id, cancellationToken);
return order is not null
? TypedResults.Ok(order)
: TypedResults.NotFound();
});
orders.MapPost("/", async (
CreateOrderRequest request,
IOrderService service,
CancellationToken cancellationToken) =>
{
var result = await service.PlaceOrderAsync(request, cancellationToken);
return result.IsSuccess
? TypedResults.Created($"/api/orders/{result.Value!.Id}", result.Value)
: TypedResults.BadRequest(result.Error);
});
ガード節
// 良い: 明確な検証による早期リターン
public async Task<ProcessResult> ProcessPaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Amount <= 0)
throw new ArgumentOutOfRangeException(nameof(request.Amount), "金額は正の値である必要があります");
if (string.IsNullOrWhiteSpace(request.Currency))
throw new ArgumentException("通貨は必須です", nameof(request.Currency));
// ハッピーパスはネストなしでここに続く
var gateway = _gatewayFactory.Create(request.Currency);
return await gateway.ChargeAsync(request, cancellationToken);
}
避けるべきアンチパターン
| アンチパターン | 修正方法 |
|---|---|
async void メソッド | Task を返す(イベントハンドラーを除く) |
.Result または .Wait() | await を使用する |
catch (Exception) { } | コンテキスト付きで処理するか再スローする |
コンストラクタ内の new Service() | コンストラクタインジェクションを使用する |
public フィールド | 適切なアクセサーを持つプロパティを使用する |
ビジネスロジック内の dynamic | ジェネリクスまたは明示的な型を使用する |
可変の static 状態 | DI スコープまたは ConcurrentDictionary を使用する |
ループ内の string.Format | StringBuilder または補間文字列ハンドラーを使用する |