Crossplatform architecture for an app’s core. Simple. Linear. Scalable

Vladimir Genovich
10 min readNov 19, 2024

--

Table of contents:
1. Why MVx architectures are usually not working
2. How to fix 3 issues of MVx architectures?
3. Crossplatform architecture for an app’s core. Simple. Linear. Scalable
4. T-function: connect logic to UI like to DB

Problem Description

Task

I’m an Android developer. Usually, people come to me with phrases like “we’ve come up with a feature, can you implement it?” and with a design mockup, like this.

I look at these and see: here are screens, this data on them is static, and this is dynamic, so it needs to be fetched from somewhere; here are interactive components: when interacting with them, something needs to be done. Sometimes it’s just opening another screen or widget, sometimes executing logic. Based on this, I design how the feature logic will look. I describe it in architecture components, break it down into tasks, find out where and how to interact with the server, etc.

Hidden Cases

But then I discover that not all transitions are as simple as shown in the design, like in the authorisation case. Not all transitions are explicit, as for back navigation. And in some cases there’re no loading screens and empty states.

Sounds familiar?

Observation

So I thought about it for a while and came to the conclusion that all this time I had been looking at new features and applications from the wrong perspective.

What do I mean?

From the user’s perspective, an application is screens, interaction points, and waiting for the application to do its job. You see, even in this description there is a second actor — the application. Why not look at all processes from its point of view.

Alternative approach

Example

As an example, let’s use one of the typical tasks from programming textbooks — the CRUD task, which stands for Create, Read, Update, and Delete. In fact, we’ll even trim it down to just creating, deleting, and displaying.

Initially I thought about making a chess game, but at some point I realized that it was too complex of an example, and we would be stuck with it forever.

Our application will look like this: a simple list with items, when an item is tapped it gets deleted, and below the list there’s an input field and a button for creating a new item.

And here’s the layout of such an application.

First features

The question is, where to start? In general, we could start with anything, but I thought it would be more logical to begin with the core feature: displaying the list of items.

But why should I — the application — display it?

And that’s a very good question. Because the application doesn’t need to interact with the user as long as it can make decisions on its own. For example, the decision on how to find an item in the list and delete it. But the application can’t decide which item needs to be deleted, at least not in this case. So we delegate this decision to the user.

Look here: there’s a delete function. It takes a list and an item as input, removes the item from the list, and returns the modified list. Usually, this is a standard library function, so we won’t describe its implementation. We’ll just take it as a given.

Now we (still as the application) have two questions, and they are described as input parameters: where to get the list from and where to get the item to delete?

And while the list can be something we own, we delegate the decision about which item to delete to the user. And now all this can be packaged into a new function of selection and deletion.

Here, I want to draw your attention to how smoothly we scaled up from removing an element to selecting and removing it. And if you think this is just a coincidence, let’s scale further: let’s add the creation of a new element.

Scalability: Adding an Element

So, as an app, we can add an item to the list, but we don’t yet know how exactly this item looks or what it contains. Let’s start with what we do know.

The function for adding a new element has a signature similar to the function for removing an element, except for different names. And, of course, different actions. 😃

Then we face the same question: “Where do we get the element to add?” And since we can’t solve this ourselves, we delegate this responsibility to the user and neatly package it into a “create and add” function.

But it feels like we’ve done all this before. So, where’s the promised scalability? It lies in the fact that now we can run these two programs in parallel, combining them into one without making changes to either.

By wrapping this all into a loop, passing the result of one step to the next, we get a complete solution for our task.

Now we have a cross-platform description of the app logic, independent of any specific UI. On paper…

Implementation

Logic

Now it’s time to turn this description into code. How?

Each block is transformed into a function. In my case, it’s a suspend function in Kotlin, but that’s not essential. I’ll explain why in detail another time.

Going step-by-step from top to bottom in our diagram, we sequentially implement the “example app,” “create or remove,” “select and remove,” and the other functions.

suspend fun <Item> exampleApp(items: List<Item>): Nothing {
updateLoop(items) {
createOrRemove(it)
}
}

suspend fun <Item> createOrRemove(items: List<Item>): List<Item> {
return parallel(
{ selectAndRemoveItem(items) },
{ createAndAdd(items) }
)
}

suspend fun <Item> selectAndRemoveItem(items: List<Item>): List<Item> {
val item = selectItem(items)
return removeItem(items, item)
}

suspend fun <Item> removeItem(items: List<Item>, item: Item): List<Item> {
return items - item
}

suspend fun <Item> createAndAdd(items: List<Item>): List<Item> {
val item: Item = createItem()
return addItem(items, item)
}

suspend fun <Item> addItem(items: List<Item>, item: Item): List<Item> {
return items + item
}

But here we hit a point where we need to interact with the user.

suspend fun <Item> selectItem(items: List<Item>): Item {
TODO("Interact with user")
}

suspend fun <Item> createItem(): Item {
TODO("Interact with user")
}

This is an external dependency in relation to our logic. “External” means we need to somehow get it from outside. But how do we do that?

