コンテンツにスキップ
  • 中級者向け

Role

アーキテクチャ定義に従ったアプリには、固有の役割(Role)を持つファイルがあります。 例えば UseCase, Repository, Controller, ViewModel などです。

どんな Role があるのかは、architecture { } の中で定義します。

architecture {
// UseCase という Role を定義する
"UseCase" {
// 説明・置き場所・制約を、この中に書く
}
}

Role の説明・置き場所・制約は、その Role のブロックの中に書きます。

  • title・summary・description など … そのRoleの説明です。ドキュメント生成に使われます。
  • layout { } … そのRoleのファイルをどこに置けるかを定義します。
  • "制約名".konsist { } … そのRoleのファイルにかけられる制限を定義します。
    • 制約は Konsist で書きます。詳しくは Konsist との統合 を参照してください。
  • .template { } … layout { } で宣言したファイルに付けて、そのファイルを新しく作るときのテンプレートを定義します。
architecture {
"UseCase" {
title = "ユースケース"
summary = "各画面で発生するアプリ固有の1つの振る舞い"
example("GetUserUseCase", "ユーザーを取得する")
// どこに置いてよいか
layout {
"src/main/kotlin/com/example/useCase" / "*UseCase".ktFile()
}
// 中身がどうあるべきか
"invoke 関数を1つだけ持つこと".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
}
}

それぞれを、もう少し詳しく説明します。

説明を書く

最低限必要なのは summary だけです。生成ドキュメントでRoleの一覧表に並ぶ1行になります。

"UseCase" {
summary = "各画面で発生するアプリ固有の1つの振る舞い"
}

description には長い説明を、example() には代表例を書けます。example() は呼んだ順に保持されます。

"UseCase" {
title = "ユースケース"
summary = "各画面で発生するアプリ固有の1つの振る舞い"
description = """
UI からは UseCase だけを呼び、Repository を直接触らない。
### やってはいけないこと
- Android の型に依存する
""".trimIndent()
example("GetUserUseCase", "ユーザーを取得する")
example("UpdateUserUseCase", "ユーザーを更新する")
}
置き場所を宣言する

置き場所を1つだけ持つ場合です。

"UseCase" {
layout {
"src/main/kotlin/com/example/useCase" / "*UseCase".ktFile()
}
}

layout { } は複数書けます。 同じ種類のファイルが複数の場所に現れてよい、という宣言です。種類の違うファイルは、Role を分けて宣言します。

"Screen" {
// feature-home のモジュールに
layout {
"feature-home/src/main/kotlin/screen" / "*Screen".ktFile()
}
// feature-settings のモジュールにも
layout {
"feature-settings/src/main/kotlin/screen" / "*Screen".ktFile()
}
}

宣言していない場所にファイルを置くと違反になります。

src/main/kotlin/com/example/GetUserUseCase.kt ← useCase/ の外に置いてしまった

テストを実行すると、katachi が近い置き場所と直し方まで1つのブロックで報告します。

Katachi check failed: 1 violation (Unexpected: 1)
[UnexpectedFile] file:///path/to/project/src/main/kotlin/com/example/GetUserUseCase.kt
No role is defined for this file.
Nearby locations:
UseCase file:///path/to/project/src/main/kotlin/com/example/useCase/
How to fix:
- Move it to one of the locations above
- Delete it if it is not needed
- Add a new role for it:
"GetUserUseCase" {
summary = "TODO"
layout {
"src/main/kotlin/com/example" / "GetUserUseCase".ktFile()
}
}
中身に制約をかける

制約には名前を付けます。この名前がそのまま違反レポートに出ます。

"UseCase" {
layout {
"src/main/kotlin/com/example/useCase" / "*UseCase".ktFile()
}
"invoke 関数を1つだけ持つこと".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
}

制約の対象は、その Role の layout { } に当てはまったファイルです。layout { } の無い Role には制約を書けません。

mustNot と mustBeEmpty() で禁止も書けます。

"RepositoryInterface" {
layout {
"src/main/kotlin/com/example/repository" / "*Repository".ktFile()
}
// 満たすべき条件を否定で書く
"internal にしないこと".konsist {
classes().mustNot { it.hasInternalModifier }
}
// 該当するものが1つも無いことを求める
"Impl を同じ場所に置かないこと".konsist {
classes().filter { it.hasNameEndingWith("Impl") }.mustBeEmpty()
}
}

制約を満たさないクラスがあると違反になります。

// useCase/ の中に置かれているが、invoke 以外の関数を持っている
class GetUserUseCase(private val repository: UserRepository) {
suspend fun invoke(id: String) = repository.find(id)
fun clearCache() = repository.clear() // ← これ
}

テストで assert(FileConstraintCheck()) を呼ぶと、配置の違反と同じレポートに並びます。どのファイルの何行目かまで出ます。

[UnsatisfiedFileConstraint] file:///path/to/project/src/main/kotlin/com/example/useCase/GetUserUseCase.kt
Role: UseCase / FileConstraint: "invoke 関数を1つだけ持つこと"
Declaration: GetUserUseCase (line 3)
Declared at: ProjectArchitecture.kt:12

なお、Role の名前はドキュメント生成などで使うため、ASCII(英字で始まり、英数字・_・- が続く)しか使えません。日本語の表示名は title に書きます。

