every Tech Blog

株式会社エブリーのTech Blogです。

ヘルシカiOSアプリのアーキテクチャについて

ヘルシカiOSアプリのアーキテクチャについて

この記事は every Tech Blog Advent Calendar 2025 の 11 日目の記事です。

はじめに

こんにちは。開発部でiOSエンジニアをしている野口です。

ヘルシカiOSアプリの開発を担当しています。今回はヘルシカiOSアプリの設計で採用しているクリーンアーキテクチャについてご紹介します。

この記事では、以下の内容を解説します。

  • クリーンアーキテクチャの各層(Feature/Usecase/Repository/Infra/Model)の役割
  • SPMを用いたマルチモジュール構成と依存関係の管理方法
  • 実際のコード例を通じた実装パターンの紹介
  • DIコンテナの設計と実装
  • AIによるコーディングを意識した設計

iOSアプリのアーキテクチャ設計に興味がある方や、クリーンアーキテクチャの導入を検討されている方の参考になれば幸いです。

アーキテクチャについて

ヘルシカiOSアプリではクリーンアーキテクチャを採用しています。 構成は以下のようになっています。

各層の役割

Feature(Presentation)層

Feature層は一般的なMVVMの構成でViewで画面を構築し、ViewModelで状態を管理します。

UsecaseとModelに依存しています。

Usecase層

Usecase層はビジネスロジックを定義する層です。 Feature層のViewModelから呼び出され、ビジネスロジックを記載します。

RepositoryとModelに依存しています。

Repository層

Repository層は、Infra層のDataStoreを呼び出してデータの取得・保存を行う層です。また、メモリ上でデータを一時的にキャッシュする処理も担当します。

Modelに依存しています。

Infra層

Infra層は、外部APIやLocalStorage、KeyChainなど外部リソースへのアクセスを担当する層です。

DataStoreの具体的な実装はInfra層に配置し、そのインターフェース(Protocol)はRepository層で定義しています。これにより依存性逆転の原則を適用し、Repository層がInfra層の実装詳細に依存しない設計を実現しています。テスト時のモック差し替えが容易になり、外部サービスの変更影響を局所化できます。

RepositoryとModelに依存しています。

Model層

Model層はドメインモデルを定義する層で、どの層からも参照されます。

クリーンアーキテクチャでは、Data層にEntityを、Domain層にModelを定義し、両者を分離する設計もあります。しかし本プロジェクトでは、バックエンドAPIがドメインモデルに沿った構造でレスポンスを返すため、EntityとModelを分けずに共通のモデルを使用しています。これにより、同じような構造のモデルを重複して定義する必要がなくなり、シンプルな設計になります。

画面都合で別モデルを使用したい場合は、Feature層にModelを定義しています。

SPMを用いたマルチモジュール構成

ヘルシカiOSアプリではSPM(Swift Package Manager)を用いたモジュール構成を採用しています。

SampleApp/
├── App/                    # アプリケーションのルートディレクトリ
│   ├── Dependency.swift    # 依存関係の注入
│   └── ViewModelProvider.swift # ViewModelの注入
├── Packages/               # ローカルパッケージ群
│   ├── Feature/           
│   ├── Usecase/           
│   ├── Repository/        
│   ├── Infra/             
│   └── Model/             

パッケージファイルで依存関係を明示する

パッケージファイルでは依存するパッケージを指定します。

依存関係をパッケージファイルで明示的に定義することで、ViewModelがRepositoryを直接参照するといったアーキテクチャ違反を防ぎ、依存関係を適切に管理できます。

具体的には以下のようなパッケージファイルを作成します。dependenciesに依存するパッケージを指定します。

なお、本記事で紹介するコードはStrict Concurrency Checkingには対応していません。Swift 6への移行は今後の課題として取り組む予定です。

// Packages/Feature/Package.swift

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "Feature",
    products: [
        .library(name: "Feature", targets: ["Feature"])
    ],
    dependencies: [
        .package(name: "Usecase", path: "../Usecase"),
        .package(name: "Model", path: "../Model")
    ]
)
// Packages/Usecase/Package.swift

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "Usecase",
    products: [
        .library(name: "Usecase", targets: ["Usecase"])
    ],
    dependencies: [
        .package(name: "Repository", path: "../Repository"),
        .package(name: "Model", path: "../Model")
    ]
)
// Packages/Repository/Package.swift

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "Repository",
    products: [
        .library(name: "Repository", targets: ["Repository"])
    ],
    dependencies: [
        .package(name: "Model", path: "../Model")
    ]
)