Dependencies

I suggest describing the dependencies of these functions as interfaces. Let the external system, which wants to run our application, handle their implementation.

suspend fun <Item> SelectItemDependencies<Item>.selectItem(items: List<Item>): Item {
return select(items)
}

interface SelectItemDependencies<Item> {
suspend fun select(items: List<Item>): Item
}

suspend fun <Item> CreateItemDependencies<Item>.createItem(): Item {
return create()
}

interface CreateItemDependencies<Item> {
suspend fun create(): Item
}

However, now the “external system” becomes the calling functions themselves.

From the perspective of the calling function, we are now required to implement this interface or request it from somewhere else. But in doing so, we strongly tie ourselves to that specific function and interface, which, as a calling function, I don’t need. I only care about the function signatures, not the implementations. So, we can use the same trick as with selectItem and createItem: abstract dependencies into an interface. Then, we can apply this recursively, all the way up to exampleApp.

suspend fun <Item> ExampleAppDependencies<Item>.exampleApp(items: List<Item>): Nothing {
updateLoop(items) {
createOrRemove(it)
}
}

interface ExampleAppDependencies<Item> {
suspend fun createOrRemove(items: List<Item>): List<Item>
}

suspend fun <Item> CreateOrRemoveDependencies<Item>.createOrRemove(items: List<Item>): List<Item> {
return parallel(
{ selectAndRemoveItem(items) },
{ createAndAdd(items) }
)
}

interface CreateOrRemoveDependencies<Item> {
suspend fun selectAndRemoveItem(items: List<Item>): List<Item>
suspend fun createAndAdd(items: List<Item>): List<Item>
}

suspend fun <Item> SelectAndRemoveItemDependencies<Item>.selectAndRemoveItem(items: List<Item>): List<Item> {
val item = selectItem(items)
return removeItem(items, item)
}

interface SelectAndRemoveItemDependencies<Item> {
suspend fun selectItem(items: List<Item>): Item
suspend fun removeItem(items: List<Item>, item: Item): List<Item>
}

suspend fun <Item> removeItem(items: List<Item>, item: Item): List<Item> {
return items - item
}

suspend fun <Item> CreateAndAddDependencies<Item>.createAndAdd(items: List<Item>): List<Item> {
val item = createItem()
return addItem(items, item)
}

interface CreateAndAddDependencies<Item> {
suspend fun createItem(): Item
suspend fun addItem(items: List<Item>, item: Item): List<Item>
}

suspend fun <Item> addItem(items: List<Item>, item: Item): List<Item> {
return items + item
}

suspend fun <Item> SelectItemDependencies<Item>.selectItem(items: List<Item>): Item {
return select(items)
}

interface SelectItemDependencies<Item> {
suspend fun select(items: List<Item>): Item
}

suspend fun <Item> CreateItemDependencies<Item>.createItem(): Item {
return create()
}

interface CreateItemDependencies<Item> {
suspend fun create(): Item
}

Now that our functions are so decoupled from each other, we need to assemble them back into a complete program.

This isn’t difficult. All we need to do is implement all the interfaces and properly compose them together.

val selectAndRemoveContext = object : SelectAndRemoveItemDependencies<String> {
override suspend fun selectItem(items: List<String>): String {
// ui-interaction here
}

override suspend fun removeItem(items: List<String>, item: String): List<String> =
com.genovich.cpa.removeItem(items, item)
}

val createAndAddContext = object : CreateAndAddDependencies<String> {
override suspend fun createItem(): String {
// ui-interaction here
}
override suspend fun addItem(items: List<String>, item: String): List<String> =
com.genovich.cpa.addItem(items, item)
}

val createOrRemoveContext = object : CreateOrRemoveDependencies<String> {
override suspend fun selectAndRemoveItem(items: List<String>): List<String> =
selectAndRemoveContext.selectAndRemoveItem(items)
override suspend fun createAndAdd(items: List<String>): List<String> =
createAndAddContext.createAndAdd(items)
}

val exampleAppContext = object : ExampleAppDependencies<String> {
override suspend fun createOrRemove(items: List<String>): List<String> =
createOrRemoveContext.createOrRemove(items)
}

Here, I simply implement each interface step-by-step and insert function calls where needed. Of course, we could discuss the complexity of the solution and why I didn’t abstract dependencies for adding and removing elements, but it’s better to do that in comments or on Patreon or Telegram.

Console UI

Finally, we need to run exampleApp using the assembled dependency graph as the context for its execution. I also added a message and response queue so the user can interact with the application.

fun main() {
val outputFlow = MutableSharedFlow<String>(1)
val inputFlow = MutableSharedFlow<OneOf<String, Int>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)

val selectAndRemoveContext = object : SelectAndRemoveItemDependencies<String> {
override suspend fun selectItem(items: List<String>): String {
outputFlow.emit(
items.withIndex().joinToString("\n") { (index, item) -> "$index. $item" })
return inputFlow.filterIsInstance<OneOf.Second<Int>>()
.mapNotNull { items.getOrNull(it.second) }
.first()
}
override suspend fun removeItem(items: List<String>, item: String): List<String> =
com.genovich.cpa.removeItem(items, item)
}

