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:
- Official Architecture Components with clear documentation
- Sample apps like Now in Android that demonstrate best practices at scale
- Consistent patterns across the ecosystem (Repository, ViewModel)
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
Task {
do {
workouts = try await api.fetchWorkouts()
isLoading = false
} catch {
self.error = error`
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)
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.
The sealed class is the contract. If an action isn’t declared there, the ViewModel can’t execute it. This policy also forces you to think about your ViewModel’s responsibilities. When you add a new action, you add it to the sealed class first. It’s a conscious decision and not a method that just quietly appears somewhere in the file.
But what exactly goes in DashboardAction? If the View can trigger an action, it should be declared as an action. Does the user tap to delete an item? Does the user select an item? What stays out? Internal helpers, such as loadWorkouts(), is called only from inside perform(). It is a private method, not an Action. The Action is .refresh. What happens internally is an implementation detail.
`
enum Action {
case refresh
case selectWorkout(String)
// Not actions — internal implementation
private func loadWorkouts() async { ... }
private func updateCache(_ workouts: [Workout]) { ... }`
If you have been writing iOS apps for some years, this pattern feels unnecessary. Why funnel everything through one method when you can just call that method directly. Well, the answer is that it doesn’t matter when the team is small and you have just three to five screens. It does matter, however, when the team and the codebase grow. Traditional iOS patterns optimize for the simple case, such as using @StateObject, @Published, and call methods directly. This approach is easy to understand and quick to write. Apple's sample code works this way because the code samples are small.
But when scaling up, those direct method calls are problematic. Every method is a potential entry point. Every entry point is a place where state can change. The more entry points, the harder it is to reason about your ViewModel.
Centralizing actions enables things that are genuinely hard to manage otherwise when the codebase grows, including logging, debugging, testing, and analytics.
#### Logging
One line in the base class, and you see every action across every ViewModel. It is not necessary to add print statements to multiple methods.
`
func perform(_ action: Action) {
print("[\(Self.self)] Action: \(action)")
// handle action……`
#### Debugging
If the state is wrong, then you need to set just one breakpoint in perform(). You will see the exact sequence of actions that led to the current state. Compare this approach to setting breakpoints in ten different methods.
#### Testing
Tests become readable. Because every action goes through the same path, you are testing the same code path the real app uses.
`
viewModel.perform(.refresh)
viewModel.perform(.selectWorkout("123"))
viewModel.perform(.delete("123"))
XCTAssertEqual(viewModel.state.workouts, .finished([]))`
#### Analytics
Every user interaction is captured automatically.
`
func perform(_ action: Action) {
analytics.track(action)
// handle action...`
This function isn't something new. It is a standard practice in Android development and is recommended in Google's official architecture guide. Android developers call it unidirectional data flow Events flow downward (View → ViewModel → Repository) and state flows upward (Repository → ViewModel → View). The onAction() method is the entry point for the downward flow.
Google's "Now in Android" sample app uses this pattern. So does most of the Kotlin community. When an Android developer joins a new project, they expect to find an Action enum and an onAction() method.
#### The Swift Implementation
Here's how to bring this pattern to iOS:
`
class ViewModel<State, Action>: ObservableObject {
@Published private(set) var state: State
init(state: State) {
self.state = state
func perform(_ action: Action) {
// Override in subclass
func updateState(changing keyPath: WritableKeyPath<State, some Any>, to value: some Any) {
state[keyPath: keyPath] = value`
The state is readable from anywhere, but only writable from inside the ViewModel. This approach enforces unidirectional data flow where Views can read state, but they can't mutate it directly. All changes go through perform().
You could define this approach as an extension, but a base class gives you a place to put shared logic such as logging, analytics, and common state update patterns. Every ViewModel inherits that behavior.
Subclasses override this base class method to handle their specific actions.
Here is a complete ViewModel using this pattern:
`
class DashboardViewModel: ViewModel<DashboardViewModel.State, DashboardViewModel.Action> {
struct State {
var workouts: Loadable<[Workout]> = .loading
var selectedTab: Tab = .dashboard
enum Action {
case refresh
case selectTab(Tab)
override func perform(_ action: Action) {
switch action {
case .refresh:
Task { await loadWorkouts() }
updateState(\.selectedTab, to: tab)
case .deleteWorkout(let id):
Task { await deleteWorkout(id) }
private func loadWorkouts() async {
updateState(\.workouts, to: .loading)
do {
let workouts = try await repository.fetchWorkouts()
updateState(\.workouts, to: .finished(workouts))
} catch {
updateState(\.workouts, to: .error(error))
private func deleteWorkout(_ id: String) async {
// implementation`
Notice the structure:
- State is a struct with all the ViewModel's data
- Action is an enum with all possible user intents
- perform() is the single entry point that routes to private methods
- Private methods do the actual work
The Action enum is the public contract. The private methods are implementation details. Looking at this file, you immediately know what it does.
Screen vs. View: The Missing Layer
We've solved state management and action routing, but there is another problem: tight coupling. Views own their ViewModels, which breaks Previews and limits reusability.
Look at this standard View:
`
struct DashboardView: View {
@StateObject private var viewModel = DashboardViewModel()
var body: some View {
ScrollView {
switch viewModel.workouts {
case .finished(let data): WorkoutList(data)
case .error(let error): ErrorView(error)`
This View is doing two jobs. Owning the ViewModel (creating it, holding a reference, and observing changes) and rendering UI (laying out views and handling the switch statement).
#### The Preview Problem
Try to preview this View in Xcode:
`
#Preview {
DashboardView()`
The View creates a real ViewModel. The ViewModel might hit the network. It might require dependencies that don't exist in the preview context. It might crash.
So you start hacking around it:
`
#Preview {
DashboardView(viewModel: MockDashboardViewModel())`
But now you either need a mock ViewModel, you have changed the initializer, and mock ViewModels are tedious to maintain, or you give up on previews entirely. Many iOS developers do give up on previews.
Previews become that feature you tried once, couldn't get working reliably, and abandoned.
#### The Reusability Problem
Say you want to show the same workout list in two places, the dashboard and a search results screen. With the current structure, you can't reuse DashboardView because it creates its own DashboardViewModel.
You could extract just the list:
`
struct WorkoutList: View {
let workouts: [Workout]
var body: some View { ... }`
But now you've lost the loading and error states. So you extract more:
`
struct WorkoutListContainer: View {
let state: Loadable<[Workout]>
var body: some View {
switch state {
case .loading: ProgressView()
case .finished(let data): WorkoutList(data)
case .error(let error): ErrorView(error)`
Now you have DashboardView, WorkoutList, and WorkoutListContainer. The extraction happened without a clear principle. Another developer looking at this won't know which pattern to follow.
#### How Kotlin Solves This Problem
In the Now in Android app example, there is a standard pattern: separate screen from content. The screen is a wrapper that owns the ViewModel, while the content is a composable that just renders the UI.
`
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
DashboardContent(
state = state,
onAction = viewModel::onAction
@Composable
fun DashboardContent(
state: DashboardState,
onAction: (DashboardAction) -> Unit
) {
// Pure UI rendering
Column {
is Loading -> CircularProgressIndicator()
is Success -> WorkoutList(state.workouts.data)
is Error -> ErrorMessage(state.workouts.message)`
In Kotlin, the Content composable is trivially previewable:
`
@Preview
@Composable
fun DashboardContentPreview() {
DashboardContent(
state = DashboardState(workouts = Success(sampleWorkouts)),
onAction = {}`
This pattern is used consistently throughout Google's Now in Android sample app.
#### Bringing This to iOS
We can apply the same separation in SwiftUI. First, the Content, a View that takes state and an action handler:
`
struct DashboardContent: View {
let state: DashboardViewModel.State
let onAction: (DashboardViewModel.Action) -> Void
var body: some View {
ScrollView {
switch state.workouts {
WorkoutList(workouts, onAction: onAction)
case .error(let error):
ErrorView(error, onRetry: { onAction(.refresh) })`
Notice that there is no @ObservedObject, no @StateObject, and no ViewModel reference, just data in, UI out.
Now the Screen, a wrapper that owns the ViewModel:
`
struct DashboardScreen: View {
@StateObject private var viewModel: DashboardViewModel
init(viewModel: DashboardViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
var body: some View {
DashboardContent(
state: viewModel.state,`
The screen observes the ViewModel and passes state down. Content doesn't know ViewModels exist.
#### A Generic Screen Wrapper
The screen can be a reusable, generic component:
`
protocol ViewModeling: ObservableObject {
associatedtype State
var state: State { get }
struct Screen<VM: ViewModeling, Content: View>: View {
@ObservedObject var viewModel: VM
let content: (VM.State) -> Content
var body: some View {
content(viewModel.state)`
Now any screen becomes:
`
struct DashboardScreen: View {
@ObservedObject var viewModel: DashboardViewModel
var body: some View {
Screen(viewModel: viewModel) { state, onAction in
WorkoutList(state.workouts) {
viewModel.perform(.action)`
The Data Flow Chain
We have covered individual patterns. Now let's see how they combine into a complete system made of distinct layers.
Figure 1: Relationship from view to API.
Figure 1 shows the downward relationship from the view to the API:
View → ViewModel → Repository → RemoteSource → API
On the other hand, state flows back:
API → RemoteSource → Repository → ViewModel → View
Each layer only knows about the layer below it. The View doesn't know Repositories exist. The Repository doesn't know Views exist. Dependencies point one way.
Why so many layers? Because each one can be tested, mocked, and replaced independently. Swap the RemoteSource for a fake source, and your Repository works offline. Swap the Repository for a mock, and your ViewModel tests don't hit the network.
`
// View
Button(onClick = { viewModel.onAction(Action.Refresh) })
// ViewModel
fun onAction(action: Action) {
when (action) {
is Action.Refresh -> viewModelScope.launch {
_state.value = State(workouts = Resource.Loading)
_state.value = State(workouts = repository.getWorkouts())
// Repository
suspend fun getWorkouts() = remoteSource.fetchWorkouts()
// RemoteSource
suspend fun fetchWorkouts() = api.getWorkouts().map { it.toDomain() }`
#### iOS Equivalent
`
// View
Button("Refresh") { onAction(.refresh) }
// ViewModel
class DashboardViewModel: ViewModel<State, Route, Action> {
private let meRepo: MeRepository
init(dependencies: Resolver) {
self.meRepo = dependencies.resolve(MeRepository.self)!
super.init(dependencies: dependencies, state: State())
override func perform(_ action: Action) {
switch action {
case .refresh:
updateState(changing: \.workouts, to: .loading)
Task {
let data = try await meRepo.fetchTrainingLoad()
updateState(changing: \.workouts, to: .finished(data))
// Repository Layer
protocol MeRepository {
func fetchTrainingLoad() async throws -> [TrainingLoad]
class MeRepositoryImpl: MeRepository {
private let remoteSource: MeRemoteSource
init(dependencies: Resolver) {
self.remoteSource = dependencies.resolve(MeRemoteSource.self)!
func fetchTrainingLoad() async throws -> [TrainingLoad] {
try await remoteSource.fetchTrainingLoad()
// Remote Source Layer
protocol MeRemoteSource {
func fetchTrainingLoad() async throws -> [TrainingLoad]
class MeRemoteSourceImpl: MeRemoteSource {
private let api: API
init(api: API) {
self.api = api
func fetchTrainingLoad() async throws -> [TrainingLoad] {
let response = try await api.query(TrainingLoadQuery())
return response.me?.trainingLoad.map { TrainingLoad(from: $0) } ?? []`
The Reactive Repository Pattern
Here is where the architecture presented earlier really shines.
Imagine this scenario. Your app has two screens: a workout list and a workout detail. The user opens a workout, edits the name, goes back to the list. The list still shows the old name. Why? Because each screen has its own copy of the data. The detail screen modified its copy. The list screen has no idea anything has changed. SwiftUI's @Binding solves this for simple parent-child relationships. You could also pass a callback, post a notification, or refresh in onAppear. But once you have three screens, or independent features needing the same data, none of these scale. You need a single source of truth.
#### The Repository Owns the Data
What if there was only one copy of the data? Every screen observes that single copy. Update it once, everyone sees the change.
That's what the reactive repository pattern does. Here is how it looks in Swift:
`
class WorkoutRepository {
protocol WorkoutRepository {
var workoutsPublisher: AnyPublisher<[Workout], Never> { get }
func updateWorkoutName(id: String, newName: String) async throws
final class WorkoutRepositoryImpl: WorkoutRepository {
private let remoteSource: WorkoutRemoteSource
@Published private var workouts: [Workout] = []
var workoutsPublisher: AnyPublisher<[Workout], Never> {
$workouts.eraseToAnyPublisher()
init(remoteSource: WorkoutRemoteSource) {
self.remoteSource = remoteSource
func updateWorkoutName(id: String, newName: String) async throws {
// 1. Update backend
try await remoteSource.updateWorkout(id: id, name: newName)
// 2. Update local state
if let index = workouts.firstIndex(where: { $0.id == id }) {
workouts[index].name = newName
// All observers notified automatically via @Published`
`
class WorkoutListViewModel: ViewModel<State, Action> {
private let repository: WorkoutRepository
private var cancellables = Set<AnyCancellable>()
init(repository: WorkoutRepository) {
self.repository = repository
super.init(state: State())
repository.workoutsPublisher
.sink { [weak self] workouts in
self?.updateState(\.workouts, to: .finished(workouts))
.store(in: &cancellables)`
Both ViewModels observe the same source. When the repository updates, both receive the new data. No callbacks, no notifications, no manual refreshing. And for testing just inject the mock.
#### The Single Source of Truth Principle
Google's architecture guide calls this the "single source of truth" principle. For any piece of data, there's exactly one owner. Everyone else observes.
- Workouts? Owned by WorkoutRepository
- User profile? Owned by UserRepository
- Settings? Owned by SettingsRepository
ViewModels don't own data. They observe it and expose it to Views. When they need to change something, they ask the repository. The repository updates its state, and the change propagates to everyone observing.
#### The Full Flow
User edits a workout’s name:
- DetailViewModel.perform(.updateName("New Name"))
- DetailViewModel calls repository.updateWorkoutName(...)
- Repository reaches out to remote source and updates backend
- Repository updates its @Published workouts
- ListViewModel receives new workouts via subscription
- DetailViewModel receives new workouts via subscription
- Both views re-render with the new name
One update provides an automatic propagation. If you want a third screen that shows workouts, it just subscribes to the repository. No code changes are required anywhere else.
This is the pattern that makes large apps manageable. Without it, you're playing Whac-A-Mole with stale data across dozens of screens.
Conclusion
Good architecture transcends platforms. By adopting patterns proven in the Android ecosystem, including explicit state management, action based updates, the screen pattern, layered data flow, and reactive repositories, we can build iOS apps that are:
- Maintainable with clear separation of concerns
- Testable where every layer can be mocked
- Scalable so that the pattern works whether you have five screens or fifty
- Predictable with unidirectional data flow makes debugging straightforward
We do not need to reinvent the wheel. We can learn from what works elsewhere and adapt it to our platform. The result is cleaner code and apps that don’t collapse under their own weight as they grow.
About the Author
Ivan Bliznyuk
Show moreShow less
This content is in the Mobile topic
Related Topics:
Development
Kotlin
Design Pattern
JVM Languages
Java
Design
iOS
Swift
Operating Systems
Mobile
SwiftUI
Patterns
Architecture
Related Editorial
Popular across InfoQ
Anthropic Study: AI Coding Assistance Reduces Developer Skill Mastery by 17%
Google Brings its Developer Documentation into the Age of AI Agents
Uforwarder: Uber’s Scalable Kafka Consumer Proxy for Efficient Event-Driven Microservices
Vercel Releases React Best Practices Skill with 40+ Performance Rules for AI Agents
Databricks Introduces Lakebase, a PostgreSQL Database for AI Workloads
Software Evolution with Microservices and LLMs: A Conversation with Chris Richardson
A round-up of last week’s content on InfoQ sent out every Tuesday. Join a community of over 250,000 senior developers.
View an example
We protect your privacy.
---
[Original source](https://www.infoq.com/articles/kotlin-scalable-swiftui-patterns/?utm_campaign=infoq_content&utm_source=infoq&utm_medium=feed&utm_term=global)