Skip to content

Architecture checks ​

architecture is an optional artifact. It states the conventions PulseDesktop expects of the code around it as Konsist assertions, so your own test suite checks them.

The compiler already rejects what the types can express. A ViewModel must implement onAction, and its message types must be the ones its Container knows. What is left are habits the types cannot express, each with a consequence that is easy to miss in review.

The checks come in five groups, one assertion each. Every group can be used without the others.

AssertionWhat it checks
assertPulseMviConventions()the state and message types and where they live, the ViewModel and Container names and places, how a feature is split into a host, contents, sections and components, and that every section and component has a preview
assertPulseDomainConventions()how a repository hands out its data, and what earns a use case its place
assertPulseModuleConventions()which module may depend on which, and the packages inside the domain and the data layer
assertPulseKoinConventions()where Koin modules are declared
assertPulseResourceConventions()where the text a user reads comes from

Setup ​

Add the artifact to the test classpath. Konsist itself comes with it, so there is nothing else to declare.

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

Then write one test. scopeFromProduction() reads the project's main source sets, leaving test code out.

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

Declarations ​

assertPulseMviConventions() runs twelve checks: five about the declarations and seven about the views. A failure names the declaration and says how to fix it.

CheckRuleWhy
States are data typesa PulseState is a data class or data objectStateFlow drops an emission equal to the last one, and Compose skips a composable whose arguments are equal. Without equals the screen recomposes on every update
Messages are sealed classesan Action, Event, Broadcast or Unicast is a sealed class, and each case inside it is a data class or data object; a message with one case may be a single data class or data object, and an interface is never oneeach one is consumed by a when. Sealing is what turns a new case into a compile error instead of a branch nobody wrote, and data lets a case compare and print by its value
States and messages live in Contractsa PulseState, Action, Event, Broadcast or Unicast is in a …Contracts.kt file in the package of a ViewModel or Containerthey are what a ViewModel or a Container agrees on, so a reader finds them in one file beside the class that uses them: PulseAreaContracts.kt beside PulseAreaViewModel, PulseCountContracts.kt beside PulseCountContainer
Names follow the base classa PulseViewModel is named …ViewModel, a PulseContainer is named …Containerboth are ViewModel subclasses to the compiler, so the name is what tells a reader which one this is
Data lives in the statea ViewModel and a Container declare no var propertya var beside the state changes without an emission, so nothing recomposes

Views ​

A feature is split into four roles. The host is what a route shows: it creates the ViewModels and the Container and lays the contents out. A content is the part one ViewModel answers for and binds it with PulseContent. A section draws a part of the screen from a state and callbacks, and a component is a small building block. Sections and components are both pure; only their role differs.

The package decides which is which, so names stay free except the host's. The root of a feature is the package its content package sits in, and it holds the host (a file named …Host), the Container and the Container's Contracts. Each content has a package of its own under content, with its ViewModel and Contracts beside it. Sections and components may sit at the root of the feature or inside the one content that uses them, and those three are the only packages under the root. A pure helper the whole feature uses, such as a label read with stringResource, goes in component; a composable that only returns a value is not a view, so it needs no preview.

count/
├── PulseCountHost.kt          # host
├── PulseCountContainer.kt
├── PulseCountContracts.kt     # the Container's broadcasts and unicasts
├── component/                 # components shared by the feature
│   └── PulseCountHeader.kt
└── content/
    └── area/
        ├── PulseAreaContent.kt    # content
        ├── PulseAreaViewModel.kt
        ├── PulseAreaContracts.kt  # the ViewModel's state, actions and events
        ├── section/               # sections of this content
        │   └── PulseAreaSection.kt
        └── component/             # components of this content
            └── PulseAreaCell.kt
CheckRuleWhy
ViewModels live in contentsa PulseViewModel is under content, outside section and componenta ViewModel answers for one content, so it sits beside the composable that binds it and the pure parts below never see it
Containers live at the feature roota PulseContainer is in the package that holds contentthe Container coordinates every content of the feature, so it sits beside the host that creates it
Hosts compose contentsa composable at the feature root is in a file named …Host and does not call PulseContentbinding a ViewModel in the host instead of a content is how a host grows into the file that knows everything
Features have only their packagesinside a feature, every file is at the root or under content, section or component; generated code under build/generated is skippeda package of its own, such as text, is one more place nobody decided on
Contents bind a ViewModela file with a composable under content, outside section and component, calls PulseContentthe content is the only place a ViewModel and the UI meet. A file that binds nothing is a section in the wrong package
Sections and components are purea composable under section or component takes no …ViewModel or …Container parameter, and calls neither PulseContent nor PulseHostdata and callbacks are what make them previewable on their own and reusable
Sections and components have previewsevery public composable under section or component has a matching @Previewa preview is all a pure composable needs to be seen on its own, and it is the first thing that breaks when the composable does