val createAndAddContext = object : CreateAndAddDependencies<String> {
override suspend fun createItem(): String {
return inputFlow.filterIsInstance<OneOf.First<String>>()
.map { it.first }
.first()
}
override suspend fun addItem(items: List<String>, item: String): List<String> =
com.genovich.cpa.addItem(items, item)
}

val createOrRemoveContext = object : CreateOrRemoveDependencies<String> {
override suspend fun selectAndRemoveItem(items: List<String>): List<String> =
selectAndRemoveContext.selectAndRemoveItem(items)
override suspend fun createAndAdd(items: List<String>): List<String> =
createAndAddContext.createAndAdd(items)
}

val exampleAppContext = object : ExampleAppDependencies<String> {
override suspend fun createOrRemove(items: List<String>): List<String> =
createOrRemoveContext.createOrRemove(items)
}

runBlocking {
launch(Dispatchers.Default) { exampleAppContext.exampleApp(emptyList()) }
outputFlow.collectLatest { text ->
println("Items:")
println(text)
print("Enter item number to delete or item name to add: ")
val input = readln()
inputFlow.emit(
input.toIntOrNull()?.let { OneOf.Second(it) } ?: OneOf.First(input)
)
}
}
}

So, looking at the logic from the perspective of the application, we can create scalable, connected, and testable code for our apps.

Mobile UI

Oh, I did promise a mobile application. So, let’s do this: replace the part that interacts with the user with something more suitable. I call it the T-function. I outlined the basic idea at the end of my article about solving MVX architecture problems.

What you need to know for now: it sends a “request” paired with a callback to a channel and waits for the receiving side to invoke the callback. It works similarly to how we usually interact with the backend. Android developers might recognise this approach from Handler.replyTo.

object Logic

@Composable
@Preview
fun App(
selectItemsFlow: MutableStateFlow<UiState<List<String>, String>?> = MutableStateFlow(null),
createItemsFlow: MutableStateFlow<UiState<Unit, String>?> = MutableStateFlow(null),
) {
MaterialTheme {
// WARNING: don't do like this!!!
// Logic should not be in the composition!
// Ideally you should to run the logic outside of App() and provide selectItemsFlow and createItemsFlow as parameters
// Logic should be able to "live" longer than the UI
LaunchedEffect(Logic) {
val selectAndRemoveContext = object : SelectAndRemoveItemDependencies<String> {
override suspend fun selectItem(items: List<String>): String {
return selectItemsFlow.showAndGetResult(items)
}
override suspend fun removeItem(items: List<String>, item: String): List<String> =
com.genovich.cpa.removeItem(items, item)
}
val createAndAddContext = object : CreateAndAddDependencies<String> {
override suspend fun createItem(): String {
return createItemsFlow.showAndGetResult(Unit)
}
override suspend fun addItem(items: List<String>, item: String): List<String> =
com.genovich.cpa.addItem(items, item)
}
val createOrRemoveContext = object : CreateOrRemoveDependencies<String> {
override suspend fun selectAndRemoveItem(items: List<String>): List<String> =
selectAndRemoveContext.selectAndRemoveItem(items)
override suspend fun createAndAdd(items: List<String>): List<String> =
createAndAddContext.createAndAdd(items)
}
val exampleAppContext = object : ExampleAppDependencies<String> {
override suspend fun createOrRemove(items: List<String>): List<String> =
createOrRemoveContext.createOrRemove(items)
}
exampleAppContext.exampleApp(emptyList())
}
Column {
val selectItems by selectItemsFlow.collectAsState()
selectItems?.also { (items, select) ->
LazyColumn(
modifier = Modifier.weight(1f),
reverseLayout = true,
) {
items(items.asReversed()) { item ->
Text(
modifier = Modifier
.fillMaxWidth()
.clickable { select(item) }
.padding(16.dp),
text = item,
)
}
}
}
val createItem by createItemsFlow.collectAsState()
createItem?.also { (_, create) ->
Row(Modifier.fillMaxWidth()) {
var value by remember(create) { mutableStateOf("") }
TextField(
modifier = Modifier.weight(1f),
value = value,
onValueChange = { value = it },
)
Button(
onClick = { create(value) },
) {
Text(text = "Create")
}
}
}
}
}

It’s worth discussing the scaling and composition of event streams for the UI separately, but I’ve already said quite a bit.

Conclusion

The main point I want to emphasise is how cohesive the logic becomes when designed from the application’s perspective rather than the user interface’s. Additionally, it highlights how flexible, testable, and scalable it is when each function is decoupled from its dependencies at the action (function) level, rather than at the object level.

I believe we can delve deeper into these aspects separately. For now, thank you for your attention. See you next time!

P.S. Here’s the link to the project made in this article, as well as the GitHub repository of the project with the utilized functions.

--

--

Responses (3)