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.
| Assertion | What 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.
// 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.
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.
| Check | Rule | Why |
|---|---|---|
| States are data types | a PulseState is a data class or data object | StateFlow 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 classes | an 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 one | each 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 Contracts | a PulseState, Action, Event, Broadcast or Unicast is in a …Contracts.kt file in the package of a ViewModel or Container | they 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 class | a PulseViewModel is named …ViewModel, a PulseContainer is named …Container | both are ViewModel subclasses to the compiler, so the name is what tells a reader which one this is |
| Data lives in the state | a ViewModel and a Container declare no var property | a 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| Check | Rule | Why |
|---|---|---|
| ViewModels live in contents | a PulseViewModel is under content, outside section and component | a 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 root | a PulseContainer is in the package that holds content | the Container coordinates every content of the feature, so it sits beside the host that creates it |
| Hosts compose contents | a composable at the feature root is in a file named …Host and does not call PulseContent | binding a ViewModel in the host instead of a content is how a host grows into the file that knows everything |
| Features have only their packages | inside a feature, every file is at the root or under content, section or component; generated code under build/generated is skipped | a package of its own, such as text, is one more place nobody decided on |
| Contents bind a ViewModel | a file with a composable under content, outside section and component, calls PulseContent | the 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 pure | a composable under section or component takes no …ViewModel or …Container parameter, and calls neither PulseContent nor PulseHost | data and callbacks are what make them previewable on their own and reusable |
| Sections and components have previews | every public composable under section or component has a matching @Preview | a 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.
| Preview | Matches PulseTextButton |
|---|---|
PulseTextButton_Preview | yes |
PulseTextButton_Preview_Disabled | yes |
PulseTextButton_Preview_Disabled_Dark | yes |
PulseTextButtonPreview | no |
Repositories
assertPulseDomainConventions() checks how a repository hands out its data. A repository is a class or an interface whose name ends with Repository.
| Check | Rule | Why |
|---|---|---|
| Values to follow are flows | a public property is a cold Flow or a StateFlow | whoever 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 result | a 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
MutableStateFlowbehind a publicStateFlowis 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.
| Check | Rule | Why |
|---|---|---|
| Use cases have a package | a use case is in a usecase package | the 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 own | no public function of a use case is a single line | a 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.
| Module | Layer |
|---|---|
app | the 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/domain | the entities, the repository interfaces, and the use cases when there are any |
core/data | the repository implementations and the data sources they read |
core/designsystem | the theme and the building blocks every screen is drawn with |
core/ui | composables 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.
| Check | Rule |
|---|---|
| Nothing depends on the app | no module imports from the app |
| Features are independent | a feature imports from no other feature |
| The core never depends on features | no core module imports from a feature |
| The domain depends on nothing | the domain imports only from support modules |
| The data layer implements the domain | the data layer imports only from the domain and support |
| The design system stands alone | the design system imports only from support |
| Shared UI only draws | the shared UI imports only from the domain, the design system, itself and support |
| Features reach data through the domain | a feature imports nothing from the data layer |
| The domain is framework-free | the domain imports nothing from Compose, AndroidX Lifecycle or PulseDesktop |
| The design system is split by kind | every 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.
| Layer | Packages |
|---|---|
| domain | model for the entities, repository for the repository interfaces, usecase for the use cases |
| data | repository for the repository implementations, local for the data sources, model for what those sources store |
| design system | one 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/ # PulseDesktopThemeOther 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.
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.
| Check | Rule | Why |
|---|---|---|
| Koin modules live in the app | a file that declares a Koin module — with org.koin.dsl.module or Koin Annotations' @Module — is in the app module | the 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.
| Check | Rule | Why |
|---|---|---|
| The domain holds no text | no string literal in the domain contains a letter | a 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 resources | a composable hands no literal to Text("…") or to a text, title, label, placeholder or contentDescription argument | a literal there can only be read in one language. A literal that only places values, such as "$count", and anything inside a @Preview pass |
// 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.
@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.
@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
- Architecture checks API — the signature of every assertion and query
- Architecture — the conventions these checks come from
- ViewModel — state, actions and the lifecycle hooks