Preview names ​

A preview matches its section or component by name: <Name>_Preview, or <Name>_Preview_<Condition> for a particular state. One match is enough, and one with several states can have one per condition. A preview named any other way does not count, which is how a renamed composable left without its preview is caught.

PreviewMatches PulseTextButton
PulseTextButton_Previewyes
PulseTextButton_Preview_Disabledyes
PulseTextButton_Preview_Disabled_Darkyes
PulseTextButtonPreviewno

Repositories ​

assertPulseDomainConventions() checks how a repository hands out its data. A repository is a class or an interface whose name ends with Repository.

CheckRuleWhy
Values to follow are flowsa public property is a cold Flow or a StateFlowwhoever reads it keeps following it. A SharedFlow drops what came before the collector, a public MutableStateFlow lets the caller write past the repository, and a plain value is stale once read
One-shot calls return a resulta public function that returns a value returns a Flow, a StateFlow or kotlin-result's Result<V, E>a one-shot call can fail, and Result<V, E> puts the failure and its kinds in the type. kotlin.Result has no error type, so it does not count

What a repository may do ​

These are allowed and are not counted as data the repository hands out.

  • A function that returns nothing is a command and is not checked
  • A property holding another repository, or a function such as one a data module exposes, is what the repository uses
  • A private MutableStateFlow behind a public StateFlow is how the repository changes its own state

Use cases ​

A ViewModel may call a repository directly, so a use case is optional. Write one only when it has work of its own, such as combining two flows or deciding whether to act. assertPulseDomainConventions() checks the use cases there are. A use case is a class whose name ends with UseCase.

CheckRuleWhy
Use cases have a packagea use case is in a usecase packagethe entities, the repository interfaces and the use cases are read for different reasons, so each has its own place
Use cases do work of their ownno public function of a use case is a single linea one-line use case only hands the call on to a repository. It is a class to read and wire for a call the ViewModel can make itself

Modules ​

assertPulseModuleConventions() checks a multi-module app. The modules are named after Now in Android's, and the dependencies point the way Clean Architecture asks.

Layers ​

Each module belongs to one layer. A module outside these names is not checked.

ModuleLayer
appthe application, which wires everything together
feature/<name>one feature's host, contents, sections, components and ViewModels; feature/<name>/api and /impl count as one feature
core/domainthe entities, the repository interfaces, and the use cases when there are any
core/datathe repository implementations and the data sources they read
core/designsystemthe theme and the building blocks every screen is drawn with
core/uicomposables shared between features that draw the domain's entities
any other core/<name>support, such as core/common or core/testing

Direction ​

The domain is the centre and depends on nothing. Unlike Now in Android, where the domain module depends on the data module, the domain owns the repository interfaces and core/data implements them. A feature reaches data only through the domain, and the app hands it the implementation.

The design system depends on nothing either. Its building blocks take text, numbers and callbacks, so any screen can use them whatever it shows. A composable that takes an entity belongs in core/ui, which may use both the domain and the design system.

Rules ​

Dependencies are read from the imports in each file, so what is checked is the code that is actually used.

CheckRule
Nothing depends on the appno module imports from the app
Features are independenta feature imports from no other feature
The core never depends on featuresno core module imports from a feature
The domain depends on nothingthe domain imports only from support modules
The data layer implements the domainthe data layer imports only from the domain and support
The design system stands alonethe design system imports only from support
Shared UI only drawsthe shared UI imports only from the domain, the design system, itself and support
Features reach data through the domaina feature imports nothing from the data layer
The domain is framework-freethe domain imports nothing from Compose, AndroidX Lifecycle or PulseDesktop
The design system is split by kindevery file of the design system is in a package named after its kind

Packages ​

Inside the domain and the data layer, the package says what a declaration is. A file sits in one of these packages, and any part of its package name may be the one: com.example.core.domain.model and com.example.core.domain.model.area both count as model.

