Complete Guide to Becoming a Fullstack Kotlin Developer - application architecture with Decompose

Michał KonkelMichał Konkel
15 min read

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.

0
Subscribe to my newsletter

Read articles from Michał Konkel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Michał Konkel
Michał Konkel