Clean Architecture — ロバート・マーティン(アンクル・ボブ)が2012年に提唱した多層アーキテクチャで、アプリケーションを独立した層(Domain:エンティティ、ユースケース、Data:リポジトリ、データソース、Presentation:ビューモデル、ビュー)に分割します。主要な原則は依存関係ルール(Dependency Rule)です。依存関係は内側に向き、外部層は内部層に依存しますが、その逆はありません。Clean Architectureは、ビジネスロジックが複雑なモバイル開発プロジェクトで使用されます。詳細は書籍 The Clean Architecture をご覧ください。
重要なポイント
Clean Architecture — ロバート・マーティン(アンクル・ボブ)が2012年に策定したアーキテクチャパターン。核となる考え方は、厳格な依存関係ルールでアプリケーションを層に分割することです。層の内部のコードは外部のコードについて知りません。外部層(UI、フレームワーク、DB)は実装の詳細です。内部層(ビジネスロジック、エンタープライズルール)がアプリケーションの本質です。
モバイル開発におけるClean Architectureの層:1) Domain — エンティティ(ビジネスオブジェクト)とユースケース(使用シナリオ);2) Data — RepositoryImpl(リポジトリ実装)、DataSources(ネットワーク、DB、キャッシュ);3) Presentation — ビューモデル、ビュー(Compose/SwiftUI)。Domainは最も内側の層で依存関係がありません。DataはDomainに依存します(リポジトリインターフェースを実装)。PresentationはDomainに依存します(ユースケースを呼び出し、結果を購読)。
| 層 | 含むもの | 依存関係 |
|---|---|---|
| Domain | エンティティ、ユースケース、リポジトリインターフェース | なし(純粋なKotlin/Swift) |
| Data | RepositoryImpl、DataSources(API、DB、キャッシュ) | Domain、Retrofit、Room、Ktor |
| Presentation | ビューモデル、ビュー、Composables | Domain、Jetpack、SwiftUI |
依存関係ルール — Clean Architectureの唯一の厳格なルール。ソースコードは自分自身の内側の層か、より下位の層(中心に近い層)のみを参照できます。PresentationはDomainをインポートします。DomainはDataやPresentationをインポートしません。これは依存関係逆転の原則(Dependency Inversion Principle)によって達成されます:DomainがRepositoryインターフェースを定義し、Dataがそれを実装します。Presentationは特定のリポジトリではなく、UseCaseの抽象化に依存します。
Domain — アプリケーションの最も安定した層。エンティティはフレームワークに依存しないビジネスオブジェクトです:User、Product、Order。ユースケースは1つのinvokeメソッド(またはKotlinのoperator fun invoke)を持つクラスで、1つのシナリオを実装します:GetUserUseCase、PlaceOrderUseCase、CalculateTotalUseCase。リポジトリインターフェースはDomainで定義されDataで実装されるデータアクセスの抽象化です。DomainにはAndroid SDK、iOS UIKit、Retrofit、Roomは含まれません — 純粋なKotlinまたはSwiftのみです。
// Entity — ビジネスオブジェクト(Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — データ抽象化(Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — 1つのシナリオ(Domain)
final class GetUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: Int) async throws -> User {
return try await repository.getUser(id: id)
}
}
ユースケース — 「1つのメソッドを持つクラス」は教義ではなく実践的な推奨事項です。ユースケースがより複雑になると(バリデーション+ロギング+リポジトリ呼び出し)、そのメソッドは意味ごとにグループ化されます:UserUseCase.getUser、UserUseCase.searchUsers、UserUseCase.deleteUser。重要なのは、ユースケースがデータの出所(ネットワーク、DB、キャッシュ)や表示者(Compose、SwiftUI)を知らないことです。IT Sectrでは、ビジネスルール、バリデーション、または2つのソースからのデータ結合を含む各操作にユースケースを割り当てています。
Domainの純粋性は層の境界でのDTOマッピングによって達成されます。Data層はJSONモデル(DTO)を受け取り、Domainエンティティにマッピングします。PresentationはDomainエンティティを受け取り、ビューモデル(DisplayItem)にマッピングします。DomainエンティティにはRetrofit、Room、Codableのアノテーションが含まれることはありません — これにより、DBをRoomからRealmに変更したり、RetrofitをKtorに置き換えたりしても、層を変更する必要がないことが保証されます。
Data層 — Domainで定義されたインターフェースの実装。RepositoryImpl(UserRepositoryを実装するクラス)とDataSources(RemoteDataSource — API、LocalDataSource — DB、CacheDataSource — SharedPreferences/NSUserDefaults)を含みます。Data層はDomain(リポジトリインターフェースとエンティティをインポート)とフレームワーク(Retrofit、Room、Ktor、CoreData)に依存します。RepositoryImplはDomainからデータソースを隠蔽します — ユースケースはデータがネットワークから来たのかキャッシュから来たのかを知りません。
// DTO — ネットワーク用モデル(Data)
data class UserDto(
@SerializedName("id") val id: Int,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
@SerializedName("email") val email: String
)
// RepositoryImpl — 実装(Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// キャッシュから取得を試みる
localDataSource.getUser(id)?.let { return it.toDomain() }
// なければ — ネットワークからロード
val dto = remoteDataSource.fetchUser(id)
val user = dto.toDomain()
localDataSource.saveUser(user)
return user
}
override suspend fun getUsers(): List<User> {
return remoteDataSource.fetchAllUsers().map { it.toDomain() }
}
}
// Mapper — DTO ↔ Domain変換
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Data層のキャッシュ戦略:RepositoryImplは最初にローカルストレージを確認し、データが見つからない場合はネットワークからロードしてローカルに保存します。ネットワークが利用できない場合は、isStaleフラグ付きで古いデータを返します。Domainのユースケースは戦略について知りません — Repository.getUser(id)を介してUserを受け取ります。戦略の変更(例:15分ごとのキャッシュ無効化)はDomainやPresentationに影響しません。
Androidでのモジュール性 — Kotlin Multiplatformを使用すると、Android SDKの依存関係なしにDomainを別のKMPモジュールに抽出できます。DataはDomainに依存する別のモジュールです。PresentationはDomainに依存するAndroidモジュールです。Gradleの依存関係:domain(純粋なKotlin)、data(domain + Retrofit + Room)、app(domain + presentation + Hilt)。このようなモジュール性は大規模プロジェクトに不可欠です — CIはDomainを個別にビルドし、Domainの単体テストにAndroidエミュレーターは必要ありません。
Presentation層 — Clean Architectureの最も外側の層。ビューモデル(Android)/ ObservableObject(iOS)とビュー(Compose/SwiftUI)を含みます。ビューモデルはユースケースを呼び出し、結果を受け取ってUI状態に変換します。ビューは状態を購読してレンダリングします。PresentationはDomainに依存します — ユースケースとエンティティをインポートします。PresentationはData層をインポートしません — データは内部でリポジトリを使用するユースケースを介して届きます。
Clean Architectureのビューモデルはビジネスロジックを含みません — ユースケースを呼び出します。ユースケースがUserを返す場合、ビューモデルはそれをUserDisplayItem(name、emailFormatted、avatarUrl)— 純粋なプレゼンテーションモデルに変換します。ユースケースはDisplayItemについて知りません — エンティティを返します。この分離により、UIなしでユースケースを、UseCaseなしでビューモデルを(モックを介して)テストできます。IT Sectrでは厳格に従っています:ユースケース — ビジネスロジック、ビューモデル — プレゼンテーションのみ、ビュー — 表示のみ。
// Use Case (Domain) — 純粋なビジネスロジック
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — プレゼンテーションのみ
class UserViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
val state: StateFlow<UserScreenState> = _state.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_state.value = UserScreenState.Loading
val user = getUserUseCase(id)
val displayItem = UserDisplayItem(
name = user.name,
email = user.email,
initials = user.name.split(" ").joinToString("") { it.first().toString() }
)
_state.value = UserScreenState.Success(displayItem)
}
}
}
data class UserDisplayItem(
val name: String,
val email: String,
val initials: String
)
sealed interface UserScreenState {
data object Loading : UserScreenState
data class Success(val displayItem: UserDisplayItem) : UserScreenState
data class Error(val message: String) : UserScreenState
}
ナビゲーションはPresentation層でも外側のリングの一部です。Clean Architectureはナビゲーションメカニズムを規定しません — NavController(Compose)、NavigationStack(SwiftUI)、Coordinator(UIKit)、Router(VIPER)のいずれでも構いません。重要なのは、ナビゲーションの決定はPresentationが行いますが、ナビゲーションがユースケースに浸透してはいけないことです。ユースケースは結果を返し、ビューモデルがどの画面に遷移するかを決定します。Clean Architectureでは、ナビゲーションはDomainを変更せずに置き換え可能な詳細事項です。
AndroidでのClean ArchitectureはGradleモジュールを介して実装されます:domain(純粋なKotlin)、data(domain + Retrofit + Room)、presentation(domain + Compose)。フォルダ構造:domain/user/User.kt、GetUserUseCase.kt、UserRepository.kt;data/remote/UserRemoteDataSource.kt、local/UserDao.kt、repository/UserRepositoryImpl.kt;presentation/ui/user/UserViewModel.kt、UserScreen.kt。DI(Hilt)が層を接続します:UserRepositoryImplはdomainモジュールのUserRepositoryインターフェースにバインドされます。
iOSでのClean Architectureは、Xcodeの制限により、個別モジュールなしでSPMまたはXcodeグループを使用します。Domain — UIKitやSwiftUIをインポートしないファイルを含むフォルダ。Data — APIClient、CoreDataStack、RepositoryImplを含むフォルダ。Presentation — ビューモデルとSwiftUIビューを含むフォルダ。DIはコンストラクタまたはアプリ内のアセンブリを介して行います。主要な呼び出しは、UI更新のためのMainActorチェック付きのUseCase.execute()を介したasync/awaitです。
// Data Layer: Remote DataSource (iOS)
final class UserRemoteDataSource {
private let apiClient: APIClient
func fetchUser(id: Int) async throws -> UserDTO {
return try await apiClient.get("/users/\(id)")
}
}
// Repository Implementation (Data)
final class UserRepositoryImpl: UserRepository {
private let remote: UserRemoteDataSource
private let local: UserLocalDataSource
func getUser(id: Int) async throws -> User {
if let cached = try await local.getUser(id) {
return cached
}
let dto = try await remote.fetchUser(id)
let user = dto.toDomain()
try await local.saveUser(user)
return user
}
}
// Presentation: ViewModel + SwiftUI View
@MainActor
final class UserViewModel: ObservableObject {
@Published private(set) var state: UserScreenState = .loading
private let getUserUseCase: GetUserUseCase
func loadUser(id: Int) {
Task {
state = .loading
if let user = try? await getUserUseCase.execute(id: id) {
state = .success(user)
} else {
state = .error("Failed to load")
}
}
}
}
IT SectrプロジェクトでのClean Architecture — 30日以上のプロジェクトにおける当社の標準です。2022年からAndroid/iOS向けにKotlin Multiplatformを使用した3層アーキテクチャを採用しています。Domain — 共有KMPモジュール、Data — プラットフォームモジュール(AndroidではRetrofit、iOSではURLSession)、Presentation — ネイティブUI。これにより、iOSとAndroid間で60〜80%のビジネスロジックコードが共有され、2つの個別実装と比較して開発時間が30〜40%削減されます。
よくある質問
最小限3つ:Domain、Data、Presentation。大規模プロジェクトでは、Framework(Android SDK/iOS UIKit依存関係)とDevice(GPS、カメラ、センサー)が追加されます。層の数は厳格なルールではなく、便宜上の問題です。重要なのは依存関係ルールに従うことです:依存関係は内側、Domainに向かいます。3つから始めて、プロジェクトの成長に応じて層を追加できます。
はい — リポジトリインターフェース、ユースケース、マッパーの抽出により、MVVMと比較して30〜50%増加します。単純なCRUDアプリケーションには過剰です。Clean Architectureは、テスト容易性と層の分離が開発速度より重要な複雑なビジネスロジックを持つプロジェクトに適しています。MVPやプロトタイプにはMVVMを使用してください — Clean Architectureは立ち上げを遅くします。
はい、これは一般的なプラクティスです。ユースケースはDomainに残り、PresentationはMVIサイクル(Intent → Reducer → State)を使用します。Data層は同じままで、Domainも同じままです。PresentationのMVIは予測可能な画面状態を提供し、Clean Architectureはビジネスロジックの分離を提供します。この組み合わせは、数十人の開発者がいる大規模プロジェクトで使用されています。
ユースケースは、操作にビジネスルール(バリデーション、2つのソースからのデータ結合、計算、ロギング、アクセス権限チェック)が含まれる場合に必要です。追加ロジックのない単純なgetUser(id)リクエストは、ビューモデルから直接リポジトリを呼び出すことができます。ただし、アーキテクチャの一貫性のために、多くのチームはリポジトリのすべてのパブリックメソッドに対してユースケースを作成します — これにより5〜10%のコードが追加されますが、読みやすさが向上します。
Domain:モックリポジトリを使用したユースケースの単体テスト — Android SDKなしの純粋なKotlin/Swift。Data:モック/フェイクDataSourceを使用したRepositoryImplの統合テスト。Presentation:モックUseCaseを使用したビューモデルのテスト。依存関係ルールのおかげで、各層は分離してテストされます。IT Sectrでは、Domainのカバレッジは95%、Dataは70〜80%、Presentationは60〜70%に達しています。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。