A data source in local only persists: it reads, writes and removes what it is given. How a value changes — adding one to a count, deciding what a missing value means — is the repository's.

The design system is split by kind instead. Everything in it is a building block, so a package called component says nothing about what is inside it; a package per kind tells a reader where a button is. The root is the package named after the module's directory, designsystem for core/designsystem, and no file sits directly in it.

The table lists the packages of each layer, and a typical layout follows it.

LayerPackages
domainmodel for the entities, repository for the repository interfaces, usecase for the use cases
datarepository for the repository implementations, local for the data sources, model for what those sources store
design systemone package per kind, such as button, card or theme; never a catch-all such as component, components, widget, widgets, composable, composables or common
core/domain/…/domain/
├── model/          # PulseArea
├── repository/     # PulseCountRepository
└── usecase/        # ReceivePulseUseCase
core/data/…/data/
├── local/          # PulseCountLocalDataSource
├── model/          # what the data source stores, when it differs from the entity
└── repository/     # DataStorePulseCountRepository
core/designsystem/…/designsystem/
├── button/         # PulseButton, PulseTextButton
├── card/           # PulseCard
└── theme/          # PulseDesktopTheme

Other module names ​

The names are a PulseModuleLayout. Each layer is a list of path patterns matched against the end of a module's directory, with * standing for one directory. Layers are tried in order, so a broad support pattern only takes what the others left. Every module assertion, and the Koin one, takes a layout.

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() is for an app that uses Koin, and a project without Koin passes. Only declaring a Koin module is restricted: resolving from Koin with koinInject, getKoin or parametersOf is allowed anywhere. The check takes the same PulseModuleLayout as the module checks.

CheckRuleWhy
Koin modules live in the appa file that declares a Koin module — with org.koin.dsl.module or Koin Annotations' @Module — is in the app modulethe app is the one module that knows every other, so it is the one place the whole graph can be read. A feature or core module with its own Koin module takes part of that decision away

Text ​

assertPulseResourceConventions() keeps an app translatable. Every module that draws — a feature, core/ui, core/designsystem — keeps its text in its own string resources, with Compose Multiplatform's composeResources, and reads it with stringResource(Res.string.…). A translation is a values-<language> directory beside values. The check takes the same PulseModuleLayout as the module checks.

An entity that needs a label gets it in the module that draws it. The checks are in the table, and the demo labelling the position of an area follows it.

CheckRuleWhy
The domain holds no textno string literal in the domain contains a lettera label in the domain is in one language for every screen that shows it. The domain names a case with a type, and the module that draws it maps it to a string resource
Composables read text from resourcesa composable hands no literal to Text("…") or to a text, title, label, placeholder or contentDescription argumenta literal there can only be read in one language. A literal that only places values, such as "$count", and anything inside a @Preview pass
kotlin
// feature/count — the domain's enum has no label of its own
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
        }

Choosing the checks ​

Each check is public on its own. A project that disagrees with one can run the others.

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

Writing your own rules ​

The queries behind the checks are public too, so a project can add rules of its own on top of them. pulseViewModels(), pulseContainers(), pulseStateClasses(), pulseStateObjects(), pulseMessageInterfaces(), pulseMessageClasses() and pulseMessageObjects() return the declarations that extend or implement the matching type. pulseFeatureRoots() returns the root package of each feature, and pulseHosts(), pulseContents(), pulseSections() and pulseComponents() return the files that hold composables of each role. pulseRepositoryClasses(), pulseRepositoryInterfaces() and pulseRepositories() return the repositories, pulseKoinModuleFiles() the files that declare Koin modules, and pulseModule(layout) names the module a file belongs to.

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

What it cannot do ​

Konsist has no plugin mechanism, unlike ktlint's rule sets or detekt's plugins. The artifact cannot install itself, which is why your project writes the test that calls it.

Konsist also reads source code rather than compiled classes. A ViewModel that reaches PulseViewModel through a base class from a library is not recognised, because that base class is not in the sources being scanned.

Konsist does not read Gradle build scripts either, so the module checks find dependencies through imports. A dependency that is declared in a build script but never imported is not seen.

An empty result passes. A module with no PulseDesktop types yet does not fail, so the test can be added before the code it checks.

Next steps ​

Released under the Apache 2.0 License.