Complete Guide to Becoming a Fullstack Kotlin Developer - application architecture with Decompose
In the previous post, I discussed common HTTP clients for Android, iOS, and web applications. We are gradually reaching the stage where we can present the data to the users. To achieve this, we need to properly structure our code, especially since we have already completed the repository layer. Now, it’s time to focus on building the presentation layer.
For this purpose, we will use Decompose, a Kotlin multiplatform library. Decompose allows us to create lifecycle-aware business logic components and manage routing effectively. One of the reasons I chose Decompose is its robust support for wasm (WebAssembly), which makes it highly versatile for different platforms.
Decompose provides a clean way to organize our code, ensuring that our business logic is separated from the UI components. This separation is crucial for maintaining a clean architecture and making our codebase more manageable and scalable. Additionally, Decompose's routing capabilities will help us navigate between different screens or components within our application seamlessly.
By leveraging Decompose, we can ensure that our application is not only well-structured but also capable of handling complex business logic across multiple platforms. This will ultimately lead to a better user experience and a more maintainable codebase.
The complete project is avaiable on GitHub
Dependencies
libs.versions.toml
[versions]
decompose = "3.0.0-alpha04"
essenty = "2.0.0-alpha02"
[libraries]
decompose-core = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" }
decompose-extensions-compose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" }
essenty-lifecycle = { group = "com.arkivanov.essenty", name = "lifecycle", version.ref = "essenty" }
essenty-stateKeeper = { group = "com.arkivanov.essenty", name = "state-keeper", version.ref = "essenty" }
essenty-instanceKeeper = { group = "com.arkivanov.essenty", name = "instance-keeper", version.ref ="essenty" }
essenty-backHandler = { group = "com.arkivanov.essenty", name = "back-handler", version.ref = "essenty" }
shared/build.gradle.kts
commonMain.dependencies {
...
implementation(libs.decompose.core)
implementation(libs.essenty.lifecycle)
api(libs.essenty.stateKeeper)
api(libs.essenty.backHandler)
}
Components
With Decompose, we can create classes called Components
that act as our presenters. These components need to implement the ComponentContext
interface. This is where all interactions occur: API calls, building UI models, updating views, handling user interactions, and more. If you're an Android developer, you can think of them as ViewModels
.
Decompose builds a stack of components and provides an easy way to navigate through them. Components
that aren’t currently visible are not destroyed; they can keep working in the background without an attached UI. For more details about Decompose, I encourage you to read the documentation.
With this brief overview, we can create the RootComponent
, the entry point of our application. This component will host all other components (sub-components/screens) and will live as long as the entire application.
shared/features/RootComponent.kt
interface RootComponent {
val childStack: Value<ChildStack<*, Child>>
sealed class Child {
class LoginChild(val component: LoginComponent) : Child()
class RegisterChild(val component: RegisterComponent) : Child()
}
}
Every Component that can host other components needs to provide a childStack
– a holder value for current components. The childStack
is visible to the platform, and based on its values, the UI will be determined. It can return the actual (top child) and backStack (inactive children) or all of the items. The Child
sealed class represents components that can be hosted by the RootComponent
. It also acts as a wrapper for the other Components
.
shared/features/RealRootComponent.kt
internal class RealRootComponent(
componentContext: ComponentContext,
) : RootComponent, ComponentContext by componentContext {}
The context
provides us with the whole lifecycle-aware
features and helps us implement behaviors like stack navigation. The navigation requires a Configuration
of the set of parameters, values, or any other things that are necessary to create sub-components. The configuration should be created by us and needs to be serializable
– the most common pattern is to use a sealed class again.
shared/commonMain/features/RealRootComponent.kt
private val navigation = StackNavigation<Config>()
@Serializable
sealed interface Config {
@Serializable
data object Login : Config
@Serializable
data object Register : Config
}
Following the same rules, we can create LoginComponent
and RegisterComponent
that will hold the login form. All the screens that can be reached from the RootComponent
must be defined in child and config classes. Of course, different configurations may lead us to the same components.
shared/commonMain/features/login/LoginComponent.kt
interface LoginComponent {
fun onRegisterClick()
}
shared/commonMain/features/login/RealLoginComponent.kt
internal class RealLoginComponent(
componentContext: ComponentContext,
private val onRegister: () -> Unitt
) : LoginComponent, ComponentContext by componentContext {
override fun onRegisterClick() {
onRegister()
}
}
Now we can write a function that will use the configuration to create a child component. Let's call this function childFactory
. This function will take the configuration as an input parameter and return the appropriate child component based on the configuration type.
shared/commonMain/features/RealRootComponent.kt
private fun childFactory(
config: Config,
componentContext: ComponentContext,
) = when (config) {
Config.Login ->
RootComponent.Child.LoginChild(
RealLoginComponent(componentContext = componentContext)
)
}
Navigation
The last thing is to create a childStack
that will manage the navigation. It will also be responsible for creating children and managing the components. It should have a unique key, navigation source, serializer that determines how to serialize the configuration, a flag that determines if the stack should handle the back button, an initial stack from which the component should start, and a child factory for creating new subcomponents.
private val stack = childStack(
key = "RootComponent",
source = navigation,
serializer = Config.serializer(),
handleBackButton = true,
initialStack = { listOf(Config.Login) },
childFactory = ::childFactory,
)
override val childStack: Value<ChildStack<*, RootComponent.Child>> = stack
The return type of the stack is Value
, an internal Decompose way to provide an observable state (similar to the state in Jetpack Compose). It’s a custom class that gives us the flexibility to use the library on any platform we want.
With the above configuration, we can now handle the navigation events more effectively. By using the onRegister
lambda in the LoginComponent
, we can trigger a screen change in the RootComponent
. This is achieved through the pushNew
function, which pushes a new configuration onto the top of the current stack.
This approach offers several advantages. Firstly, it ensures that the entire navigation logic is decoupled from the underlying platforms. This means that the navigation logic is not tied to any specific platform, making it more versatile and easier to maintain. Secondly, this separation simplifies the navigation logic, allowing it to be unit-tested within the shared codebase. This eliminates the need to run additional devices or emulators for testing purposes, thereby speeding up the development and testing process.
private fun childFactory(
config: Config,
componentContext: ComponentContext,
) = when (config) {
Config.Login -> {
RootComponent.Child.LoginChild(
RealLoginComponent(
componentContext = componentContext,
onRegister = {
navigation.pushNew(Config.Register)
},
),
)
}
Config.Register -> {
RootComponent.Child.RegisterChild(
RealRegisterComponent(
componentContext = componentContext,
),
)
}
}
The last step is to create the RootComponent
in the shared platform UI and start using the created stack. We begin at the entry point for all platforms with the App()
function, which will initially only provide the RootScreen
.
composeApp/commonMain/App.kt
@Composable
fun App(
component: RootComponent,
modifier: Modifier,
) {
RootScreen(component = component, modifier = modifier)
}
composeApp/commonMain/features/RootScreen.kt
@Composable
private fun RootScreen(
component: RootComponent,
modifier: Modifier = Modifier,
) {
Children(
stack = component.childStack,
modifier = modifier,
animation = stackAnimation(fade()),
) {
when (val child = it.instance) {
is RootComponent.Child.LoginChild ->
LoginScreen(child.component)
is RootComponent.Child.RegisterChild ->
RegisterScreen(child.component)
}
}
}
The Children()
function is part of the Decompose library responsible for handling the childStack
values. The last parameter of this function is a @Composable lambda that returns the current child and allows us to handle UI changes depending on the current child.
composeApp/commonMain/features/login/LoginScreen.kt
@Composable
internal fun LoginScreen(
component: LoginComponent,
modifier: Modifier = Modifier,
) {
Button(
onClick = {
component.onRegisterClick()
},
content = {
Text("Register")
},
)
}
composeApp/commonMain/features/register/RegisterScreen.kt
@Composable
internal fun RegisterScreen(
component: RegisterComponent,
modifier: Modifier = Modifier,
) {
Text("This is the Register screen")
}
Finally, it is time to use our App()
function on every platform. On Android, we need to use the retainedComponent
function that will handle orientation changes.
Android
composeApp/androidMain/gameshop/MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = retainedComponent { RealRootComponent(componentContext = it) }
setContent { App(component = root, modifier = Modifier.fillMaxSize()) }
}
}
iOS
composeApp/iosMain/gameshop/main.kt
fun MainViewController() = ComposeUIViewController {
val root = remember { RealRootComponent(componentContext = DefaultComponentContext(LifecycleRegistry()),) }
App(component = root, modifier = Modifier.fillMaxSize())
}
Web
composeApp/wasmJsMain/gameshop/main.kt
fun main() {
val root = RealRootComponent(componentContext = DefaultComponentContext(lifecycle = LifecycleRegistry()),)
CanvasBasedWindow(title = "GameShop", canvasElementId = "gameShopCanvas") {
App(component = root, modifier = Modifier.fillMaxSize())
}
}
This was the basic setup of the Decompose, which we can follow for other screens. Nevertheless, having direct access to components implementation and creating them on your own is not a great approach. Since we have created a simple DI and we are using it for the repository layer we should extend it with the methods for creating components.
Dependency Injection
composeApp/commonMain/features/factory/ComponentFactory.kt
interface ComponentFactory {
fun createRootComponent(
componentContext: ComponentContext,
): RootComponent
fun createRegisterComponent(
componentContext: ComponentContext
): RegisterComponent
fun createLoginComponent(
componentContext: ComponentContext,
onRegister: () -> Unit,
): LoginComponent
}
Now, with the usage of the factory, we can inject it via the constructor into the RootComponent
. This allows us to delegate the creation of sub-components to the factory, ensuring a more modular and maintainable codebase.
composeApp/commonMain/features/RealRootComponent.kt
internal class RealRootComponent(
componentContext: ComponentContext,
private val componentFactory: ComponentFactory,
) : RootComponent, ComponentContext by componentContext {
...
Config.Register -> {
RootComponent.Child.RegisterChild(
componentFactory.createRegisterComponent(
componentContext = componentContext,
),
)
}
...
}
The implementation of ComponentFactory
will have all the necessary dependencies to create any component. With this approach, we don’t have to worry about creating any additional classes. We can also extend the LoginComponent
and the RegisterComponent
with additional properties such as repositories and lambda functions for user interactions. Lambdas will be passed from parent to child, but the repository is injected into the factory.
composeApp/commonMain/features/factory/RealComponentFactory.kt
internal class RealComponentFactory(
private val remoteRepository: RemoteRepository,
) : ComponentFactory {
override fun createRootComponent(
componentContext: ComponentContext,
): RootComponent {
return RealRootComponent(
componentContext = componentContext,
componentFactory = this,
)
}
override fun createRegisterComponent(componentContext: ComponentContext): RegisterComponent {
return RealRegisterComponent(
componentContext = componentContext,
loginRepository = remoteRepository.loginRepository(),
)
}
override fun createLoginComponent(
componentContext: ComponentContext,
onLogin: () -> Unit,
onRegister: () -> Unit,
): LoginComponent {
return RealLoginComponent(
loginRepository = remoteRepository.loginRepository(),
onLogin = onLogin,
onRegister = onRegister,
)
}
}
The last thing to do is to add the factory and its implementation to the DI class and use it on the platforms.
composeApp/commonMain/di/DI.kt
object DI {
private val tokenStorage: TokenStorage = RealTokenStorage()
private val httpClientFactory: HttpClientFactory = HttpClientFactory(tokenStorage)
private val remoteRepository: RemoteRepository = RealRemoteRepository(httpClientFactory.create(), tokenStorage)
fun rootComponent(
componentContext: ComponentContext,
): RootComponent {
return RealComponentFactory(remoteRepository = remoteRepository)
.createRootComponent(componentContext = componentContext)
}
}
composeApp/androidMain/gameshop/MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = retainedComponent { DI.rootComponent(componentContext = it) }
setContent { App(component = root, modifier = Modifier.fillMaxSize()) }
}
}
Wiring things up
With the work we’ve done so far, where we introduced the presentation layer with various components, it's now time to integrate the API calls. Here's a detailed explanation of the process:
Assume the user clicks on the login button on the login screen. At this moment, the app will send an API request containing the user's credentials. This request is handled by our remoteRepository
, which was set up earlier with the necessary HTTP client and token storage.
Upon receiving a successful response from the server, indicating that the login was successful, the onLogin
lambda will be triggered. This lambda function is responsible for performing the next steps in the app's flow. Specifically, it will navigate the user to the home screen.
On the home screen, the user will be greeted with multiple sections. Two primary sections include the games list, which displays a curated list of games available to the user, and the orders section, where users can view their past and current orders.
The HomeComponent
will be similar to the RootComponent
as it will have its own stack with GamesListComponent
and OrdersComponent
. The screen will have bottom navigation that will allow you to switch views.
The API calls are made with suspend functions; for this reason, we need to invoke them from a coroutine scope. Therefore, every component should be able to create and provide its lifecycle-aware scope that can be used for making such requests. We can introduce a BaseComponent
abstract class that will be responsible for handling the coroutine scope creation. Every component implementation should use it to avoid code duplication.
composeApp/commonMain/features/BaseComponent.kt
internal abstract class BaseComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
) : ComponentContext by componentContext {
protected val scope by lazy { coroutineScope(coroutineContext + Dispatchers.Default + SupervisorJob()) }
private fun CoroutineScope(
context: CoroutineContext,
lifecycle: Lifecycle,
): CoroutineScope {
val scope = CoroutineScope(context)
lifecycle.doOnDestroy { scope.coroutineContext.cancelChildren() }
return scope
}
private fun LifecycleOwner.coroutineScope(context: CoroutineContext): CoroutineScope =
CoroutineScope(context, lifecycle)
}
shared/features/login/RealLoginComponent.kt
internal class RealLoginComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
private val loginRepository: LoginRepository,
private val onLogin: () -> Unit,
private val onRegister: () -> Unit,
) : BaseComponent(componentContext, coroutineContext), LoginComponent {
...
}
Let’s move forward and create a component factory, which will be used all over the app to help create components.
composeApp/commonMain/factory/ComponentFactory.kt
interface ComponentFactory {
fun createRootComponent(componentContext: ComponentContext): RootComponent
fun createRegisterComponent(componentContext: ComponentContext): RegisterComponent
fun createLoginComponent(componentContext: ComponentContext, onLogin: () -> Unit, onRegister: () -> Unit, ): LoginComponent
}
Through the implementation, we can pass all the required dependencies to ensure that each component has everything it needs to function correctly. This approach allows us to inject specific dependencies, such as repositories, context, and callback functions, directly into the components. By doing so, we maintain a clean separation of concerns and promote reusability across different parts of the application.
composeApp/commonMain/factory/RealComponentFactory.kt
internal class RealComponentFactory(
private val mainContext: CoroutineContext,
private val remoteRepository: RemoteRepository,
) : ComponentFactory {
override fun createRootComponent(componentContext: ComponentContext, ): RootComponent {
return RealRootComponent(
coroutineContext = mainContext,
componentContext = componentContext,
componentFactory = this,
)
}
override fun createRegisterComponent(componentContext: ComponentContext): RegisterComponent {
return RealRegisterComponent(
componentContext = componentContext,
coroutineContext = mainContext,
loginRepository = remoteRepository.loginRepository(),
)
}
override fun createLoginComponent(
componentContext: ComponentContext,
onLogin: () -> Unit,
onRegister: () -> Unit,
): LoginComponent {
return RealLoginComponent(
coroutineContext = mainContext,
componentContext = componentContext,
loginRepository = remoteRepository.loginRepository(),
onLogin = onLogin,
onRegister = onRegister,
)
}
}
Then in our ID object, we can replace the direct RootComponent
call with the newly created factory. We also need to modify the platform call, and finally, the internals of the RealRootComponent
to use the factory to create its sub-components.
composeApp/commonMain/di/DI.kt
object DI {
private val tokenStorage: TokenStorage = RealTokenStorage()
private val httpClientFactory: HttpClientFactory = HttpClientFactory(tokenStorage)
private val remoteRepository: RemoteRepository = RealRemoteRepository(httpClientFactory.create(), tokenStorage)
fun rootComponent(
componentContext: ComponentContext,
mainContext: CoroutineContext
): RootComponent {
return RealComponentFactory(
mainContext = mainContext,
remoteRepository = remoteRepository,
).createRootComponent(
componentContext = componentContext,
)
}
}
composeApp/androidMain/gameshop/MainActivity.kt
DI.rootComponent(componentContext = it, mainContext = MainScope().coroutineContext)
composeApp/commonMain/features/RealRootComponent.kt
internal class RealRootComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
private val componentFactory: ComponentFactory,
) : BaseComponent(componentContext, coroutineContext), RootComponent {
...
private fun childFactory(
config: Config,
componentContext: ComponentContext,
) = when (config) {
Config.Login -> {
RootComponent.Child.LoginChild(
componentFactory.createLoginComponent(
componentContext = componentContext,
onLogin = {
navigation.pushNew(Config.Home)
},
onRegister = {
navigation.pushNew(Config.Register)
},
),
)
}
Config.Register -> {
RootComponent.Child.RegisterChild(
componentFactory.createRegisterComponent(
componentContext = componentContext,
),
)
}
}
Since web application navigation is quite different from mobile – we can reach certain screens by passing a proper link – we need to ensure that this is handled. Thankfully, Decompose provides a tool to do it. The WebHistoryController
is a connection between childStack
and the WebHistory
interface. It holds the web paths and can change the navigation according to the current address. We can also introduce a sealed class called DeepLink
which will produce the current web path.
composeApp/commonMain/deepLink/DeepLink.kt
sealed interface DeepLink {
data object None : DeepLink
class Web(val path: String) : DeepLink
}
composeApp/commonMain/features/RealRootComponent.kt
@OptIn(ExperimentalDecomposeApi::class)
internal class RealRootComponent(
componentContext: ComponentContext,
coroutineContext: CoroutineContext,
private val deepLink: DeepLink = DeepLink.None,
private val webHistoryController: WebHistoryController? = null,
private val componentFactory: ComponentFactory,
) : BaseComponent(componentContext, coroutineContext), RootComponent { ... }
The webHistoryController
needs to be attached to the stack and navigation. We need to pass the navigation, stack, and serializer. Then we need to find a way to change the web application path based on the current configuration. The getPath
lambda provides the current configuration and requires a String
in return. Similarly, the proper configuration should be returned for a given path and this is a role for the getConfiguration
lambda, which takes a String
and returns Configuration
.
composeApp/commonMain/features/RealRootComponent.kt
init {
webHistoryController?.attach(
navigator = navigation,
stack = stack,
serializer = Config.serializer(),
getPath = ::getPathForConfig,
getConfiguration = ::getConfigForPath,
)
}
composeApp/commonMain/features/RealRootComponent.kt
private fun getPathForConfig(config: Config): String =
when (config) {
Config.Login -> "/login"
Config.Register -> "/register"
}
composeApp/commonMain/features/RealRootComponent.kt
private fun getConfigForPath(path: String): Config =
when (path.removePrefix("/")) {
“login” -> Config.Login
“register” -> Config.Register
else -> Config.Login
}
The childStack
function also needs to be modified. The initialStack
might be constructed differently, based on the passed path. We will take the webHistoryController
paths, iterate through them, and try to find a proper config for a given address. If we can’t find anything, we should initialize the default stack. For mobile apps (when there is no DeepLink), we are returning the Login
configuration, but for the Web application with the provided address, we should try to resolve it.
composeApp/commonMain/features/RealRootComponent.kt
private val stack =
childStack(
key = "RootComponent",
source = navigation,
serializer = Config.serializer(),
handleBackButton = true,
initialStack = {
getInitialStack(
webHistoryPaths = webHistoryController?.historyPaths,
deepLink = deepLink,
)
},
childFactory = ::childFactory,
)
composeApp/commonMain/features/RealRootComponent.kt
private fun getInitialStack(
webHistoryPaths: List<String>?,
deepLink: DeepLink,
): List<Config> =
webHistoryPaths
?.takeUnless(List<*>::isEmpty)
?.map(::getConfigForPath)
?: getInitialStack(deepLink)
private fun getInitialStack(deepLink: DeepLink): List<Config> =
when (deepLink) {
is DeepLink.None -> listOf(Config.Login)
is DeepLink.Web -> listOf(getConfigForPath(deepLink.path))
}
Since we added two new parameters to the RootComponent
constructor we need to update the factory and the platforms. We used default parameters in RootComponent
so after adjusting the factory only the Web application entry point will be changed.
composeApp/iosMain/gameshop/main.kt
fun MainViewController() = ComposeUIViewController {
val root =
DI.rootComponent(
componentContext = DefaultComponentContext(lifecycle = LifecycleRegistry()),
deepLink = DeepLink.Web(path = window.location.pathname),
webHistoryController = DefaultWebHistoryController(),
mainContext = MainScope().coroutineContext,
)
App(component = root, modifier = Modifier.fillMaxSize())
}
That’s all in the when it comes to app architecture. We’ve got the presentation layer ready, with some abstraction on top of it, a component factory, and a configured DI. Adding new functionalities should be straightforward, every new screen needs its component and should be added to the navigation.
We still missing one critical part – the UI layer. In the next blogpost, we will focus on providing UI models to the platforms and handling user interactions. We will create a simple login form and a home screen with a games list.
In this article, we delve into the presentation layer of a Kotlin multiplatform application using the Decompose library. We explore how to structure code with lifecycle-aware components, implement seamless navigation, and maintain a clean architecture. The guide covers setting up dependencies, creating components like
RootComponent
, managing navigation, and integrating with Kotlin's coroutine framework for async tasks. Additionally, we discuss the use of Dependency Injection for modularity and handling web-specific navigation. This foundational setup prepares our application for effective data presentation across Android, iOS, and web platforms.
Subscribe to my newsletter
Read articles from Michał Konkel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by