Skip to content

アーキテクチャ検査 ​

architecture は任意のアーティファクトです。PulseDesktop が周りのコードに期待する規約を Konsist のアサートとして公開しているので、自分のテストで検査できます。

型で表せることはコンパイラが弾きます。ViewModel は onAction を実装しなければならず、メッセージの型は Container が知っているものに限られます。残るのは型で表せない書き方の習慣で、どれもレビューで見落としやすいものです。

検査は 5 つのグループに分かれていて、グループごとにアサートが 1 つあります。どのグループも単独で使えます。

アサート検査する内容
assertPulseMviConventions()State とメッセージの型と置き場所、ViewModel と Container の名前と置き場所、feature を Host・Content・Section・Component に分けること、すべての Section と Component に Preview があること
assertPulseDomainConventions()Repository がデータをどう公開するか、UseCase を作ってよいのはどんなときか
assertPulseModuleConventions()どのモジュールがどのモジュールに依存してよいか、domain と data の中のパッケージ
assertPulseKoinConventions()Koin のモジュールをどこで定義するか
assertPulseResourceConventions()ユーザーが読む文言をどこから持ってくるか

セットアップ ​

テストの依存に追加します。Konsist 自体も一緒に入るので、ほかに宣言するものはありません。

kotlin
// build.gradle.kts
dependencies {
    testImplementation("com.github.kaleidot725.PulseDesktop:architecture:<version>")
}

あとはテストを 1 本書きます。scopeFromProduction() はプロダクションのソースセットだけを読み、テストコードを除きます。

kotlin
class PulseDesktopConventionTest {
    @Test
    fun `follows the PulseDesktop conventions`() {
        val scope = Konsist.scopeFromProduction()
        scope.assertPulseMviConventions()
        scope.assertPulseDomainConventions()
        scope.assertPulseModuleConventions()
        scope.assertPulseKoinConventions()
        scope.assertPulseResourceConventions()
    }
}

宣言 ​

assertPulseMviConventions() は 12 の検査を実行します。5 つは宣言について、7 つは View についてのものです。失敗したときは、該当する宣言の名前と直し方を出します。

検査ルール理由
State は data 型PulseState は data class または data objectStateFlow は直前と等しい値を流さず、Compose は引数が等しい Composable をスキップする。equals がないと更新ごとに画面全体が再コンポジションされる
メッセージは sealed classAction・Event・Broadcast・Unicast は sealed class、その中の各ケースは data class か data object。ケースが 1 つなら単独の data class か data object でもよく、interface は使わないどれも when で処理される。sealed にしてあればケースの追加がコンパイルエラーになり、data にしてあればケースが値で比較・表示される
State とメッセージは Contracts に置くPulseState・Action・Event・Broadcast・Unicast は、ViewModel か Container と同じパッケージの …Contracts.kt にあるViewModel や Container が取り決める型なので、使うクラスの隣の 1 ファイルで見つかるようにする。PulseAreaViewModel なら PulseAreaContracts.kt、PulseCountContainer なら PulseCountContracts.kt
名前は基底クラスに従うPulseViewModel は …ViewModel、PulseContainer は …Containerコンパイラにとってはどちらも ViewModel のサブクラスなので、どちらなのかを示すのは名前だけ
データは State に置くViewModel と Container は var プロパティを持たないState の外の var は変更しても emission が起きず、何も再コンポジションされない

View ​

feature は 4 つの役割に分けます。Host はルートが表示するもので、ViewModel と Container を生成し、Content を配置します。Content は 1 つの ViewModel が受け持つ部分で、その ViewModel を PulseContent で束ねます。Section は State とコールバックから画面の一部を描き、Component は小さな部品です。Section と Component はどちらも純粋な Composable で、違うのは役割だけです。

どれに当たるかはパッケージで決まるので、Host 以外の名前は自由です。feature のルートは content パッケージを持つパッケージで、そこに Host(…Host という名前のファイル)、Container、Container の Contracts を置きます。Content は content の下にそれぞれのパッケージを持ち、ViewModel と Contracts をその隣に置きます。Section と Component は、feature のルートにも、それを使う Content の中にも置けます。ルートの下に置けるパッケージは、この 3 つだけです。stringResource でラベルを読むような、feature 全体で使う純粋な関数は component に置きます。値を返すだけの Composable は View ではないので、Preview はいりません。