"グループ名".group { ... } を使うと、Roleをグループにまとめられます。

architecture {
"domain".group {
"UseCase" {
// ...
}
"RepositoryInterface" {
// ...
}
}
}

良い Role 定義は変更を必要としない

Section titled “良い Role 定義は変更を必要としない”

アプリに機能を足すたびに architecture { } を書き換える必要があるなら、その architecture { } の定義は悪い設計です。

architecture { } がディレクトリ構造の写しになって二度手間にならないよう、* や ** といったワイルドカードをうまく使ってください。

1つの Role には1種類のファイルだけを入れる

Section titled “1つの Role には1種類のファイルだけを入れる”

1つの Role は、1種類のファイルだけを表すようにしてください。 たとえば Repository の interface と実装は、名前は似ていても別の種類のファイルです。1つの Role にまとめると、説明・制約・テンプレートが「どちらのファイルの話か」で分かれてしまい、konsist { } の制約にも「interface なら…、実装なら…」という条件分岐が入ります。 種類ごとに Role を分ければ、説明も制約もその種類のファイルだけを対象にでき、生成されるドキュメントも種類ごとのページになります。

🆖 2種類のファイルを1つの Role に

"Repository" {
summary = "データの取得と保存"
layout {
"repository" / "*Repository".ktFile() // interface
"repository" / "*RepositoryImpl".ktFile() // 実装
}
"interface なら宣言だけ、実装なら…".konsist {
// どちらのファイルかで制約を分ける必要がある
}
}

🆗 種類ごとに Role を分ける

"RepositoryInterface" {
summary = "データの取得と保存の窓口"
layout { "repository" / "*Repository".ktFile() }
"interface だけを置くこと".konsist {
classes().mustBeEmpty()
}
}
"RepositoryImplementation" {
summary = "RepositoryInterface の実装"
layout { "repository" / "*RepositoryImpl".ktFile() }
}

プロジェクトが大きくなるほど、architecture { } の定義は肥大化します。

architecture { } は Kotlin DSL なので、普通の Kotlin と同じように拡張関数へ切り出せます。

ここではおすすめの Role 定義の分割方法 を紹介します。

膨らみがちなのは 各 Role 内の詳細 です。 Role ごとに拡張関数に切り出しましょう。

分割前

ProjectArchitecture.kt
architecture {
"UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
"invoke 関数を1つだけ持つこと".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
// ...
}
"RepositoryInterface" {
layout {
"src/main/kotlin/com/example/project/repository" / "*Repository".ktFile()
}
"class を置かないこと".konsist {
classes().mustBeEmpty()
}
// ...
}
// ...
}

分割後

  • ProjectArchitecture.kt
  • ディレクトリdomain/
    • UseCaseRole.kt
    • RepositoryInterfaceRole.kt
ProjectArchitecture.kt
architecture {
useCase()
repositoryInterface()
// ...
}
domain/UseCaseRole.kt
// hint: DeclarationContainerScope は architecture { } と "...".group { } のレシーバです。
fun DeclarationContainerScope.useCase() = "UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
"invoke 関数を1つだけ持つこと".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
// ...
}
domain/RepositoryInterfaceRole.kt
fun DeclarationContainerScope.repositoryInterface() = "RepositoryInterface" {
layout {
"src/main/kotlin/com/example/project/repository" / "*Repository".ktFile()
}
"class を置かないこと".konsist {
classes().mustBeEmpty()
}
// ...
}

Role 定義の中でも、特に "...".konsist { } は、制約が多かったり複雑になったりして肥大化しがちです。

"...".konsist { } は、積極的に private な拡張関数へ切り出しましょう。

分割前

fun DeclarationContainerScope.useCase() = "UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
"invoke 関数を1つだけ持つこと".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
// ...
}

分割後

fun DeclarationContainerScope.useCase() = "UseCase" {
layout {
"src/main/kotlin/com/example/project/useCase" / "*UseCase".ktFile()
}
onlyInvokeFun()
// ...
}
private fun RoleScope.onlyInvokeFun() =
"invoke 関数を1つだけ持つこと".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
  • Role は プロジェクトの中に現れてよいファイルの種類 です。パッケージやモジュールとは独立しています。
  • 1つのRoleに、説明(title / summary)・置き場所(layout { })・制約(konsist { })が まとまります。
  • Role 定義はすぐに肥大化します。 architecture { } や Role 定義が膨らまないよう、拡張関数を使って適切に分割しましょう。
architecture {
"domain".group {
"UseCase" {
summary = "各画面で発生するアプリ固有の1つの振る舞い"
// 置き場所が増えても、説明はこの1箇所のまま
layout {
"core/domain/src/main/kotlin/useCase" / "*UseCase".ktFile()
}
"invoke 関数を1つだけ持つこと".konsist {
classes().must { it.functions().map { f -> f.name } == listOf("invoke") }
}
}
}
}
  • Layout … Roleのファイルをどこに置けるか宣言する方法を学びましょう。
  • Konsist との統合 … Roleのファイルの中身に制約をかける方法を学びましょう。
  • 初めてのアーキテクチャ定義 … あなたのプロジェクトに katachi を導入して、実際に Role を書いてみましょう。