DEV Community

Cover image for Building the Architecture in KMP: Data Flow, MVI, and Hard Decisions
Raul Arroyo
Raul Arroyo

Posted on

Building the Architecture in KMP: Data Flow, MVI, and Hard Decisions

Part 2 — Architecture, MVI, and the Offline-First Decision


In Part 1 I wrote about the stack and the first walls — library compatibility, Navigation3 bugs, Coil 3 differences. Now we get into the decisions that shaped how the whole app is structured.

Fair warning: this post has opinions. Architecture is one of those topics where everyone has a preferred approach, and mine was shaped by years of Android development followed by three years of Flutter. Your mileage may vary.


The Overall Structure

I'm following a simplified Clean Architecture. Not strict by the book, but principled enough that I can navigate the codebase without thinking too hard about where things live:

data/
  remote/
    model/       ← DTOs — what the API returns
    service/     ← Ktor clients (WordPress, Spotify)
    mapper/      ← DTO → Entity, Entity → Domain
  repository/    ← PostRepository — single source of truth
domain/
  model/         ← PostDetail, PostSummary, ContentPart
ui/
  screens/       ← ViewModels + Composables
  components/    ← Shared UI components
  navigation/    ← Navigation3 setup
  theme/         ← Colors, typography, shapes
Enter fullscreen mode Exit fullscreen mode

The key constraint I set for myself: the UI layer never talks to the network directly. Everything goes through the repository, and the repository always reads from the database. The network is just a way to keep the database up to date.

Two Domain Models Instead of One

Early on I had a single Post model that tried to serve both the list view and the detail view. That doesn't scale well. The list needs almost nothing — title, excerpt, image, category. The detail needs author info, content, tags, related posts. Cramming those into one model means either always fetching too much or constantly dealing with nullable fields that "don't apply here."

I split them:

// For lists — lightweight, fast to load from DB
data class PostSummary(
    val id: Int,
    val title: String,
    val excerpt: String,
    val imageUrl: String?,
    val categoryName: String?,
    val isSaved: Boolean
)

// For the detail screen — everything we need
data class PostDetail(
    val id: Int,
    val title: String,
    val content: String,
    val imageUrl: String?,
    val categoryName: String?,
    val categoryId: Int?,
    val author: Author?,
    val date: String?,
    val slug: String?,
    val tags: List<String>,
    val tagIds: List<Int>,
    val isSaved: Boolean
)
Enter fullscreen mode Exit fullscreen mode

One thing worth noting: PostDetail has both tags (the display names, like "Vive Latino") and tagIds (the WordPress IDs, like [38763, 1032]). The names come from the API's embedded _embedded.wp:term data. The IDs come from the top-level tags array. We need both — names for display, IDs for querying related posts.

Also notice that PostDetail has content as a raw string, but we never show it directly. It gets parsed into List<ContentPart> in the ViewModel before reaching the UI. More on the parser in Part 3.

The Data Flow

WordPress API (Ktor)
    ↓
WordpressPostDto
    ↓  toEntity()
PostEntity (SQLDelight DB)
    ↓  toPostSummary() / toPostDetail()
Domain Model
    ↓
ViewModel → UI
Enter fullscreen mode Exit fullscreen mode

Every piece of data touches the database. The API is the source of new data. The database is the source of truth for the UI. This gives you offline support for free — if the API fails, you still render cached content.

The mapper layer is split into two files: WordpressPostDtoMapper.kt for DTO → Entity (data layer concern), and PostEntityMapper.kt for Entity → Domain (connecting the data layer to the domain). Each file has one job.

Offline-First Sync

The WordPress REST API has a modified_after parameter that returns only posts modified since a given timestamp. Combined with SQLDelight's ability to query MAX(lastModified), incremental sync becomes straightforward:

suspend fun syncPosts() = withContext(Dispatchers.IO) {
    val latestModified = queries.getLatestModifiedDate().executeAsOneOrNull()?.MAX
    val latestDate = queries.getLatestDate().executeAsOneOrNull()?.MAX

    if (latestModified == null || latestDate == null) {
        // Empty DB — initial download
        val posts = api.getPosts(perPage = 50)
        savePosts(posts)
    } else {
        // Incremental — only what changed since last sync
        val newPosts = api.getPosts(
            after = latestDate,
            modifiedAfter = latestModified
        )
        if (newPosts.isNotEmpty()) savePosts(newPosts)
    }
}
Enter fullscreen mode Exit fullscreen mode

First launch: 50 posts. Every subsequent launch: only what changed. Fast, bandwidth-efficient, and the UI always has something to show even offline.

One gotcha: WordPress's modified_after parameter is not widely documented and the naming is slightly inconsistent across WordPress versions. Test it against your actual endpoint before trusting it.

MVI for Screen State

I've always used MVVM. It's what the Android documentation pushes, it's what most tutorials use, and honestly it works fine for most apps. But MVI has been getting a lot of attention lately — and for good reason. The unidirectional data flow, the explicit state modeling, the clear separation between what the user does and what the UI shows — it all clicks when you see it in action.

