dotnet-solid-principles について
.NET 開発者向けに、クラス設計、インターフェース契約、依存関係の境界における SOLID と DRY の原則を解説するスキルです。C# のアンチパターンと改善例を示し、重複を残して早すぎる抽象化を避ける判断も扱います。
主な機能
- 01C# のアンチパターンで学ぶ SOLID
- 02クラスの責務分割の指針
- 03Strategy パターンによる振る舞いの拡張
- 04役割ごとに絞ったインターフェース設計
- 05依存性逆転と抽象化の境界
- 06DRY の判断と三回ルール
活用シーン
- 01責務が多すぎるサービスやエンドポイントの分割
- 02既存型を変更せずに振る舞いを追加
- 03呼び出し側が必要とするインターフェースの絞り込み
- 04重複コードを抽象化すべきか判断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-solid-principlesインストール後、Claude Code で/dotnet-solid-principlesを入力して読み込まれたか確認します。
詳細
dotnet-solid-principles
.NET アプリケーションの基礎となる設計原則です。各 SOLID 原則について、具体的な C# のアンチパターンと修正方法を取り上げ、重複が許容される場合の考慮事項も含めて DRY の指針を示します。これらの原則は、すべての .NET プロジェクトのクラス設計、インターフェイスの契約、依存関係の管理に役立ちます。
対象範囲の境界: このスキルでは、クラスおよびインターフェイスレベルの基礎的な SOLID/DRY 設計原則を扱います。アーキテクチャパターン(垂直スライス、リクエストパイプライン、キャッシュ、冪等性)は [skill:dotnet-architecture-patterns] が担当します。DI コンテナーの仕組み(登録、ライフタイム、キー付きサービス)は [skill:dotnet-csharp-dependency-injection] が担当します。コードの不吉な臭いやアンチパターンの検出は [skill:dotnet-csharp-code-smells] が担当します。
相互参照:クリーンアーキテクチャと垂直スライスについては [skill:dotnet-architecture-patterns]、DI の登録パターンとライフタイム管理については [skill:dotnet-csharp-dependency-injection]、アンチパターンの検出については [skill:dotnet-csharp-code-smells]、命名規則とスタイル規約については [skill:dotnet-csharp-coding-standards] を参照してください。
単一責任の原則(SRP)
クラスを変更する理由は一つだけであるべきです。「一文で説明する」テストを適用してください。「そして」や「または」を使わずにクラスの役割を一文で説明できない場合、そのクラスは SRP に違反している可能性があります。
アンチパターン:神クラス
// WRONG -- OrderService handles validation, persistence, email, and PDF generation
public class OrderService
{
private readonly AppDbContext _db;
private readonly SmtpClient _smtp;
public OrderService(AppDbContext db, SmtpClient smtp)
{
_db = db;
_smtp = smtp;
}
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
// Validation logic (reason to change #1)
if (string.IsNullOrEmpty(request.CustomerId))
throw new ArgumentException("Customer required");
// Persistence logic (reason to change #2)
var order = new Order { CustomerId = request.CustomerId };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
// Email notification (reason to change #3)
var message = new MailMessage("[email protected]", request.Email,
"Order Confirmed", $"Order {order.Id} created.");
await _smtp.SendMailAsync(message);
// PDF generation (reason to change #4)
GenerateInvoicePdf(order);
return order;
}
private void GenerateInvoicePdf(Order order) { /* ... */ }
}
修正:責任を分離する
// Each class has one reason to change
public sealed class OrderCreator(
IOrderValidator validator,
IOrderRepository repository,
IOrderNotifier notifier)
{
public async Task<Order> CreateAsync(
CreateOrderRequest request, CancellationToken ct)
{
validator.Validate(request);
var order = await repository.AddAsync(request, ct);
await notifier.OrderCreatedAsync(order, ct);
return order;
}
}
public sealed class OrderValidator : IOrderValidator
{
public void Validate(CreateOrderRequest request)
{
ArgumentException.ThrowIfNullOrEmpty(request.CustomerId);
// ... validation rules
}
}
public sealed class OrderRepository(AppDbContext db) : IOrderRepository
{
public async Task<Order> AddAsync(
CreateOrderRequest request, CancellationToken ct)
{
var order = new Order { CustomerId = request.CustomerId };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order;
}
}
アンチパターン:肥大化したコントローラー
// WRONG -- controller contains business logic, mapping, and persistence
app.MapPost("/api/orders", async (
CreateOrderRequest request,
AppDbContext db,
ILogger<Program> logger) =>
{
// Validation in the endpoint
if (request.Lines.Count == 0)
return Results.BadRequest("At least one line required");
// Business logic in the endpoint
var total = request.Lines.Sum(l => l.Quantity * l.Price);
if (total > 100_000)
return Results.BadRequest("Order exceeds credit limit");
// Mapping in the endpoint
var order = new Order
{
CustomerId = request.CustomerId,
Total = total,
Lines = request.Lines.Select(l => new OrderLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
Price = l.Price
}).ToList()
};
// Persistence in the endpoint
db.Orders.Add(order);
await db.SaveChangesAsync();
logger.LogInformation("Order {OrderId} created", order.Id);
return Results.Created($"/api/orders/{order.Id}", order);
});
ビジネスロジックをハンドラーに移し、エンドポイントは薄く保ちます。
app.MapPost("/api/orders", async (
CreateOrderRequest request,
IOrderHandler handler,
CancellationToken ct) =>
{
var result = await handler.CreateAsync(request, ct);
return result switch
{
{ IsSuccess: true } => Results.Created(
$"/api/orders/{result.Value.Id}", result.Value),
_ => Results.ValidationProblem(result.Errors)
};
});
オープン・クローズドの原則(OCP)
クラスは拡張に対して開かれ、変更に対して閉じられているべきです。既存の switch/if チェーンを編集するのではなく、新しい型を実装して振る舞いを追加します。
アンチパターン:型による switch
// WRONG -- adding a new discount type requires modifying this method
public decimal CalculateDiscount(Order order)
{
switch (order.DiscountType)
{
case "Percentage":
return order.Total * order.DiscountValue / 100;
case "FixedAmount":
return order.DiscountValue;
case "BuyOneGetOneFree":
return order.Lines
.Where(l => l.Quantity >= 2)
.Sum(l => l.Price);
default:
return 0;
}
}
修正:ストラテジーパターン
public interface IDiscountStrategy
{
decimal Calculate(Order order);
}
public sealed class PercentageDiscount(decimal percentage) : IDiscountStrategy
{
public decimal Calculate(Order order) =>
order.Total * percentage / 100;
}
public sealed class FixedAmountDiscount(decimal amount) : IDiscountStrategy
{
public decimal Calculate(Order order) =>
Math.Min(amount, order.Total);
}
// New discount type -- no existing code modified
public sealed class BuyOneGetOneFreeDiscount : IDiscountStrategy
{
public decimal Calculate(Order order) =>
order.Lines
.Where(l => l.Quantity >= 2)
.Sum(l => l.Price);
}
// Usage -- resolved via DI or factory
public sealed class OrderPricing(
IEnumerable<IDiscountStrategy> strategies)
{
public decimal ApplyBestDiscount(Order order) =>
strategies.Max(s => s.Calculate(order));
}
抽象クラスによる拡張
ストラテジー間で共通の振る舞いが多い場合は、抽象基底クラスを使用します。
public abstract class NotificationSender
{
public async Task SendAsync(Notification notification, CancellationToken ct)
{
// Shared behavior: validation and logging
ArgumentNullException.ThrowIfNull(notification);
await SendCoreAsync(notification, ct);
}
protected abstract Task SendCoreAsync(
Notification notification, CancellationToken ct);
}
public sealed class EmailNotificationSender(IEmailClient client)
: NotificationSender
{
protected override async Task SendCoreAsync(
Notification notification, CancellationToken ct)
{
await client.SendEmailAsync(
notification.Recipient, notification.Subject,
notification.Body, ct);
}
}
リスコフの置換原則(LSP)
サブタイプは、プログラムの正しさを損なうことなく、基底型と置き換えられなければなりません。サブクラスは親クラスの振る舞いに関する契約を守る必要があります。事前条件を強化してはならず、事後条件を弱めてはなりません。
アンチパターン:オーバーライド内で例外をスローする
public class FileStorage : IStorage
{
public virtual Stream OpenRead(string path) =>
File.OpenRead(path);
}
// WRONG -- ReadOnlyFileStorage violates the base contract by
// throwing on a method the base type supports
public class ReadOnlyFileStorage : FileStorage
{
public override Stream OpenRead(string path)
{
if (!File.Exists(path))
throw new InvalidOperationException(
"Cannot open files in read-only mode");
return base.OpenRead(path);
}
// Surprise: callers expecting FileStorage behavior get exceptions
}
アンチパターン:コレクションの共変性に関する落とし穴
// WRONG -- List<T> is not covariant; this compiles but causes runtime issues
IList<Animal> animals = new List<Dog>(); // Compile error (correctly)
// However, arrays ARE covariant in C# -- this compiles but throws at runtime:
Animal[] animals = new Dog[10];
animals[0] = new Cat(); // ArrayTypeMismatchException at runtime!
修正:共変インターフェイスを使用する
// IEnumerable<out T> and IReadOnlyList<out T> are covariant
IEnumerable<Animal> animals = new List<Dog>(); // Safe -- read-only
IReadOnlyList<Animal> readOnlyAnimals = new List<Dog>(); // Safe
// When you need mutability, keep the concrete type
List<Dog> dogs = [new Dog("Rex"), new Dog("Buddy")];
ProcessAnimals(dogs); // Pass to covariant parameter
void ProcessAnimals(IReadOnlyList<Animal> animals)
{
foreach (var animal in animals)
animal.Speak();
}
LSP 適合性チェックリスト
- 派生クラスは、基底クラスで宣言されていない新しい例外型をスローしません
- オーバーライドで事前条件を追加しません(例:基底クラスでは不要な null チェック)
- オーバーライドで事後条件を弱めません(例:基底クラスが null 以外を保証しているのに null を返す)
- 振る舞いに関する契約を維持します。基底型で
ICollection.Addが成功する場合、派生型でも成功しなければなりません
インターフェイス分離の原則(ISP)
クライアントに、使用しないメソッドへの依存を強制すべきではありません。幅広い「ヘッダー」インターフェイスよりも、狭く役割に特化したインターフェイスを優先してください。
アンチパターン:ヘッダーインターフェイス
// WRONG -- IWorker forces all implementations to support every capability
public interface IWorker
{
Task DoWorkAsync(CancellationToken ct);
void ClockIn();
void ClockOut();
Task<decimal> CalculatePayAsync();
void RequestTimeOff(DateRange range);
Task SubmitExpenseAsync(Expense expense);
}
// ContractWorker does not clock in/out or request time off
public class ContractWorker : IWorker
{
public Task DoWorkAsync(CancellationToken ct) => /* ... */;
public void ClockIn() => throw new NotSupportedException(); // ISP violation
public void ClockOut() => throw new NotSupportedException(); // ISP violation
public Task<decimal> CalculatePayAsync() => /* ... */;
public void RequestTimeOff(DateRange range) =>
throw new NotSupportedException(); // ISP violation
public Task SubmitExpenseAsync(Expense expense) =>
throw new NotSupportedException(); // ISP violation
}
修正:役割ごとのインターフェイス
public interface IWorkPerformer
{
Task DoWorkAsync(CancellationToken ct);
}
public interface ITimeTrackable
{
void ClockIn();
void ClockOut();
}
public interface IPayable
{
Task<decimal> CalculatePayAsync();
}
public interface ITimeOffEligible
{
void RequestTimeOff(DateRange range);
}
// FullTimeEmployee implements all applicable interfaces
public sealed class FullTimeEmployee :
IWorkPerformer, ITimeTrackable, IPayable, ITimeOffEligible
{
public Task DoWorkAsync(CancellationToken ct) => /* ... */;
public void ClockIn() { /* ... */ }
public void ClockOut() { /* ... */ }
public Task<decimal> CalculatePayAsync() => /* ... */;
public void RequestTimeOff(DateRange range) { /* ... */ }
}
// ContractWorker only implements what it needs
public sealed class ContractWorker : IWorkPerformer, IPayable
{
public Task DoWorkAsync(CancellationToken ct) => /* ... */;
public Task<decimal> CalculatePayAsync() => /* ... */;
}
実践的な .NET の ISP
.NET BCL は ISP の優れた例です。
| 幅広いインターフェイス | 分離された代替インターフェイス |
|---|---|
IList<T>(読み取り+書き込み) | IReadOnlyList<T>(読み取り専用) |
ICollection<T> | IReadOnlyCollection<T> |
IDictionary<K,V> | IReadOnlyDictionary<K,V> |
メソッドが実際に必要とする、最も狭いインターフェイスを受け取るようにします。
// WRONG -- requires IList<T> but only reads
public decimal CalculateTotal(IList<OrderLine> lines) =>
lines.Sum(l => l.Price * l.Quantity);
// RIGHT -- accepts IReadOnlyList<T> since it only reads
public decimal CalculateTotal(IReadOnlyList<OrderLine> lines) =>
lines.Sum(l => l.Price * l.Quantity);
// BEST for iteration only -- accepts IEnumerable<T>
public decimal CalculateTotal(IEnumerable<OrderLine> lines) =>
lines.Sum(l => l.Price * l.Quantity);
依存関係逆転の原則(DIP)
高水準モジュールは低水準モジュールに依存すべきではありません。どちらも抽象に依存すべきです。抽象は詳細に依存すべきではありません。
アンチパターン:直接依存
// WRONG -- high-level OrderProcessor depends directly on low-level SqlOrderRepository
public sealed class OrderProcessor
{
private readonly SqlOrderRepository _repository = new();
private readonly SmtpEmailSender _emailSender = new();
public async Task ProcessAsync(Order order)
{
await _repository.SaveAsync(order); // Tight coupling to SQL
await _emailSender.SendAsync(order.Email, // Tight coupling to SMTP
"Order processed", $"Order {order.Id}");
}
}
修正:抽象に依存する
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct = default);
Task<Order?> GetByIdAsync(string id, CancellationToken ct = default);
}
public interface INotificationService
{
Task NotifyAsync(string recipient, string subject,
string body, CancellationToken ct = default);
}
// High-level module depends on abstractions
public sealed class OrderProcessor(
IOrderRepository repository,
INotificationService notifier)
{
public async Task ProcessAsync(Order order, CancellationToken ct)
{
await repository.SaveAsync(order, ct);
await notifier.NotifyAsync(order.Email,
"Order processed", $"Order {order.Id}", ct);
}
}
// Low-level modules implement abstractions
public sealed class SqlOrderRepository(AppDbContext db) : IOrderRepository
{
public async Task SaveAsync(Order order, CancellationToken ct) =>
/* EF Core persistence */;
public async Task<Order?> GetByIdAsync(string id, CancellationToken ct) =>
await db.Orders.FindAsync([id], ct);
}
DI の登録
Microsoft.Extensions.DependencyInjection を使用して抽象を登録します。ライフタイム管理、キー付きサービス、デコレーションパターンについては [skill:dotnet-csharp-dependency-injection] を参照してください。
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<INotificationService, SmtpNotificationService>();
builder.Services.AddScoped<OrderProcessor>();
DIP の境界
DIP はあらゆる場所ではなく、モジュールの境界に適用します。
- DO インフラストラクチャ(データベース、メール、ファイルシステム、HTTP クライアント)を抽象化します
- DO 横断的関心事を抽象化します(ロギングはすでに
ILogger<T>を介して抽象化されています) - DO NOT 単純な値オブジェクト、DTO、内部実装の詳細を抽象化しません
- DO NOT すべてのクラスに
IFoo/Fooのペアを作りません。置き換えることで価値が生まれる場合(テスト、複数の実装、または変更の予測)に限って抽象化します
DRY(Don't Repeat Yourself)
知識はすべて、信頼できる単一の表現を持つべきです。ただし、DRY が対象とするのは知識の重複であり、コードの重複ではありません。
DRY を適用する場合
2 つのコードが同じ概念を表し、同時に変更する必要がある場合に DRY を適用します。
// WRONG -- tax rate duplicated across two services
public sealed class InvoiceService
{
public decimal CalculateTax(decimal amount) => amount * 0.08m;
}
public sealed class QuoteService
{
public decimal EstimateTax(decimal amount) => amount * 0.08m;
}
// RIGHT -- single source of truth
public static class TaxRates
{
public const decimal StandardRate = 0.08m;
}
3 回ルール
早まって抽象化しないでください。共通の抽象化を取り出す前に、同じパターンが 3 回現れるまで待ちます。
- 1 回目 -- インラインで記述します
- 2 回目 -- 重複を認識しますが、そのままにします(2 つの使用箇所は別々に発展する可能性があります)
- 3 回目 -- 共通のメソッド、クラス、またはユーティリティを取り出します
重複が許容される場合
コードが似ていても、必ずしも知識が重複しているとは限りません。
// These look similar but represent DIFFERENT business concepts
// They will evolve independently -- DO NOT merge them
public sealed class CustomerValidator
{
public bool IsValid(Customer customer) =>
!string.IsNullOrEmpty(customer.Name) &&
!string.IsNullOrEmpty(customer.Email);
}
public sealed class SupplierValidator
{
public bool IsValid(Supplier supplier) =>
!string.IsNullOrEmpty(supplier.Name) &&
!string.IsNullOrEmpty(supplier.ContactEmail);
}
重複が許容されるシナリオ:
- テストクラス間で似ているテストセットアップコード(テストを共有ヘルパーに依存させると、テストが壊れやすくなります)
- 異なる API バージョン向けの DTO(V1 と V2 は現在フィールドを共有していても、後で異なる可能性があります)
- 異なる環境向けの構成(開発環境と本番環境の構成は、現在は似ているだけかもしれません)
- レイヤー間のマッピングコード(マッパーを共有するためにレイヤーを結合すると、レイヤーを分ける目的が損なわれます)
共通動作の抽象化
抽出する場合は、継承よりもコンポジションを優先します。
// Prefer: composition via a shared utility
public static class StringValidation
{
public static bool IsNonEmpty(string? value) =>
!string.IsNullOrWhiteSpace(value);
}
// Over: inheritance via a base class
// (couples validators to a shared base, harder to test independently)
原則を組み合わせて適用する
判断ガイド
| 症状 | 該当する可能性のある違反 | 修正方法 |
|---|---|---|
| 「and」で説明されるクラス | SRP | 役割ごとにクラスを分割します |
| 機能追加のために既存コードを変更している | OCP | ストラテジー/プラグインパターンを使用します |
オーバーライド内の NotSupportedException | LSP | 階層を再設計するか、コンポジションを使用します |
| 空のインターフェイスメソッド/例外をスローするインターフェイスメソッド | ISP | 役割ごとのインターフェイスに分割します |
依存関係を作成するための new キーワード | DIP | コンストラクター経由で注入します |
| 複数のファイルにあるマジックナンバー/文字列 | DRY | 定数または構成として取り出します |
| コピー&ペーストされたコードブロック(3 つ以上) | DRY | 共通のメソッドを取り出します |
SRP 適合性テスト
各クラスについて、次の質問に答えてください。
- 一文テスト: 「and」や「or」を使わずに、そのクラスの目的を一文で説明できますか?
- 変更理由テスト: このクラスを変更する必要が生じる理由をすべて挙げてください。複数ある場合は、分割を検討します。
- 依存関係の数テスト: コンストラクターに 3~4 個を超える依存関係を渡していますか?パラメーター数が多い場合、複数の責務を担っている可能性があります。
エージェントでの注意点
- すべてのクラスで
IFoo/Fooのペアを作成しないでください。 DIP が求めるのは、モジュール境界(インフラストラクチャ、外部サービス)での抽象化であり、内部のクラスすべてに対する抽象化ではありません。不必要なインターフェースは、価値のない間接性を生み、コードベースを煩雑にします。 - 見た目が似ているコードを、異なる境界づけられたコンテキスト間で統合しないでください。 似ているバリデーターや DTO でも、異なるビジネス概念に対応するなら分けておくべきです。早まった DRY の適用は、独立して進化する概念間に結合を生みます。
- 無関係な型の動作を共有するために、継承を使わないでください。 共通の基底クラスを継承するより、コンポジション(共有サービスの注入や拡張メソッドの使用)を優先してください。継承は強い結合を生み、LSP 違反の可能性を高めます。
- 肥大化したコントローラーや神クラスは、SRP 違反です。 エンドポイントハンドラーを生成する際は、薄く保ち、バリデーション、ビジネスロジック、永続化を専用のサービスに委譲してください。各クラスに「一文」テストを適用してください。
- 型の判別子に対する switch 文は OCP 違反です。 ポリモーフィズム(ストラテジーパターン、インターフェースによるディスパッチ)に置き換え、新しい型を既存のコードを変更せずに追加できるようにしてください。
- C# の配列共変性は安全ではありません。
Animal[] animals = new Dog[10]はコンパイルできますが、Dog 以外の要素を追加すると、実行時にArrayTypeMismatchExceptionがスローされます。共変の読み取り専用アクセスには、IReadOnlyList<T>またはIEnumerable<T>を使用してください。 - メソッドが必要とする、最も限定的なインターフェース型を受け取るようにしてください。 反復には
IEnumerable<T>、インデックスによる読み取りにはIReadOnlyList<T>を使用し、変更が必要な場合に限りIList<T>を使用してください。これは ISP に沿った方法であり、メソッドの再利用性を高めます。
ナレッジソース
このスキルの SOLID および DRY に関するガイダンスは、以下の公開コンテンツに基づいています。
- Steve Smith (Ardalis) の SOLID 原則 -- ガード節パターン、OCP に準拠するための仕様パターン、プロジェクト境界で DIP を適用するクリーンアーキテクチャのレイヤリングなど、.NET で SOLID を実践的に適用する方法です。ソース:https://ardalis.com/
- Jimmy Bogard のドメイン駆動設計パターン -- 境界づけられたコンテキストごとに集約ルートを 1 つとする集約設計への SRP の適用や、既存のハンドラーを変更せずに新しいハンドラーを追加できるドメインイベント処理への OCP の適用など、リッチドメインモデルに関するガイダンスです。注:MediatR は商用利用では有償です。可能な場合は、組み込みの仕組みを使ってパターンを適用してください。ソース:https://www.jimmybogard.com/
注: このスキルは、公開されているガイダンスを適用したものです。記載されている情報源を代表するものでも、その立場を代弁するものでもありません。
参考資料
クレジット
Aaronontheweb/dotnet-skills を元に改変(MIT ライセンス)。