arkitekt
Arkitekt is a set of architectural tools based on Android Architecture Components, which gives you a solid base to implement the concise, testable and solid application.
Installation
dependencies {
// Android / Compose
implementation("app.futured.arkitekt:core:LatestVersion")
implementation("app.futured.arkitekt:cr-usecases:LatestVersion")
implementation("app.futured.arkitekt:compose:LatestVersion")
// Decompose / KMP (optional)
implementation("app.futured.arkitekt:decompose:LatestVersion")
implementation("app.futured.arkitekt:decompose-annotation:LatestVersion")
ksp("app.futured.arkitekt:decompose-processor:LatestVersion")
// Testing
testImplementation("app.futured.arkitekt:core-test:LatestVersion")
testImplementation("app.futured.arkitekt:cr-usecases-test:LatestVersion")
}
Snapshot installation
Add new maven repo to your top level gradle file.
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
Snapshots are grouped based on major version, so for version 6.x use:
implementation "app.futured.arkitekt:core:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:cr-usecases:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:compose:6.X.X-SNAPSHOT"
implementation "app.futured.arkitekt:decompose:6.X.X-SNAPSHOT"
Features
Arkitekt is a modern Android & Kotlin Multiplatform architecture library focused on Jetpack Compose and Kotlin Coroutines. It combines built-in support for ViewModel, Coroutines use cases, Jetpack Compose, and Decompose (KMP). For dependency injection, the Android/Compose path uses Dagger-Hilt, while the Decompose/KMP path uses Koin.
Note: As of version 6.x, Arkitekt has removed legacy LiveData-based components, Fragment/Activity base classes, RxJava support, DataBinding, and the Dagger module. The library is now exclusively focused on Jetpack Compose with State/StateFlow for reactive UI and Kotlin Multiplatform via Decompose.

