
ヘルシカ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に切り出すかは状況に応じた判断が必要であり、この辺りは引き続き人間が担う領域だと感じています。