Three-layer architecture following the Android architecture guide

The app architecture guide published by Android Developers is widely known as the baseline architecture for Android applications.
Below is an example architecture definition for a setup that adopts a domain layer.
Example directory structure
Section titled “Example directory structure”Directoryapp/src/main/kotlin/com/example/project/
Directoryui/
Directoryhome/
- HomeUiState.kt
- HomeViewModel.kt
- HomeScreen.kt
Directorydomain/
Directoryuser/
- GetUserUseCase.kt
Directorymodel/
- User.kt
Directorydata/
Directoryuser/
- UserRepository.kt
- UserRepositoryImpl.kt
- UserRemoteDataSource.kt
Architecture definition
Section titled “Architecture definition”@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.architectureimport me.tbsten.katachi.dsl.gradle.gradle
/** * See https://developer.android.com/topic/architecture */val projectArchitecture = architecture { gradle() // Gradle files such as the wrapper, settings and build scripts
// 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.ktconst val appPackageDir = "com/example/project"
// roles/UiState.kt@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.RoleScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFileimport me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.uiState() = "UiState" { summary = "Holds the state a screen renders, as an immutable value" layout { "app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*UiState".ktFile() }
shouldBeImmutable()}
private fun RoleScope.shouldBeImmutable() = "All properties are val".konsist { classes().must { klass -> klass.properties().all { it.isVal } } }
// roles/ViewModel.kt@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.RoleScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFileimport me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.viewModel() = "ViewModel" { summary = "Exposes a screen's state as a StateFlow and receives events from the screen" layout { "app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*ViewModel".ktFile() }
shouldContainsViewModelClass() shouldExtendsViewModel() shouldUiStateFlowBePublic()}
private fun RoleScope.shouldContainsViewModelClass() = "Declares exactly one class named after the file".konsist { files.must { file -> file.classes().map { it.name } == listOf(file.name) } }
private fun RoleScope.shouldExtendsViewModel() = "Extends ViewModel".konsist { classes().must { klass -> klass.parents().any { it.name == "ViewModel" } } }
private fun RoleScope.shouldUiStateFlowBePublic() = "Exposes the UiState Flow as 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.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.RoleScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFileimport me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.screen() = "Screen" { summary = "The public Composable that draws a screen" layout { "app" / mainSourceSet / kotlin / appPackageDir / "ui/*" / "*Screen".ktFile() }
shouldContainsPublicScreenComposable()}
private fun RoleScope.shouldContainsPublicScreenComposable() = "Declares a public Composable named after the file".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.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.RoleScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFileimport me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.useCase() = "UseCase" { summary = "Runs one app-specific behavior through invoke" layout { "app" / mainSourceSet / kotlin / appPackageDir / "domain/*" / "*UseCase".ktFile() }
shouldContainsUseCaseClass() shouldSingleInvokePublicMethod()}
private fun RoleScope.shouldContainsUseCaseClass() = "Declares exactly one class named after the file".konsist { files.must { file -> file.classes().map { it.name } == listOf(file.name) } }
private fun RoleScope.shouldSingleInvokePublicMethod() = "Has exactly one public invoke method".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.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFile
fun DeclarationContainerScope.domainModel() = "DomainModel" { summary = "Models that represent the app's concepts in the domain layer" layout { "app" / mainSourceSet / kotlin / appPackageDir / "domain/model" / "*".ktFile() }}
// roles/RepositoryInterface.kt@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.RoleScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFileimport me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.repositoryInterface() = "RepositoryInterface" { summary = "The interface that serves as the entry point for reading and storing data" layout { "app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*Repository".ktFile() }
shouldContainsRepositoryInterface()}
private fun RoleScope.shouldContainsRepositoryInterface() = "Declares exactly one interface named after the file".konsist { files.must { file -> file.interfaces().map { it.name } == listOf(file.name) } }
// roles/RepositoryImplementation.kt@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.RoleScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFileimport me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.repositoryImplementation() = "RepositoryImplementation" { summary = "The implementation of RepositoryInterface" layout { "app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*RepositoryImpl".ktFile() }
shouldContainsRepositoryImplementationClass()}
private fun RoleScope.shouldContainsRepositoryImplementationClass() = "Declares exactly one class named after the file".konsist { files.must { file -> file.classes().map { it.name } == listOf(file.name) } }
// roles/DataSource.kt@file:OptIn(ExperimentalKatachiApi::class)
import me.tbsten.katachi.ExperimentalKatachiApiimport me.tbsten.katachi.dsl.DeclarationContainerScopeimport me.tbsten.katachi.dsl.RoleScopeimport me.tbsten.katachi.dsl.gradle.kotlinimport me.tbsten.katachi.dsl.gradle.mainSourceSetimport me.tbsten.katachi.dsl.kotlin.ktFileimport me.tbsten.katachi.konsist.konsist
fun DeclarationContainerScope.dataSource() = "DataSource" { summary = "Access to the sources and stores of data that a Repository uses" layout { "app" / mainSourceSet / kotlin / appPackageDir / "data/*" / "*DataSource".ktFile() }
shouldContainsDataSourceClass()}
private fun RoleScope.shouldContainsDataSourceClass() = "Declares exactly one class named after the file".konsist { files.must { file -> file.classes().map { it.name } == listOf(file.name) } }Because this uses konsist { }, add a dependency on katachi-konsist to :architecture-test and call assert(FileConstraintCheck()) in the test. With assert() alone the constraints are not evaluated and the test fails with [UncheckedFileConstraint]. See Konsist integration for details.
A real definition that splits the layers into groups and the kinds of files into Roles is in ProjectArchitecture.kt in sample/android. The sample has no domain layer and splits its modules by screen, so its definition is shaped differently from the one on this page.