count/
├── PulseCountHost.kt          # Host
├── PulseCountContainer.kt
├── PulseCountContracts.kt     # Container の Broadcast と Unicast
├── component/                 # feature 全体で使う Component
│   └── PulseCountHeader.kt
└── content/
    └── area/
        ├── PulseAreaContent.kt    # Content
        ├── PulseAreaViewModel.kt
        ├── PulseAreaContracts.kt  # ViewModel の State・Action・Event
        ├── section/               # この Content の Section
        │   └── PulseAreaSection.kt
        └── component/             # この Content の Component
            └── PulseAreaCell.kt
検査ルール理由
ViewModel は Content に置くPulseViewModel は content の下で、section と component の外にあるViewModel は 1 つの Content を受け持つので、それを束ねる Composable の隣に置き、下の純粋な部分からは見えないようにする
Container は feature のルートに置くPulseContainer は content を持つパッケージにあるContainer は feature のすべての Content をまとめるので、それを生成する Host の隣に置く
Host は Content を並べるfeature のルートにある Composable は …Host という名前のファイルにあり、PulseContent を呼ばないContent ではなく Host で束ねると、Host がすべてを知るファイルに育つ
feature のパッケージは決まっているfeature の中のファイルは、ルートか、content・section・component のどれかの下にある。build/generated の下の生成コードは対象外text のような独自のパッケージは、誰も決めていない置き場所になる
Content は ViewModel を束ねるcontent の下で section と component の外にある Composable を持つファイルは PulseContent を呼ぶViewModel と UI が出会うのは Content だけ。何も束ねていないファイルは、置き場所を間違えた Section
Section と Component は純粋section と component の下の Composable は …ViewModel や …Container を引数に取らず、PulseContent も PulseHost も呼ばないデータとコールバックだけで済むから、単体で Preview でき、使い回せる
Section と Component には Preview があるsection と component の下の public な Composable には、対応する @Preview がある純粋な Composable は Preview があれば単体で確認でき、壊れたときに最初に気づける

Preview の名前 ​

Preview は名前で Section や Component と対応付けます。<Name>_Preview、または特定の状態を示す <Name>_Preview_<条件> です。1 つあれば十分で、状態がいくつもあるものは条件ごとに置けます。それ以外の名前の Preview は数えないので、Composable だけリネームして Preview が古い名前のまま残った場合も検出できます。

PreviewPulseTextButton に対応するか
PulseTextButton_Previewする
PulseTextButton_Preview_Disabledする
PulseTextButton_Preview_Disabled_Darkする
PulseTextButtonPreviewしない

Repository ​

assertPulseDomainConventions() は、Repository がデータをどう公開するかを検査します。Repository は、名前が Repository で終わる class または interface です。

検査ルール理由
購読する値は Flow で公開するpublic プロパティはコールド Flow または StateFlow読む側はその値を購読し続ける。SharedFlow は購読前の値を落とし、public な MutableStateFlow は呼び出し側に書き換えさせ、ただの値は読んだ時点で古くなる
ワンショットの取得は Result で返す値を返す public 関数は Flow、StateFlow、または kotlin-result の Result<V, E> を返すワンショットの呼び出しは失敗しうる。Result<V, E> なら失敗とその種類が型に現れる。エラーの型を持たない kotlin.Result は対象外

Repository に許されていること ​

次のものは許可されていて、Repository が公開するデータとしては数えません。

  • 値を返さない関数はコマンドとして扱い、検査しない
  • 別の Repository や、Data モジュールが公開する関数などを保持するプロパティは、Repository が使うもの
  • public な StateFlow の裏にある private な MutableStateFlow は、Repository が自分の状態を変えるためのもの

UseCase ​

ViewModel は Repository を直接呼んでかまわないので、UseCase は必須ではありません。2 つの Flow を組み合わせる、動くかどうかを判断するなど、UseCase 自身の仕事があるときだけ作ります。assertPulseDomainConventions() は、作った UseCase を検査します。UseCase は、名前が UseCase で終わる class です。

検査ルール理由
UseCase はパッケージを分けるUseCase は usecase パッケージに置くエンティティ・Repository の interface・UseCase は読む目的が違うので、それぞれに置き場所を持たせる
UseCase は自分の仕事をするUseCase の public 関数は 1 行にしない1 行の UseCase は Repository に呼び出しを渡すだけで、ViewModel が自分で呼べる処理のために、読むクラスと組み立てるクラスが 1 つ増える

モジュール ​

assertPulseModuleConventions() はマルチモジュールのアプリを検査します。モジュール名は Now in Android にならい、依存の向きは Clean Architecture に従います。

