単一責任の原則による堅牢なソフトウェア設計とリファクタリング手法

単一責任の原則の核心

単一責任の原則(Single Responsibility Principle, SRP)は、SOLID設計原則の基礎をなす概念です。この原則が求めるのは、クラスやモジュールが「変更をきっかけとする理由を一つだけ持つこと」です。もしあるコンポーネントが複数の異なる目的を担っている場合、それらの目的が同時に、または独立して変化することを想定すると、コードの結合度は高まり、保守コストは指数関数的に増大します。理想的な設計では、それぞれの責任を明確に区切り、独立した単位として分離します。

問題事例:多重責任を抱えたデータ処理クラス

以下は、ファイルストリームから取引データを取得し、パース、バリデーション、ログ出力、データベース保存までを単一のメソッドで実行するクラスです。一見簡潔に見えても、機能の追加や変更が容易にコードを複雑にする典型例であり、様々な変更理由によって頻繁に修正対象となることが予想されます。

public class OrderBatchProcessor
{
    private const decimal BatchUnitSize = 100000m;

    public void Execute(System.IO.Stream inputSource)
    {
        var rawLines = new List<string>();
        using (var streamReader = new System.IO.StreamReader(inputSource))
        {
            string currentLine;
            while ((currentLine = streamReader.ReadLine()) != null)
            {
                rawLines.Add(currentLine);
            }
        }

        var parsedOrders = new List<OrderEntity>();
        int lineNumber = 1;
        foreach (var line in rawLines)
        {
            var columns = line.Split(',');
            if (columns.Length != 3 || columns[0].Length != 6)
            {
                System.Console.WriteLine($"[WARN] Line {lineNumber}: Invalid format.");
                lineNumber++;
                continue;
            }

            if (!int.TryParse(columns[1], out int volume) || !decimal.TryParse(columns[2], out decimal rate))
            {
                System.Console.WriteLine($"[WARN] Line {lineNumber}: Numeric conversion failed.");
                lineNumber++;
                continue;
            }

            parsedOrders.Add(new OrderEntity
            {
                BaseCurrency = columns[0].Substring(0, 3),
                QuoteCurrency = columns[0].Substring(3, 3),
                Volume = volume / BatchUnitSize,
                Rate = rate
            });
            lineNumber++;
        }

        using (var dbConn = new System.Data.SqlClient.SqlConnection("Server=localhost;Database=TradingDB;Trusted_Connection=True"))
        {
            dbConn.Open();
            using (var tx = dbConn.BeginTransaction())
            {
                foreach (var item in parsedOrders)
                {
                    var cmd = dbConn.CreateCommand();
                    cmd.Transaction = tx;
                    cmd.CommandType = System.Data.CommandType.StoredProcedure;
                    cmd.CommandText = "usp_SaveOrder";
                    cmd.Parameters.AddWithValue("@baseCur", item.BaseCurrency);
                    cmd.Parameters.AddWithValue("@quoteCur", item.QuoteCurrency);
                    cmd.Parameters.AddWithValue("@vol", item.Volume);
                    cmd.Parameters.AddWithValue("@rate", item.Rate);
                    cmd.ExecuteNonQuery();
                }
                tx.Commit();
            }
        }
        System.Console.WriteLine($"[INFO] Processed {parsedOrders.Count} records.");
    }
}

上記の実装は以下の複数の責任が混在しています。

  • ストリームからのバイト列・文字列の読み取り
  • CSV形式のテキスト解析とデータ型変換
  • 入力値の整合性チェックとコンソール出力
  • リレーショナルデータベースへのトランザクション処理

データソースをAPIに変更する場合、バリデーションルールが改訂される場合、ログ出力先がファイルや中央集約型サービスに変わる場合、あるいはNoSQLへの移行が必要になった場合など、いずれのシナリオでもこのクラス全体が修正対象となってしまいます。

段階的なリファクタリング:処理の分離

SRPを適用する第一段階は、巨大なメソッドを論理的な単位に分割することです。各ステップの入出力を明確にし、連鎖的に実行されるように設計します。

public void Execute(System.IO.Stream inputSource)
{
    var rawData = LoadRawLines(inputSource);
    var validOrders = ConvertToEntities(rawData);
    PersistToStorage(validOrders);
}

private IEnumerable<string> LoadRawLines(System.IO.Stream source)
{
    var buffer = new List<string>();
    using (var reader = new System.IO.StreamReader(source))
    {
        while (!reader.EndOfStream)
            buffer.Add(reader.ReadLine());
    }
    return buffer;
}

