Article: Borrowing from Kotlin/Android to Architect Scalable iOS Apps in SwiftUI
Building iOS apps can feel like stitching together guidance from blog posts and Apple samples, which are rarely representative of how production architectures grow and survive. In contrast, the Kotlin/Android ecosystem has converged on well-documented, real-world patterns. This article explores how those approaches can be translated into Swift/SwiftUI to create maintainable, scalable iOS apps. By Ivan Bliznyuk
InfoQ Homepage
Articles
Borrowing from Kotlin/Android to Architect Scalable iOS Apps in SwiftUI
Mobile
Borrowing from Kotlin/Android to Architect Scalable iOS Apps in SwiftUI
Feb 26, 2026
min read
Ivan Bliznyuk
reviewed by
Sergio De Simone
Write for InfoQ
Feed your curiosity.
Help 550k+ global
senior developers
each month stay ahead.Get in touch
Listen to this article - 0:00
0:00
0:00
- Reading list
Key Takeaways
- Good architecture is platform agnostic.The principles that make Android apps maintainable work equally well on iOS.
- Action-based ViewModels create a clear contract: routing all mutations through a single method gives you centralized logging, easier testing, and a documented "API" of what your ViewModel actually does.
- Explicit state eliminates impossible states at the outset:
Loadable<T>enum instead of multiple@Published- one property, one source of truth.
- The Screen/Content separation clarifies responsibilities: splitting the "owns the ViewModel" concern (Screen) from "renders the UI" concern (Content) makes views more reusable and easier to preview in isolation.
- Reactive repositories enable automatic UI synchronization: when the repository owns the data and exposes it via publishers, any update propagates to all observing ViewModels automatically.
For us iOS developers, it’s often hard to create scalable architecture out of simple one-page example apps from Apple. Sure it works for a simple app, but I have always struggled with what to do next when you want to build something scalable.
After looking around, I discovered the Android world. I was surprised by what Google provides for developers compared to Apple. Android developers have clear guides and patterns, and most importantly, real-world examples that show how to structure production apps and not just toy projects.
The Android community benefits from:
In comparison, iOS developers are often left piecing together solutions from blog posts and Apple’s sample apps. These solutions are useful in isolation, but rarely represent how a real-world app’s architecture evolves, leaving us hoping our architecture doesn’t collapse as the app grows.
But here is the encouraging thing: Good architecture is platform agnostic. The principles that make Android apps maintainable work just as well on iOS.
This article explores how iOS apps can be built using architecture patterns inspired by modern Kotlin and Android development. It demonstrates how these patterns translate to Swift and SwiftUI.
We will start with a fundamental problem: managing state inside a view. This problem includes enforcing a single entry point for mutations and enabling cross-cutting concerns such as logging and debugging.
Next, we will move one layer up and separate the view from its view model to improve reusability, testability, and previewability.
Finally, we will introduce an active repository layer to bring the concept of a single source of truth to life and show how data can automatically propagate across the app.
The Problem with Traditional iOS ViewModels
If you’ve built iOS apps with SwiftUI, you’ve probably written something like this:
class DashboardViewModel: ObservableObject {
@Published var workouts: [Workout] = []
@Published var isLoading = false
@Published var error: Error?
func loadWorkouts() {
isLoading = true
error = nil
Task {
do {
workouts = try await api.fetchWorkouts()
isLoading = false
} catch {
self.error = error
isLoading = false
This code works for a simple screen, but consider what happens as the ViewModel grows.
The State Problem
Multiple properties that can contradict each other. Nothing stops this:
viewModel.isLoading = true
viewModel.workouts = cachedWorkouts // Now we're "loading" with data viewModel.error = NetworkError.timeout // And also errored?
Which state should the UI show? The compiler doesn’t help you here. Developers make different choices and bugs are created.
The mutation problem
You will add more methods such as loadMore(), then refresh(), then deleteWorkout(), filterWorkout(), and selectWorkout(). Now you have multiple methods all mutating state in their own way. Want to log every state change? Add logging to multiple places. Want to debug to determine why isLoading is stuck on true? Set breakpoints in ten places. Want to write a test? Figure out which combination of method calls reproduces the user flow. There is no central place where things happen. The ViewModel is a bunch of methods and you are left to remember how they interact.
Imagine you are working on a feature. It touches a ViewModel you haven’t seen in six months, or a new one that you have never seen. You open the file and it has six hundred lines and twenty methods. What does the thing do? Which methods are called from the view and which ones are internal helpers? You will have to read the whole class to understand it. There is no summary, no contract, no list of "here’s what this ViewModel can do". Now multiply that by 100 other ViewModels.
Solving the State Problem: Explicit State
In Kotlin, the state problem is solved at the type level:
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
val workouts: StateFlow<UiState<List<Workout>>> = ...
The state is defined by a single source of truth. Its type makes the possible states mutually exclusive, and the compiler enforces this. Being in both Loading and Success at the same time is impossible.
The Swift equivalent is straightforward:
enum Loadable<T, U> {
case loading
case finished(T)
case error(U)
class DashboardViewModel: ObservableObject {
@Published var workouts: Loadable<[Workout]> = .loading
Solving the Mutation Problem: Single Entry Point
Explicit state prevents contradictory states, but what about the problem of multiple mutating methods? Kotlin’s answer is to funnel everything through a single entry point:
fun onAction(action: DashboardAction) {
when (action) {
is DashboardAction.Refresh -> loadWorkouts()
is DashboardAction.SelectWorkout -> selectWorkout(action.id)
is DashboardAction.Delete -> deleteWorkout(action.id)
Every mutation flows through onAction(),t not just some mutations, but all of them.
Look at the DashboardAction class in isolation:
sealed class DashboardAction {
object Refresh : DashboardAction()
data class SelectWorkout(val id: String) : DashboardAction()
data class Delete(val id: String) : DashboardAction()
data class FilterBy(val type: WorkoutType) : DashboardAction()
This is a complete list of every action in the ViewModel. A new engineer can open this file, read the class and immediately understand the ViewModel’s capabilities. No scrolling through six hundred lines of code, no guessing which methods are public, no wondering if this is called from the View or if it is internal only.
[...]