This project felt like the right place to try it properly. Not because MVVM would have failed here, but because I wanted to understand MVI from the inside, not just from blog posts.

MVI Diagram

The pattern maps cleanly to KMP ViewModels.

Each screen has three things:

Intent — what the user can do:

sealed interface PostDetailIntent {
    data class LoadPost(val postId: Int) : PostDetailIntent
    data class ToggleSaved(val postId: Int, val current: Boolean) : PostDetailIntent
    data class OpenGallery(val imageUrls: List<String>, val initialIndex: Int) : PostDetailIntent
}
Enter fullscreen mode Exit fullscreen mode

State — what the UI renders:

sealed interface PostDetailState {
    data object Loading : PostDetailState
    data class Success(
        val postDetail: PostDetail,
        val parts: List<ContentPart>,
        val imageUrls: List<String>,
        val isSaved: Boolean,
        val relatedPosts: List<PostSummary> = emptyList()
    ) : PostDetailState
    data class Error(val error: Error) : PostDetailState
}
Enter fullscreen mode Exit fullscreen mode

Effect — one-time events that don't belong in state:

sealed interface PostDetailEffect {
    data class ShowError(val error: Error) : PostDetailEffect
}
Enter fullscreen mode Exit fullscreen mode

The ViewModel exposes a StateFlow<PostDetailState> for state and a Channel<PostDetailEffect> for effects. Why Channel and not SharedFlow for effects? Because SharedFlow can drop events if the collector isn't active at the moment of emission. Channel buffers them. For a snackbar that should appear exactly once, that matters.

class PostDetailViewModel(
    private val repository: PostRepository,
    private val htmlParser: HtmlParser,
    private val navigator: AppNavigator
) : ViewModel() {

    private val _state = MutableStateFlow<PostDetailState>(PostDetailState.Loading)
    val state: StateFlow<PostDetailState> = _state.asStateFlow()

    private val _effects = Channel<PostDetailEffect>(Channel.BUFFERED)
    val effects = _effects.receiveAsFlow()

    fun onIntent(intent: PostDetailIntent) {
        when (intent) {
            is PostDetailIntent.LoadPost -> loadPost(intent.postId)
            is PostDetailIntent.ToggleSaved -> toggleSaved(intent.postId, intent.current)
            is PostDetailIntent.OpenGallery -> navigator.navigateToGallery(
                intent.imageUrls, intent.initialIndex
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

One Kotlin-specific gotcha worth mentioning: state comes from collectAsState() in Compose, which returns a delegated property. Smart casts don't work on delegated properties. This means you can't write if (state is PostDetailState.Success) { state.postDetail } — the compiler won't smart-cast state inside the branch. The fix is a local variable:

val currentState = state  // local variable — smart cast works here
when (currentState) {
    is PostDetailState.Success -> { currentState.postDetail }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

A small thing that can cost 20 minutes of confusion if you've never hit it before.

Navigation as a Singleton

One non-obvious decision: AppNavigator is a singleton in Koin. Not a scoped dependency, not passed through the composable tree — a single instance that any ViewModel or Composable can inject.

class AppNavigator {
    private var backStack: MutableList<NavKey>? = null

    fun attach(backStack: MutableList<NavKey>) {
        this.backStack = backStack
    }

    fun navigateToPost(postId: Int) {
        backStack?.add(AppRoute.PostDetail(postId))
    }

    fun navigateToGallery(imageUrls: List<String>, initialIndex: Int) {
        val json = Json.encodeToString(imageUrls)
        backStack?.add(AppRoute.ImageGallery(json, initialIndex))
    }
}
Enter fullscreen mode Exit fullscreen mode

This means a component like InternalPostContentPart — which renders a "Read also" card inside a post's content — can navigate to another post without needing to bubble a callback up through five layers of composables. It just injects the navigator directly:

@Composable
fun InternalPostContentPart(part: ContentPart.InternalPost) {
    val repository = koinInject<PostRepository>()
    val navigator = koinInject<AppNavigator>()
    // ...
    Card(onClick = { postId?.let { navigator.navigateToPost(it) } }) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Whether this is "clean" is debatable. But it's pragmatic, and in a KMP project where threading the callback through shared composables gets complicated, the singleton approach saves real complexity.


What's Next

In Part 3 we get into the most technically interesting part of the project: the custom HTML parser that turns raw WordPress content into typed ContentPart objects that Compose can render. WordPress posts contain a mix of paragraphs, headings, YouTube embeds, Spotify iframes, Instagram blockquotes, image galleries, and internal post references — none of it consistent, all of it needing different UI treatment.


Stack: Kotlin 2.3.10 · Compose Multiplatform 1.10.2 · Ktor 3.4.1 · SQLDelight 2.3.1 · Koin 4.1.1

Top comments (0)