前述の通り、Repository層はModel層にのみ依存しています。

// Packages/Infra/Package.swift

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "Infra",
    products: [
        .library(name: "Infra", targets: ["Infra"])
    ],
    dependencies: [
        .package(name: "Repository", path: "../Repository"),
        .package(name: "Model", path: "../Model")
    ]
)

誤って意図しない依存関係のパッケージを使用した際には、このファイルに依存関係が追加されるのでレビューの際に変更に気づくことが容易にできます。依存関係を明確に定義することでAIが安全にコードを生成できるようになります。

実装例

ユーザー情報の取得を例にして実装例を紹介します。

Feature

ViewModelはObservableObjectに準拠し@Publishedで状態管理します。UseCaseをDIで注入し、UseCaseの結果をViewModelの状態にバインドします。

protocol UserViewModel: ObservableObject {
    var user: User? { get }
    var isLoading: Bool { get }
    func onAppear()
}

@MainActor
public final class UserViewModelImpl: UserViewModel {
    @Published private(set) var user: User?
    @Published private(set) var isLoading: Bool = false

    private let fetchUserUseCase: FetchUserUseCase

    public init(fetchUserUseCase: FetchUserUseCase) {
        self.fetchUserUseCase = fetchUserUseCase
    }

    func onAppear() {
        Task {
            isLoading = true
            let result = await fetchUserUseCase.execute()
            switch result {
            case .success(let user):
                self.user = user
            case .failure(let error):
                print(error)
            }
            isLoading = false
        }
    }
}

Usecase

UseCaseはビジネスロジックを実装し、Repositoryを呼び出してデータを取得します。

この例ではシンプルにRepositoryを呼び出すだけですが、実際のプロダクトコードでは複数のRepositoryからデータを取得して結合したり、バリデーション処理を行うなど、より複雑なビジネスロジックを実装します。

public protocol FetchUserUseCase {
    func execute() async -> Result<User, UseCaseError>
}

public final class FetchUserUseCaseImpl: FetchUserUseCase {
    private let userRepository: UserRepository

    public init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }

    public func execute() async -> Result<User, UseCaseError> {
        let result = await userRepository.fetchUser()
        switch result {
        case .success(let user):
            return .success(user)
        case .failure(let error):
            return .failure(.repositoryError(error.localizedDescription))
        }
    }
}

Repository

RepositoryはDataSourceを呼び出しデータ取得・キャッシュ管理を行います。DataSource Protocolは Repository層で定義し、依存性逆転を実現します。

// Packages/Repository/Sources/Repository/DataSource/UserDataSource.swift
public protocol UserDataSource {
    func fetchUser() async -> Result<User, RepositoryError>
}
// Packages/Repository/Sources/Repository/UserRepository.swift
public protocol UserRepository {
    func fetchUser() async -> Result<User, RepositoryError>
}

public final class UserRepositoryImpl: UserRepository {
    private let userDataSource: UserDataSource
    private var cachedUser: User? // キャッシュ

    public init(userDataSource: UserDataSource) {
        self.userDataSource = userDataSource
    }

    public func fetchUser() async -> Result<User, RepositoryError> {
        let result = await userDataSource.fetchUser()
        if case .success(let user) = result {
            cachedUser = user
        }
        return result
    }
}

Infra

Repository層のProtocolを実装し、APIClientを使用してデータ取得します。

public final class UserDataSourceImpl: UserDataSource {
    private let apiClient: APIClient

    public init(apiClient: APIClient) {
        self.apiClient = apiClient
    }

    public func fetchUser() async -> Result<User, RepositoryError> {
        let result = await apiClient.request(FetchUserRequest())
        switch result {
        case .success(let response):
            return .success(response.user)
        case .failure(let error):
            return .failure(.apiError(error.localizedDescription))
        }
    }
}

Model

Modelはシンプルなstructでプロパティと計算プロパティを定義します。DecodableはInfra層で拡張することで、Model層の独立性を保ちます。

