LoginSignup
1
1

iOSアプリ開発でSOLID原則のインターフェース分離を意識してクラス設計する【Swift】

Posted at

SOLID原則とは

SOLID原則は、オブジェクト指向のソフトウェア設計で使用される、重要な5つの原則をまとめたものです。

書籍「Clean Architecture 達人に学ぶソフトウェアの構造と設計」では、SOLID原則は「設計の原則」という見出しで紹介されています。

本記事では、SOLID のうち「インターフェース分離の原則(ISP: Interface Segregation Principle)」に焦点を当て、iOSアプリ開発のクラス設計にどのように適用できるかを実例とともにご紹介できればと思います。

インターフェース分離の原則とは

インターフェース分離の原則は、SOLIDの「I」に該当する原則です。

この原則の意味を端的にいうと、
自身が依存している型の、必要ではない機能に依存してはいけない」というものだと理解しています。

「必要ではない機能に依存している状況」とは、具体的にどういう場合でしょうか。

以下で具体例とともに考えていきます。

iOSアプリ開発におけるインターフェース分離

本記事では、「メモとタスクを管理するアプリ」を開発するケースで考えてみます。

このアプリのビジネスロジックを実装する場合、
簡単な構成だと以下のようなレイヤー図になるかと思います。


レイヤードアーキテクチャなどでよく見られる構成ですね。
この構成について、以下で簡単に説明します。

  • 「メモ」と「タスク」は Database に保存されている
    • Database は、RealmSwiftCoreData などのフレームワークに依存する
  • UseCaseRepository を介してデータベースからデータを取得して使用する
    • Repository は、Database から取得したデータ型を Entity に変換する

ドメインロジック (UseCase) が RealmSwift などのフレームワークに依存しないように、Repository という変換層を経由してデータを取得するような構成です。

実際にコードで書いてみると、まず Repository の実装は以下の通りです。



// 📦 Interface モジュール
//  (protocol 定義だけをまとめたモジュールです)

/// Repository が準拠すべきプロトコル
public protocol Repository {
    /// Databaseからすべての「メモ」を取得する
    func fetchAllMemo() -> [MemoEntity]
    /// Databaseからすべての「タスク」を取得する
    func fetchAllTask() -> [TaskEntity]
}

// 📦 Domain モジュール

import Interface

/// Database から取得したデータを Entity に変換して返却するクラス
public class DatabaseRepository: Repository {

    public func fetchAllMemo() -> [MemoEntity] {
        let objects = database.fetchAllMemo()
        // Entity へ変換して return する
    }

    public func fetchAllTask() -> [TaskEntity] {
        let objects = database.fetchAllTask()
        // Entity へ変換して return する
    }
}

続いて、UseCaseRepository からデータを取得する実装は以下の通りです。
(MemoUseCase の例だけですが、TaskUseCase も同じようなコードになります)


// 📦 Domain モジュール

import Interface

/// メモに関するビジネスロジックを担うクラス
public class MemoUseCase {

    // 本クラスは Repository に依存する
    private let repository: Repository

    // Repository のインスタンスはイニシャライザで DI される
    public init(repository: Repository) {
        self.repository = repository
    }

    /// 最新のメモを取得
    public func fetchMostRecentlyCreatedMemo() -> MemoEntity? {
        // Repository からすべてのメモを取得し、任意のメモだけを返却する
        repository.fetchAllMemo()
            .sorted {...}
            .first
    }
}

UseCaseが巨大なリポジトリに依存することの問題

ここで、ある問題が発生します。

本来、MemoUseCase は「メモ」に関するビジネスロジックだけを担当する想定で作成されたクラスです。
ところが、Repository のメソッドを呼び出せば「タスク」も取得することができるため、
以下のようなコードも書けてしまいます。

/// メモに関するビジネスロジックを担うクラス
public class MemoUseCase {

    public func firstTask() -> TaskEntity? {
        // ❌ 「タスク」に関する処理も実装できてしまう
        repository.fetchAllTask()
            .first
    }
}

このような構造の問題点は以下の通りです。

  • UseCase が担当するビジネスロジックとは本来関係のない Repository のメソッドに意図せず依存してしまう
    • 不要な依存メソッドが変更された場合でも、ビジネスロジックに影響が及ぶ恐れがある
    • 別の開発担当者に、UseCase のクラス設計の意図から逸れた機能を拡張される恐れがある

下図の「❌」は、UseCase が意図せず依存してしまっている Repository のメソッドです。




今回は小規模な例なのでそこまで問題にならないかもしれませんが、
このプロジェクトが成長して巨大なプロジェクトになると、
Repository が変更された時に影響を受けるクラスの数が膨大になってしまいます。

このような構造を禁止するための原則が「インターフェース分離の原則」です。

つまり、上記の例は「インターフェース分離の原則」に違反しているとみなせます。

Repository のインターフェースを分離

「インターフェース分離の原則」を適用して、Repository のインターフェースを分割します。

まず、UseCase が本当に必要な Repository のメソッドだけをまとめたプロトコルを以下のように定義します。


// 📦 Interface モジュール

/// MemoUseCase に必要なメソッドだけをまとめたプロトコル
public protocol MemoRepository {
    func fetchAllMemo() -> [MemoEntity]
}

/// TaskUseCase に必要なメソッドだけをまとめたプロトコル
public protocol TaskRepository {
    func fetchAllTask() -> [TaskEntity]
}

そして、Repository を以下の通り定義します。

// 📦 Interface モジュール

/// Repository は、MemoRepository と TaskRepository に準拠した型
public typealias Repository = MemoRepository & TaskRepository

// 📦 Domain モジュール

public class DatabaseRepository: Repository {
    // 先ほどと同じように Repository を実装

    public func fetchAllMemo() -> [MemoEntity] {
        // ...
    }

    public func fetchAllTask() -> [TaskEntity] {
        // ...
    }
}

Repository は、MemoRepositoryTaskRepository を組み合わせた型です。

typealias で複数のプロトコルをまとめるアプローチは、標準型の Codable でも使用されていますね。

// https://developer.apple.com/documentation/swift/codable
public typealias Codable = Decodable & Encodable

最後に、UseCase は以下の通り必要な Repository の機能にのみ依存します。

// 📦 Domain モジュール

import Interface

public class MemoUseCase {

    // 🟢 Repository の必要な機能にのみ依存
    private let repository: MemoRepository

    public init(repository: MemoRepository) {
        self.repository = repository
    }

    public func fetchMostRecentlyCreatedMemo() -> MemoEntity? {
        repository.fetchAllMemo()
            .sorted {...}
            .first
    }
}

最終的な依存関係グラフ図は以下のようになりました。

まとめ

今回は、Repository の実装クラス DatabaseRepository 自体は大きく作りました。

しかし、アプリが成長するにつれて Repository の実装クラス自体を分割した方が良いという判断になるかもしれません。

そのような場合でも、UseCase が依存する Repository のプロトコルが分割されていれば、UseCase の実装自体は変更せずに対応できます。

プロジェクトの経過時間や成熟度に合わせて柔軟に実装を変更することができるようになることも、インターフェース分離の原則のメリットだと感じました。

参考文献

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1