層 ​

各モジュールはどれか 1 つの層に属します。ここに挙げた名前以外のモジュールは検査しません。

モジュール層
appすべてをつなぐアプリ本体
feature/<name>1 つの機能の Host・Content・Section・Component・ViewModel。feature/<name>/api と /impl は 1 つの feature として扱う
core/domainエンティティ、Repository の interface、UseCase(作る場合)
core/dataRepository の実装と、それが読む DataSource
core/designsystemテーマと、すべての画面で使う部品
core/uidomain のエンティティを描く、feature 間で共有する Composable
その他の core/<name>core/common や core/testing などの補助

依存の向き ​

domain が中心で、どこにも依存しません。Now in Android では domain モジュールが data モジュールに依存しますが、ここでは domain が Repository の interface を持ち、core/data がそれを実装します。feature は domain を通してだけデータに触れ、実装は app が渡します。

designsystem もどこにも依存しません。部品は文字列・数値・コールバックを受け取るので、何を表示する画面でも使えます。エンティティを受け取る Composable は core/ui に置きます。core/ui は domain と designsystem の両方を使えます。

ルール ​

依存は各ファイルの import から読み取るので、検査するのは実際に使われているコードです。

検査ルール
app に依存しないどのモジュールも app から import しない
feature は独立しているfeature はほかの feature から import しない
core は feature に依存しないcore のモジュールは feature から import しない
domain は何にも依存しないdomain は補助モジュールからだけ import する
data は domain を実装するdata は domain と補助モジュールからだけ import する
designsystem は独立しているdesignsystem は補助モジュールからだけ import する
共有 UI は描くだけ共有 UI は domain、designsystem、自身、補助モジュールからだけ import する
feature は domain 経由でデータに触れるfeature は data から何も import しない
domain はフレームワークに依存しないdomain は Compose、AndroidX Lifecycle、PulseDesktop から import しない
designsystem は種類ごとに分けるdesignsystem のファイルは、種類の名前のパッケージにある

パッケージ ​

domain と data の中では、パッケージで宣言の種類を表します。ファイルは次のどれかのパッケージに置きます。パッケージ名のどの部分が当たってもよく、com.example.core.domain.model も com.example.core.domain.model.area も model として扱います。

local の DataSource は永続化だけを担い、渡されたものを読む・書く・消すだけです。カウントを 1 増やす、値がないときをどう扱うかといった、値の変え方は Repository が決めます。

designsystem は、種類ごとに分けます。中身はすべて部品なので、component というパッケージ名では何が入っているのか分かりません。種類ごとのパッケージにすれば、ボタンがどこにあるかがすぐ分かります。ルートはモジュールのディレクトリと同じ名前のパッケージ(core/designsystem なら designsystem)で、その直下にはファイルを置きません。

各層のパッケージを表に、典型的な構成をその下に示します。

層パッケージ
domainエンティティは model、Repository の interface は repository、UseCase は usecase
dataRepository の実装は repository、DataSource は local、DataSource が保存する形は model
designsystembutton・card・theme のように種類ごとに 1 つ。component・components・widget・widgets・composable・composables・common のような何でも入る名前は使わない
core/domain/…/domain/
├── model/          # PulseArea
├── repository/     # PulseCountRepository
└── usecase/        # ReceivePulseUseCase
core/data/…/data/
├── local/          # PulseCountLocalDataSource
├── model/          # DataSource が保存する形(エンティティと異なる場合)
└── repository/     # DataStorePulseCountRepository
core/designsystem/…/designsystem/
├── button/         # PulseButton, PulseTextButton
├── card/           # PulseCard
└── theme/          # PulseDesktopTheme

別のモジュール名 ​

モジュール名は PulseModuleLayout で決まります。層ごとにパスのパターンを並べ、モジュールのディレクトリの末尾と照合します。* は 1 階層を表します。層は順に判定するので、support の広いパターンは、ほかの層が取らなかったモジュールだけを拾います。モジュールの検査と Koin の検査は、すべてレイアウトを受け取ります。

kotlin
val layout =
    PulseModuleLayout(
        app = listOf("desktop"),
        features = listOf("features/*"),
        domain = listOf("shared/domain"),
        data = listOf("shared/data"),
        sharedUi = listOf("shared/widget"),
        support = listOf("shared/*"),
        designSystem = listOf("shared/theme"),
    )

Konsist.scopeFromProduction().assertPulseModuleConventions(layout)