public struct User {
    public let name: String
    public let weight: Float
    public let height: Float
}
// Packages/Infra/Sources/Infra/Decodable/User+Decodable.swift
extension User: Decodable {
    enum CodingKeys: String, CodingKey {
        case name
        case weight
        case height
    }
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        weight = try container.decode(Float.self, forKey: .weight)
        height = try container.decode(Float.self, forKey: .height)

        self.init(
            name: name,
            weight: weight,
            height: height
        )
    }
}

DIについて

DIはUIKitとSwiftUIが混在しているため、グローバルアクセス可能なシングルトンとしてDependencyクラスを定義し、アプリ起動時にViewModelProviderを設定しています。SwiftUIのみであれば、Environmentに置き換えることも可能です。

具体的には、AppDelegateのapplication(_:didFinishLaunchingWithOptions:)Dependency.shared.set(ViewModelProvider())を呼び出して初期化します。

// AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let viewModelProvider = ViewModelProvider()
    Dependency.shared.set(viewModelProvider)
    return true
}
// App/Dependency.swift
public final class Dependency {
    public static let shared = Dependency()

    private var viewModelProvider: ViewModelProvidable?

    public var viewModel: ViewModelProvidable {
        guard let viewModelProvider else {
            preconditionFailure("アプリ起動時に `set(_:)` してから利用してください.")
        }
        return viewModelProvider
    }

    private init() {}

    public func set(_ viewModelProvider: ViewModelProvidable) {
        self.viewModelProvider = viewModelProvider
    }
}
// App/ViewModelProvider.swift
final class ViewModelProvider: ViewModelProvidable {
    func userViewModel() -> UserViewModelImpl {
        UserViewModelImpl(
            fetchUserUseCase: fetchUserUseCase
        )
    }

    private lazy var fetchUserUseCase: FetchUserUseCaseImpl = {
        FetchUserUseCaseImpl(
            userRepository: userRepository
        )
    }()

    private lazy var userRepository: UserRepositoryImpl = {
        UserRepositoryImpl(
            userDataSource: userDataSource
        )
    }()

    private lazy var userDataSource: UserDataSourceImpl = {
        UserDataSourceImpl(
            apiClient: apiClient // APIClientもDIで注入しています。
        )
    }()
}

DIしたものを利用するために以下のようなProtocolを定義します。これによって、ViewModelProviderを利用する側では、ViewModelの具体的な実装を知らなくても、ViewModelProviderを通じてViewModelを利用することができます。

// Packages/Feature/Sources/Feature/ViewModelProvidable.swift
public protocol ViewModelProvidable {
    func userViewModel() -> UserViewModelImpl
}

まとめ

ヘルシカiOSアプリで採用しているクリーンアーキテクチャについてご紹介しました。

本記事のポイントをまとめます:

  • 各層の責務分離: Feature層(UI/状態管理)、Usecase層(ビジネスロジック)、Repository層(データアクセス/キャッシュ)、Infra層(外部リソースアクセス)、Model層(ドメインモデル)と明確に責務を分離しています
  • SPMによるモジュール管理: パッケージファイルで依存関係を明示することで、アーキテクチャ違反を防ぎ、レビュー時に変更を検知しやすくなります
  • 依存性逆転の原則: DataSourceのProtocolをRepository層で定義することで、Repository層がInfra層の実装詳細に依存しない設計を実現しています
  • DI(依存性注入): シングルトンのDependencyクラスを通じて依存関係を一元管理し、ViewModelProviderで各層のインスタンスを生成・注入しています

AIがコードを書く時代では、依存関係を明確に定義し、責務を分離することでAIにとってもわかりやすく、安全にコード生成することが可能になると考えています。

実際の開発ではCursorを活用しています。Usecase、Repository、Infra、Model層は構造化されたパターンが多いため、AIによるコード生成との相性が良く、ほぼAIに任せることができています。

一方、Feature層は画面ごとに実装方針にばらつきがあり、完全にAIへ委ねるのは難しい印象です。また、簡単なロジックをViewModelに持たせるかUseCaseに切り出すかは状況に応じた判断が必要であり、この辺りは引き続き人間が担う領域だと感じています。