SOLID、DRY、KISS、YAGNI——ソフトウェア設計の原則を学ぶと「全部守らないとダメなのか?」という疑問にぶつかる。
正直に書くと、私は小さなWinFormsツール(1画面、1機能)にDIコンテナとインターフェース分離を入れて、「なんでこんなに面倒なんだ」と思ったことがある。3ファイルで済むはずのツールが15ファイルに膨れ上がった。
設計原則は「常に全部適用する」ものではない。プロジェクトの規模によって、どこまで適用するかの線引きが必要。この記事では、C#プロジェクトを3段階の規模に分けて、各設計原則の適用基準を整理する。
まず4つの原則をおさらい
知っている人は読み飛ばしてOK。
| 原則 | 意味 | 一言で言うと |
|---|---|---|
| SOLID | 5つの設計原則(SRP, OCP, LSP, ISP, DIP) | クラス設計のベストプラクティス |
| DRY | Don't Repeat Yourself(同じことを繰り返すな) | 重複コードを排除する |
| KISS | Keep It Simple, Stupid(シンプルに保て) | 不要な複雑さを入れない |
| YAGNI | You Aren't Gonna Need It(それ、今いらない) | 将来の拡張を先回りしない |
ここで気づくと思うが、SOLIDとKISS/YAGNIは方向性が逆のことがある。SOLIDを忠実に守ると抽象化レイヤーが増えてコードは複雑になる。KISSは「シンプルに保て」と言う。この矛盾をどう解決するかが、規模別の線引きのポイントになる。
プロジェクト規模の3段階
C#プロジェクトを規模で分けると、だいたいこの3つに収まる。
| 規模 | 目安 | 例 |
|---|---|---|
| S(小規模) | 1〜3画面、コード1,000行以下 | 個人用ツール、バッチ処理、PoC |
| M(中規模) | 5〜20画面、コード5,000〜30,000行 | 部門ツール、社内業務アプリ |
| L(大規模) | 20画面以上、10万行超 | 全社システム、製品、OSS |
ここからが本題。各原則を規模別に「どこまでやるか」を整理する。
規模別 適用マトリクス
先に全体像を出す。
| 原則 | S(小規模) | M(中規模) | L(大規模) |
|---|---|---|---|
| SRP(単一責任) | △ ゆるく | ○ クラス単位 | ◎ メソッド単位 |
| OCP(開放閉鎖) | ✕ 不要 | △ 変更頻度高い箇所のみ | ◎ 全面適用 |
| LSP(リスコフ置換) | ✕ 継承自体が少ない | ○ 意識する | ◎ テストで検証 |
| ISP(インターフェース分離) | ✕ 不要 | △ 大きいI/Fのみ分離 | ◎ 細かく分離 |
| DIP(依存性逆転) | ✕ 直接依存でOK | △ 外部依存のみ | ◎ DIコンテナ使用 |
| DRY | △ 3回以上なら | ○ 2回以上なら | ◎ 1回でも共通化検討 |
| KISS | ◎ 最優先 | ○ SRPとバランス | △ 構造が優先される場面も |
| YAGNI | ◎ 最優先 | ○ 基本守る | △ 拡張計画があるなら例外 |
この表の読み方:小規模ではKISS/YAGNIが最優先、大規模ではSOLIDが最優先。中規模はバランスを取る。
以下、特に判断が難しいポイントをコード例つきで解説する。
SRP(単一責任原則)— 分けすぎると逆に辛い
SRPは「クラスを変更する理由は1つだけにせよ」という原則。
S(小規模): ゆるくていい
個人用ツールで「データ取得 + 表示 + 保存」を1クラスに書くのは、SRP的にはアウト。でも3ファイルで完結するツールを10ファイルに分割するメリットはない。
// S規模: 1クラスにまとめてOK
public class DataExporter
{
public List<Record> LoadFromDatabase() { /* ... */ }
public void ExportToCsv(List<Record> records) { /* ... */ }
public void ShowResult(int count) { /* ... */ }
}
「将来分割が必要になったら、そのときやる」でいい。これがYAGNI。
M(中規模): クラス単位で分ける
部門ツールになると、複数人が触る可能性がある。ここからはSRPを意識する。
// M規模: 責務ごとにクラスを分離
public class RecordRepository
{
public List<Record> LoadAll() { /* ... */ }
}
public class CsvExporter
{
public void Export(List<Record> records, string path) { /* ... */ }
}
public class ExportViewModel
{
private readonly RecordRepository _repo;
private readonly CsvExporter _exporter;
// ...
}
ただし、1メソッド1クラスまで細かくする必要はない。「変更理由が明らかに異なるもの」を分ければ十分。
L(大規模): メソッドレベルで意識する
全社システムでは、1つのクラスが肥大化すると保守コストが跳ね上がる。メソッドレベルで「この処理は本当にこのクラスの責務か?」を問う。
DIP(依存性逆転)— DIコンテナが必要になるライン
DIPは「上位モジュールは下位モジュールに依存すべきでない」という原則。C#ではインターフェースとDIコンテナで実現する。
S(小規模): 直接newでいい
// S規模: インターフェースもDIも不要
public class ReportGenerator
{
public void Generate()
{
var db = new SqliteConnection("Data Source=app.db");
var records = db.Query<Record>("SELECT * FROM records");
File.WriteAllText("report.csv", ToCsv(records));
}
}
これを「テスタビリティのためにインターフェースを挟む」のは、テストを書く予定がない小規模ツールでは過剰。
M(中規模): 外部依存だけ抽象化する
データベースや外部APIなど、環境によって差し替えたいものだけインターフェースを挟む。
// M規模: DB接続だけ抽象化
public interface IRecordStore
{
List<Record> GetAll();
}
public class SqliteRecordStore : IRecordStore { /* ... */ }
public class ReportGenerator
{
private readonly IRecordStore _store;
public ReportGenerator(IRecordStore store)
{
_store = store;
}
}
DIコンテナは使わず、エントリポイントで手動で注入するレベルでOK。
L(大規模): DIコンテナ + 全面的なインターフェース
// L規模: Microsoft.Extensions.DependencyInjection を使用
services.AddScoped<IRecordStore, SqliteRecordStore>();
services.AddScoped<IReportFormatter, CsvReportFormatter>();
services.AddScoped<IReportGenerator, ReportGenerator>();
大規模では依存関係の管理自体が複雑になるため、DIコンテナの導入コストは十分ペイする。
DRY — 「3回ルール」のすすめ
DRYは「コードの重複を排除せよ」だが、小規模で過剰に適用すると逆効果になることがある。
S規模での失敗例
2箇所で似たようなバリデーションロジックがあったので共通メソッドに切り出した。すると、片方だけ仕様変更が入ったときに、共通メソッドにif分岐が増えて読みにくくなった。
私が使っている基準: 3回ルール
| 重複回数 | 対応 |
|---|---|
| 1回目 | そのまま書く |
| 2回目 | コメントで「ここにも同じロジックあり」と書く |
| 3回目 | 共通化する |
3回同じコードを書いたら、それはパターンが確定している。2回目の時点ではまだ「たまたま似ているだけ」の可能性がある。
M/L規模では2回目から共通化を検討していい。チームで開発していると、重複に気づかないまま増殖するリスクが高いから。
KISS vs SOLID — 矛盾したとき、どちらを優先するか
ここが一番聞かれる質問だと思う。答えは 「規模による」 。
小規模 → KISS優先
SOLIDを守るためにコードが複雑になるなら、小規模ではKISSを優先する。
// SOLID的には正しいが、小規模ツールには過剰
public interface ILogger { void Log(string message); }
public interface IValidator { bool Validate(Input input); }
public interface IProcessor { Result Process(Input input); }
public interface INotifier { void Notify(Result result); }
// KISS的に書くと
public class SimpleProcessor
{
public void Run(Input input)
{
if (!IsValid(input)) return;
var result = DoProcess(input);
Console.WriteLine($"Done: {result}");
}
}
大規模 → SOLID優先
大規模では「今シンプルに書く」より「将来の変更に耐えられる構造」の方が重要。初期の複雑さは、長期的な保守コストの削減で回収できる。
中規模 → 判断基準を持つ
中規模が一番悩ましい。私が使っている判断基準はこれ:
「このコードを、半年後の自分(または別の人)が修正するとき、今の構造で困らないか?」
答えがYesなら今のままでいい。Noなら構造を見直す。
実践的な判断フロー
迷ったときのフローチャート。
1. このプロジェクトの規模は?
├─ S(小規模) → KISS/YAGNI優先。SOLIDは意識しなくていい
├─ M(中規模) → SRP + 外部依存のDIP。あとはケースバイケース
└─ L(大規模) → SOLID全面適用。DIコンテナ導入
2. 「将来のために」抽象化しようとしていないか?
├─ Yes → YAGNI違反。今必要な分だけにする
└─ No → そのまま進める
3. コードが複雑になってきた。分割するか?
├─ 複数人が触る → 分割する(SRP)
└─ 自分だけ → 読みやすさ(KISS)を優先
よくある質問
Q: 小規模で作り始めたものが中規模に成長したらどうする?
リファクタリングのタイミングは「変更が辛くなったとき」。予防的にリファクタリングするのではなく、痛みを感じてから構造を変える方が的確な判断ができる。ただし、テストがないとリファクタリングは怖い。テストだけは規模に関係なく書いておくと後で助かる。
Q: チーム開発では、全員が同じ基準を持てる?
ADR(Architecture Decision Record)やコーディング規約に「このプロジェクトではSOLIDのうちSRPとDIPだけ適用する」と明文化する。基準が曖昧だと、メンバーごとに「ここは抽象化すべき」「不要でしょ」の判断がバラつく。
Q: テストのためだけにインターフェースを入れるのはあり?
M規模以上なら、テストのためのインターフェース導入は正当なコスト。S規模では、テスト自体を省略するか、インターフェースなしでテストできる構造(publicメソッドだけテスト)を選ぶ。
まとめ
設計原則は「守るか守らないか」の二択ではなく、「どこまで適用するか」のグラデーション。
| 規模 | 最優先 | 意識する | 後回しでいい |
|---|---|---|---|
| S | KISS, YAGNI | DRY(3回ルール) | SOLID全般 |
| M | SRP, DRY | DIP(外部依存のみ) | OCP, ISP |
| L | SOLID全般 | DRY(厳格に) | KISS(構造優先の場面あり) |
原則を全部守ろうとして動けなくなるより、規模に合った基準で判断して、手を動かし続ける方が良いコードになる。コードは書いて、使って、困って、直して——のサイクルで良くなっていくものだから。
📝 この記事は Zenn で最初に公開されました。