このように分離することで、各処理の単体テストが可能になり、依存関係が明確化されます。さらに、バリデーションやマッピング処理も独立したメソッドとして抽出し、ログ出力は統一されたヘルパーメソッドに委譲します。メソッドの戻り値を次の処理の引数とするパイプライン構造を作ることで、データの不可変性を意識しやすくし、誤った書き込みを防止します。

抽象化と依存性注入の導入

メソッドレベルの分離に加え、クラスレベルでの抽象化を行います。データ取得、変換、永続化の3つの高次責務をそれぞれインターフェースとして定義し、実装を外部から注入する形式に置き換えます。

public class OrderPipeline
{
    private readonly ISourceLoader _loader;
    private readonly IRecordTransformer _transformer;
    private readonly IRepository _repository;

    public OrderPipeline(ISourceLoader loader, IRecordTransformer transformer, IRepository repository)
    {
        _loader = loader;
        _transformer = transformer;
        _repository = repository;
    }

    public void Run()
    {
        var lines = _loader.Fetch();
        var entities = _transformer.Map(lines);
        _repository.Save(entities);
    }
}

OrderPipelineクラスは具体的なI/O処理やDB接続を一切意識しなくなります。このクラスの変更理由が「パイプライン処理のフロー変更に限られる」状態に収束します。各インターフェースの実装クラスは、必要なコンテキスト(例:ストリームインスタンス、DB接続文字列)をコンストラクタを通じて受け取るように設計します。これにより、インターフェース自体は外部ライブラリに依存しないまま、実装側のみが必要な依存关系を保有する形になります。

デコレータパターンの適用可能性

単一責任を維持しつつ、既存の機能に横断的な関心事(ロギング、認証、キャッシュ、非同期化など)を付加する際に、デコレータパターンは非常に有効です。このパターンでは、対象となるインターフェースを実装したクラスが、同じインターフェースを持つインスタンスをラップし、処理の前後に追加の動作を実行します。

public interface IExecutionUnit
{
    void Process();
}

public class TimingWrapper : IExecutionUnit
{
    private readonly IExecutionUnit _inner;
    private readonly System.Diagnostics.Stopwatch _timer;

    public TimingWrapper(IExecutionUnit inner)
    {
        _inner = inner;
        _timer = new System.Diagnostics.Stopwatch();
    }

    public void Process()
    {
        _timer.Start();
        _inner.Process();
        _timer.Stop();
        System.Console.WriteLine($"Elapsed: {_timer.ElapsedMilliseconds}ms");
    }
}

この構造により、本体ロジックには計測コードが混入せず、クライアント側もラップされたインスタンスをIExecutionUnitとして単に扱うだけで済みます。同様のアプローチは、遅延初期化(Lazy Initialization)、実行条件による分岐(Predicate Decorator)、複数コンポーネントの合成(Composite Pattern)などにも拡張可能です。各デコレータは自身の責任範囲を厳密に保ちつつ、ラップ対象の振る舞いを透明に拡張します。

条件分岐の排除とストラテジーパターン

コード内のswitchif-elseチェーンは、新しいケースの追加時に必ず本体クラスの修正を伴います。これらはオープン・クローズドの原則に反し、SRPの観点からも条件分岐と実際の処理ロジックが混在している状態です。

public class PaymentGateway
{
    private readonly Dictionary<PaymentMethod, ISettlementStrategy> _strategies;

    public PaymentGateway()
    {
        _strategies = new Dictionary<PaymentMethod, ISettlementStrategy>
        {
            { PaymentMethod.CreditCard, new CardProcessor() },
            { PaymentMethod.EWallet, new EWalletProcessor() }
        };
    }

    public void Checkout(PaymentMethod method, decimal amount)
    {
        if (_strategies.TryGetValue(method, out var handler))
            handler.Settle(amount);
    }
}

処理の具体像をISettlementStrategyの実装クラスに委任することで、新しいケースの追加は対応クラスの実装とマッピング登録だけで完結します。本体クラスは変更されることなく、各戦略クラスは独立した責務範囲内で動作します。このように抽象化とパターンの適用を組み合わせることで、コードベースは機能の追加や外部環境の変化に対して柔軟に対応可能な構造へと進化します。

タグ: C# SOLID 単一責任の原則 リファクタリング デコレータパターン

7月24日 04:45 投稿