# Android architecture guide に従った3層アーキテクチャ









<LinkCard
  title="アプリ アーキテクチャ ガイド"
  description="Android 公式の推奨アーキテクチャ。UI 層・ドメイン層・データ層の役割と依存の向き。"
  href="https://developer.android.com/topic/architecture"
/>

Android developers で公開されているアプリアーキテクチャガイドは、Android アプリケーションのベースとなるアーキテクチャとして広く知られています。

以下は Domain レイヤーを採用する場合のアーキテクチャ定義の例です。

## ディレクトリ構造の例

<FileTree>
- app/src/main/kotlin/com/example/project/
  - ui/
    - home/
      - HomeUiState.kt
      - HomeViewModel.kt
      - HomeScreen.kt
  - domain/
    - user/
      - GetUserUseCase.kt
    - model/
      - User.kt
  - data/
    - user/
      - UserRepository.kt
      - UserRepositoryImpl.kt
      - UserRemoteDataSource.kt
</FileTree>

## アーキテクチャ定義

```kt
// ProjectArchitecture.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.architecture
import me.tbsten.katachi.dsl.gradle.gradle

/**
 * See https://developer.android.com/topic/architecture
 */
val projectArchitecture = architecture {
  gradle() // wrapper・settings・build スクリプトなど Gradle のファイル

  // https://developer.android.com/topic/architecture/ui-layer
  "ui".group {
    uiState()
    viewModel()
    screen()
  }

  // https://developer.android.com/topic/architecture/domain-layer
  "domain".group {
    useCase()
    domainModel()
  }

  // https://developer.android.com/topic/architecture/data-layer
  "data".group {
    repositoryInterface()
    repositoryImplementation()
    dataSource()
  }  
}

// Constants.kt
const val appPackageDir = "com/example/project"

// roles/UiState.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist

fun DeclarationContainerScope.uiState() = "UiState" {
  summary = "画面が描画に使う状態を、変更されない値として持つ"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*UiState".ktFile()
  }

  shouldBeImmutable()
}

private fun RoleScope.shouldBeImmutable() =
  "プロパティはすべて val であること".konsist {
    classes().must { klass -> klass.properties().all { it.isVal } }
  }

// roles/ViewModel.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist

fun DeclarationContainerScope.viewModel() = "ViewModel" {
  summary = "画面の状態を StateFlow で公開し、画面からのイベントを受け取る"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*ViewModel".ktFile()
  }

  shouldContainsViewModelClass()
  shouldExtendsViewModel()
  shouldUiStateFlowBePublic()
}

private fun RoleScope.shouldContainsViewModelClass() =
  "ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
    files.must { file -> file.classes().map { it.name } == listOf(file.name) }
  }

private fun RoleScope.shouldExtendsViewModel() =
  "ViewModel を継承すること".konsist {
    classes().must { klass -> klass.parents().any { it.name == "ViewModel" } }
  }

private fun RoleScope.shouldUiStateFlowBePublic() =
  "UiState の Flow を public で公開すること".konsist {
    classes().must { klass ->
      klass.properties().any {
        it.hasPublicOrDefaultModifier && it.type?.name?.startsWith("StateFlow") == true
      }
    }
  }

// roles/Screen.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist

fun DeclarationContainerScope.screen() = "Screen" {
  summary = "画面を描く public な Composable"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*Screen".ktFile()
  }

  shouldContainsPublicScreenComposable()
}

private fun RoleScope.shouldContainsPublicScreenComposable() =
  "ファイル名と同じ名前の public な Composable を宣言すること".konsist {
    files.must { file ->
      file.functions().any {
        it.name == file.name && it.hasPublicOrDefaultModifier &&
          it.hasAnnotationWithName("Composable")
      }
    }
  }

// roles/UseCase.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist

fun DeclarationContainerScope.useCase() = "UseCase" {
  summary = "アプリ固有の1つの振る舞いを invoke で実行する"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "domain/*" / "*UseCase".ktFile()
  }

  shouldContainsUseCaseClass()
  shouldSingleInvokePublicMethod()
}

private fun RoleScope.shouldContainsUseCaseClass() =
  "ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
    files.must { file -> file.classes().map { it.name } == listOf(file.name) }
  }

private fun RoleScope.shouldSingleInvokePublicMethod() =
  "public な invoke メソッドを1つだけ持つこと".konsist {
    classes().must { klass ->
      klass.functions().filter { it.hasPublicOrDefaultModifier }.map { it.name } == listOf("invoke")
    }
  }

// roles/DomainModel.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile

fun DeclarationContainerScope.domainModel() = "DomainModel" {
  summary = "ドメイン層で扱う、アプリの概念を表すモデル"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "domain/model" / "*".ktFile()
  }
}

// roles/RepositoryInterface.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist

fun DeclarationContainerScope.repositoryInterface() = "RepositoryInterface" {
  summary = "データの取得と保存の窓口となる interface"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*Repository".ktFile()
  }

  shouldContainsRepositoryInterface()
}

private fun RoleScope.shouldContainsRepositoryInterface() =
  "ファイル名と同じ名前の interface を1つだけ宣言すること".konsist {
    files.must { file -> file.interfaces().map { it.name } == listOf(file.name) }
  }

// roles/RepositoryImplementation.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist

fun DeclarationContainerScope.repositoryImplementation() = "RepositoryImplementation" {
  summary = "RepositoryInterface の実装"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*RepositoryImpl".ktFile()
  }

  shouldContainsRepositoryImplementationClass()
}

private fun RoleScope.shouldContainsRepositoryImplementationClass() =
  "ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
    files.must { file -> file.classes().map { it.name } == listOf(file.name) }
  }

// roles/DataSource.kt
@file:OptIn(ExperimentalKatachiApi::class)

import me.tbsten.katachi.ExperimentalKatachiApi
import me.tbsten.katachi.dsl.DeclarationContainerScope
import me.tbsten.katachi.dsl.RoleScope
import me.tbsten.katachi.dsl.gradle.kotlin
import me.tbsten.katachi.dsl.gradle.mainSourceSet
import me.tbsten.katachi.dsl.kotlin.ktFile
import me.tbsten.katachi.konsist.konsist

fun DeclarationContainerScope.dataSource() = "DataSource" {
  summary = "Repository が使う、データの取得元・保存先へのアクセス"
  layout {
    "app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*DataSource".ktFile()
  }

  shouldContainsDataSourceClass()
}

private fun RoleScope.shouldContainsDataSourceClass() =
  "ファイル名と同じ名前のクラスを1つだけ宣言すること".konsist {
    files.must { file -> file.classes().map { it.name } == listOf(file.name) }
  }
```

`konsist { }` を使うので、`:architecture-test` に `katachi-konsist` への依存を足し、テストでは `assert(FileConstraintCheck())` を呼んでください。`assert()` だけでは制約が評価されず、`[UncheckedFileConstraint]` で落ちます。詳しくは [Konsist との統合](/katachi/ja/guides/konsist-integration/) を参照してください。

層を group に、ファイルの種類を Role に分けた実際の定義は、[sample/android の ProjectArchitecture.kt](https://github.com/TBSten/katachi/blob/main/sample/android/architecture-test/src/test/kotlin/com/example/sample/ProjectArchitecture.kt) にあります。サンプルはドメイン層を持たず、画面ごとにモジュールを分けた構成なので、このページの定義とは形が違います。

<SnippetIsStartingPoint>
たとえばプロダクトで Dagger Hilt を使っているなら、ViewModel に `@HiltViewModel` アノテーションが付いているかの検査や、Repository の実装クラスに `@Inject` コンストラクタがあるかの検査を追加するなど、さらにカスタマイズが必要になるでしょう。
</SnippetIsStartingPoint>