Migration Guide (5.x → 6.x)
Version 6.x represents a major refactoring focused on modern Android development with Jetpack Compose. The following legacy components have been removed:
Removed Modules
rx-usecasesandrx-usecases-test- RxJava support has been completely removed. Usecr-usecases(Coroutines) insteaddagger- Dagger 2 injection module removed. Use Dagger-Hilt (@HiltViewModel,@AndroidEntryPoint) insteadbindingadapters- DataBinding adapters removed. Use Jetpack Compose insteadexample-minimalandexample-hilt- Consolidated into singleexamplemodule
Removed Classes
ViewModel Base Classes:
BaseViewModel(from core) - UseBaseCoreViewModel(core) orBaseViewModel(compose) insteadBaseCrViewModel(from cr-usecases) - UseBaseViewModel(compose) instead
Fragment/Activity Base Classes:
ViewModelActivity,BindingViewModelActivity- Use standardComponentActivitywith@AndroidEntryPointViewModelFragment,BindingViewModelFragment- Use standard Compose navigationViewModelBottomSheetDialogFragment,BindingViewModelBottomSheetDialogFragment- Use Compose bottom sheetsViewModelDialogFragment,BindingViewModelDialogFragment- Use Compose dialogs
Dagger Classes:
BaseViewModelFactory,BaseSavedStateViewModelFactory- Use@HiltViewModelwithhiltViewModel()BaseDaggerActivity,BaseDaggerFragmentand their Binding variants - Use@AndroidEntryPointViewModelCreator,ViewModelFactory- No longer needed with Hilt
LiveData Components:
LiveEventandLiveEventBus- UseEventwithChannel-based eventsDefaultValueLiveDataandDefaultValueMediatorLiveData- UseStateFlowor ComposeStateNonNullLiveData- UseStateFlowor ComposeStateUiData,UiDataExtensions,UiDataMediator- UseStateFlowor ComposeStateLiveDataExtensionsandLiveDataUtils- Use Kotlin Flow operators
DataBinding:
- All DataBinding support (
ViewDataBindingbase classes, binding adapters) - Use Jetpack Compose
Migration Path
- Replace Fragment/Activity base classes with standard Android components annotated with
@AndroidEntryPoint - Replace LiveData with
StateFlow(for ViewModels) orState(for Compose) - Replace LiveEvent with Channel-based
Eventsystem (see Events section) - Migrate to Jetpack Compose for UI layer
Usage
Table of contents
- Getting started - Minimal project file hierarchy
- Use Cases
- Propagating data model changes into UI
- Stores (Repositories)
Getting Started - Minimal project file hierarchy
Minimal working project must contain files as presented in example
module. File hierarchy might look like this:
example
`-- src/main
|-- java/app/futured/arkitekt/sample
| |-- ui
| | |-- main
| | | `-- MainActivity.kt
| | `-- home
| | |-- HomeScreen.kt
| | |-- HomeViewModel.kt
| | `-- HomeViewState.kt
| `-- App.kt
`-- res/values/strings.xml
Let's describe individual files one by one:
App.kt
Application class must be annotated with @HiltAndroidApp to trigger Hilt code generation. Optionally set UseCaseErrorHandler.globalOnErrorLogger for application-wide error logging in use cases.
@HiltAndroidApp
class App : Application() {
override fun onCreate() {
super.onCreate()
UseCaseErrorHandler.globalOnErrorLogger = { error ->
android.util.Log.d("UseCase error", "$error")
}
}
}
MainActivity.kt
Activity must be annotated with @AndroidEntryPoint. We use setContent to define the UI using Jetpack Compose. For a single screen, use HomeScreen() directly. For multiple screens, use a NavHost with your navigation graph (see example MainActivity).
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ArkitektTheme {
// Your navigation or single screen
HomeScreen()
}
}
}
}
HomeViewModel.kt
ViewModel annotated with @HiltViewModel. You can choose between extending
BaseCoreViewModel or BaseViewModel (for Coroutines support).
@HiltViewModel
class HomeViewModel @Inject constructor() : BaseCoreViewModel<HomeViewState>() {
override val viewState = HomeViewState()
}
HomeViewState.kt
State representation of a screen. Should contain a set of State (Compose) or StateFlow fields observed by the UI.
class HomeViewState @Inject constructor() : ViewState {
val user = mutableStateOf(User.EMPTY)
}
HomeScreen.kt
Composable function representing the UI. It obtains the ViewModel via Hilt injection.
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel()
) {
val user by viewModel.viewState.user
// UI implementation - User has firstName and lastName
Text(
text = "${user.firstName} ${user.lastName}".trim().ifEmpty { "Guest" },
modifier = modifier
)
}
Use Cases
Module cr-usecases contains base classes useful for easy execution of
background tasks based on Coroutines. Two base types are available - UseCase (single result use case)
and FlowUseCase (multi result use case).
Following example describes how to make an API call and how to deal with result of this call.
LoginUseCase.kt
class LoginUseCase @Inject constructor(
private val userStore: UserStore
) : UseCase<LoginData, Unit>() {
override suspend fun build(args: LoginData) {
userStore.setUser(User(args.firstName, args.lastName))
}
}
data class LoginData(val firstName: String, val lastName: String)
LoginViewState.kt
class LoginViewState @Inject constructor() : ViewState {
// IN - values provided by UI
val name = mutableStateOf("")
val surname = mutableStateOf("")
// OUT - Values observed by UI
val fullName = mutableStateOf("")
val isLoading = mutableStateOf(false)
}
LoginViewModel.kt
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase, // Inject UseCase
override val viewState: LoginViewState
) : BaseViewModel<LoginViewState>() {
fun logIn() = with(viewState) {
loginUseCase.execute(LoginData(name.value, surname.value)) {
onStart {
isLoading.value = true
}
onSuccess {
isLoading.value = false
fullName.value = "${viewState.name.value} ${viewState.surname.value}"
}
onError {
isLoading.value = false
// handle error
}
}
}
}
Synchronous execution of cr-usecase
Module cr-usecases allows you to execute use cases synchronously.
fun onButtonClicked() = launchWithHandler {
// ...
val data = useCase.execute().getOrDefault("Default")
// ...
}
execute method returns a Result that can be either successful Success or failed Error.
launchWithHandler launches a new coroutine encapsulated with a try-catch block. By default exception thrown in launchWithHandler is rethrown but it is possible to override this behavior with defaultErrorHandler or just log these exceptions in logUnhandledException.
Global error logger for handled errors in use-cases
In order to set an application-wide error logger for all handled errors in all use-cases, it is possible to set the following method in the Application class:
UseCaseErrorHandler.globalOnErrorLogger = { error ->
CustomLogger.logError(error)
}
The globalOnErrorLogger callback in the UseCaseErrorHandler will be called for every error thrown in all use-cases that have defined onError receiver in the execute method.
The following execute method will trigger globalOnErrorLogger:
useCase.execute {
...
onError {
isLoading = false
}
...
}
The following execute method won't trigger globalOnErrorLogger because onError is not defined and execute method will throw an unhandled exception.
useCase.execute {}
Propagating data model changes into UI
There are two main ways how to reflect data model changes in UI. Through ViewState observation
or one-shot Events.
ViewState observation
You can observe state changes and reflect these changes in UI by observing State (Compose) or StateFlow
from your viewState in your Composable:
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val myText by viewModel.viewState.myTextState
Text(text = myText)
}
Events
Events are one-shot messages sent from ViewModel to a Composable. They
are based on Channel. Events are guaranteed to be delivered only once even when
there is screen rotation in progress. Basic event communication might look like this:
If you are using Jetpack Compose, you can collect these events via EventsEffect:
viewModel.EventsEffect {
onEvent<ShowDetailEvent> { /* handle event */ }
}
HomeEvents.kt
sealed class HomeEvent : Event<HomeViewState>()
object ShowDetailEvent : HomeEvent()
HomeViewModel.kt
class HomeViewModel @Inject constructor(
override val viewState: HomeViewState
) : BaseCoreViewModel<HomeViewState>() {
fun onDetail() {
sendEvent(ShowDetailEvent)
}
}
Stores (Repositories)
All our applications respect broadly known repository pattern. The main message this
pattern tells: Define Store (Repository) classes with single entity related business logic
eg. UserStore, OrderStore, DeviceStore etc. Let's see this principle on UserStore class
from sample app:
UserStore.kt
@Singleton
class UserStore @Inject constructor() {
private val userState = MutableStateFlow(User.EMPTY)
fun setUser(user: User) {
userState.value = user
// ... optionally persist user
}
fun getUser(): StateFlow<User> = userState
}
With this approach only one class is responsible for User related data access. Besides
custom classes, Room library Daos or for example Retrofit API interfaces might be
perceived on the same domain level as stores. Thanks to use cases we can easily access,
manipulate and combine this kind of data on background threads.
class ObserveUserFullNameUseCase @Inject constructor(
private val userStore: UserStore
) : FlowUseCase<Unit, String>() {
override fun build(args: Unit): Flow<String> =
userStore.getUser().map { "${it.firstName} ${it.lastName}" }
}
We strictly respect this injection hierarchy:
| Application Component | Injects |
|---|---|
Composable |
ViewModel |
ViewModel |
ViewState, UseCase |
UseCase |
Store |
Store |
Dao, Persistence, ApiService |
Navigation
Arkitekt supports two modern navigation approaches:
Native Android Navigation (Jetpack Compose)
You can use the standard Jetpack Navigation component with Compose. See the example module for a working implementation.
Decompose (Kotlin Multiplatform)
For KMP projects, decompose module provides integration with the Decompose library using Koin for dependency injection.
BaseComponent
BaseComponent is the base class for all Decompose components. It provides coroutine scope tied to component lifecycle, state management via MutableStateFlow, and UiEvent support via Channel.
class HomeComponent(
componentContext: ComponentContext,
private val navigation: HomeNavigation,
) : BaseComponent<HomeState, HomeUiEvent>(componentContext, HomeState()) {
val state: StateFlow<HomeState> = componentState
fun onDetailClicked() {
navigation.toDetail()
}
}
data class HomeState(val title: String = "")
sealed interface HomeUiEvent : UiEvent {
data object ShowToast : HomeUiEvent
}
NavigationActions
NavigationActions and NavigationActionsProducer define navigation contracts for components:
interface HomeNavigation : NavigationActions {
fun toDetail()
fun toSettings()
}
@GenerateFactory (KSP)
Annotate components with @GenerateFactory to auto-generate Koin-based factory objects.
Requires decompose-annotation and decompose-processor dependencies.
@GenerateFactory
class HomeComponent(
@InjectedParam componentContext: AppComponentContext,
@InjectedParam navigation: HomeNavigation,
private val someUseCase: SomeUseCase, // injected by Koin
) : BaseComponent<HomeState, HomeUiEvent>(componentContext, HomeState())
This generates a HomeComponentFactory object with a createComponent(componentContext, navigation) method that resolves remaining dependencies from Koin.
KSP Configuration for KMP
In a Kotlin Multiplatform project, the processor must run in the common metadata compilation phase so generated code is available to all targets. Add the following to your module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp")
}
dependencies {
add("kspCommonMainMetadata", "app.futured.arkitekt:decompose-processor:LatestVersion")
}
// Register generated sources in commonMain source set
kotlin.sourceSets.named("commonMain") {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
// Ensure KSP metadata task runs before compilation
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
if (name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}
Important: Use
kspCommonMainMetadataconfiguration only. Do not add the processor to platform-specific configurations (kspAndroid,kspIosArm64, etc.) as this would cause duplicate generation.
ResultFlow
ResultFlow enables passing results back between navigation destinations:
val resultFlow = ResultFlow<String>()
// Pass to child, collect results
resultFlow.collect { result -> /* handle */ }
// In child component
resultFlow.sendResult("some result")
EventsEffect (Decompose)
On Android, collect UiEvents from a component in Compose:
EventsEffect(component.events) {
onEvent<HomeUiEvent.ShowToast> { /* handle */ }
}
Utility Extensions
Flow<T>.collectAsValue(initial, scope)- converts Flow to DecomposeValueValue<T>.asStateFlow()- converts DecomposeValueto KotlinStateFlowStackNavigator.switchTab(config)- brings configuration to front without recreating if already on stack
SavedStateHandle
Arkitekt supports SavedStateHandle in ViewModel via Hilt standard mechanism. Simply inject SavedStateHandle into your @HiltViewModel.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
) : BaseViewModel<HomeViewState>() {
// uses savedStateHandle
}
Testing
In order to create successful applications, it is highly encouraged to write tests for your application.
See these tests in example module for more detailed sample.
ViewModel testing
core-test dependency contains utilities to help you with ViewModel testing.
ViewModelTest can be used as a base class for view model tests inside core-test module to help with Coroutines testing.
Events testing
The spy object should be used for an easy way of testing that expected events were sent to the view.
viewModel = spyk(SampleViewModel(mockViewState, ...), recordPrivateCalls = true)
...
verify { viewModel.sendEvent(ExpectedEvent) }
Mocking of Use Cases
cr-usecase-test dependency contains utilities to help you with mocking use cases in a view model.
Since all 'execute' methods for use cases are implemented as extension functions, we created testing methods that will help you to easily mock them.
So if a method in the view model looks somehow like this:
fun onLoginClicked(name: String, password: String) {
loginUseCase.execute(LoginData(name, password)) {
onSuccess = { ... }
}
}
then it can be mocked with the following method:
mockLoginUseCase.mockExecute(args = ...) { user } // For Coroutines Use Cases
In case that use case is using nullable arguments:
mockLoginUseCase.mockExecuteNullable(args = ...) { user } // For Coroutines Use Cases
Compose tests
If you want to test your UI, you can use standard Compose testing APIs (createComposeRule).
@HiltAndroidTest
class HomeScreenTest {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun testUI() {
composeTestRule.onNodeWithText("Hello").assertIsDisplayed()
}
}
License
Arkitekt is available under the MIT license. See the LICENSE file for more information.
Created with ❤ at Futured. Inspired by Alfonz library.