Koin ​

assertPulseKoinConventions() は Koin を使うアプリのためのもので、Koin を使っていないプロジェクトは通ります。制限するのは Koin モジュールの定義だけで、koinInject、getKoin、parametersOf による取得はどこでも構いません。モジュールの検査と同じ PulseModuleLayout を使います。

検査ルール理由
Koin モジュールは app に置くKoin モジュールを定義するファイル(org.koin.dsl.module か Koin Annotations の @Module を使うもの)は app モジュールにあるapp はほかのすべてを知る唯一のモジュールなので、依存の全体を 1 か所で読める。feature や core が自分で Koin モジュールを持つと、その判断の一部を app から奪う

文言 ​

assertPulseResourceConventions() は、アプリを翻訳できる状態に保ちます。描画するモジュール(feature、core/ui、core/designsystem)は、それぞれ自分の文言を Compose Multiplatform の composeResources に文字列リソースとして持ち、stringResource(Res.string.…) で読みます。翻訳は values の隣に置く values-<言語> ディレクトリです。モジュールの検査と同じ PulseModuleLayout を使います。

ラベルが必要なエンティティには、描画するモジュールでラベルを付けます。検査を表に、デモがエリアの位置にラベルを付ける例をその下に示します。

検査ルール理由
domain は文言を持たないdomain の文字列リテラルに文字を含めないdomain に置いたラベルは、それを表示するすべての画面で 1 つの言語になる。domain はケースを型で表し、描画するモジュールが文字列リソースに対応付ける
Composable は文言をリソースから読むComposable は Text("…") や、text・title・label・placeholder・contentDescription の引数にリテラルを渡さないそこに書いたリテラルは 1 つの言語でしか読めない。"$count" のように値を埋め込むだけのリテラルと、@Preview の中は対象外
kotlin
// feature/count — domain の enum は自分のラベルを持たない
internal val PulseAreaPosition.label: StringResource
    get() =
        when (this) {
            PulseAreaPosition.TopLeft -> Res.string.position_top_left
            PulseAreaPosition.TopRight -> Res.string.position_top_right
            PulseAreaPosition.BottomLeft -> Res.string.position_bottom_left
            PulseAreaPosition.BottomRight -> Res.string.position_bottom_right
        }

検査を選ぶ ​

それぞれの検査は単体でも公開しています。合わない検査があるプロジェクトは、ほかの検査だけを実行できます。

kotlin
@Test
fun `follows the conventions we agreed on`() {
    val scope = Konsist.scopeFromProduction()
    scope.assertStatesAreDataTypes()
    scope.assertMessagesAreSealedOrDataTypes()
    scope.assertViewModelsHoldTheirDataInTheirState()
}

自分のルールを書く ​

検査の土台になっているクエリも公開しているので、その上に独自のルールを足せます。pulseViewModels()、pulseContainers()、pulseStateClasses()、pulseStateObjects()、pulseMessageInterfaces()、pulseMessageClasses()、pulseMessageObjects() は、対応する型を継承・実装している宣言を返します。pulseFeatureRoots() は各 feature のルートのパッケージを返し、pulseHosts()、pulseContents()、pulseSections()、pulseComponents() は各役割の Composable を持つファイルを返します。pulseRepositoryClasses()、pulseRepositoryInterfaces()、pulseRepositories() は Repository を、pulseKoinModuleFiles() は Koin モジュールを定義するファイルを返し、pulseModule(layout) はファイルが属するモジュールの名前を返します。

kotlin
@Test
fun `every ViewModel lives in a feature package`() {
    Konsist
        .scopeFromProduction()
        .pulseViewModels()
        .assertTrue { it.resideInPackage("..feature..") }
}

できないこと ​

Konsist には ktlint のルールセットや detekt のプラグインのような読み込みの仕組みがありません。アーティファクト自身が勝手に走ることはできないので、呼び出すテストはプロジェクト側で書くことになります。

Konsist が読むのはコンパイル済みのクラスではなくソースコードです。ライブラリ側の基底クラスを経由して PulseViewModel に到達する ViewModel は、その基底クラスが走査対象のソースにないため認識できません。

Konsist は Gradle の build script も読まないので、モジュールの検査は import から依存を見つけます。build script で宣言していても、一度も import していない依存は見えません。

対象が 0 件のときは成功します。PulseDesktop の型がまだないモジュールでも落ちないので、検査するコードより先にテストを置けます。

次のステップ ​

Apache 2.0 License の下で公開されています。