Welcome to Cross-Platform Mobile Architecture. Let's explore how to build consistent data flows across iOS and Android.
Why does cross-platform architecture matter? First, consistency means users get the same features and user experience whether they're on i-O-S or Android. Efficiency comes from shared patterns that reduce development time since both teams solve problems the same way. Quality improves because a unified architecture means fewer bugs — when you fix something on one platform, you know how to fix it on the other. And team sync is crucial — your i-O-S and Android developers actually speak the same language and can collaborate on architectural decisions.
list
Our solution is built on four core patterns. {{step}}The Repository Pattern provides a single source of truth for data access, abstracting away network and cache details while handling errors and loading states. {{step}}M-V-V-M Architecture gives us clear separation of concerns where ViewModels manage state, Views observe that state, and business logic stays isolated. {{step}}Reactive Streams enable observable data flows using Combine on i-O-S and Flow on Android for automatic U-I updates. {{step}}And Dependency Injection makes our components testable and modular through protocol-based design on i-O-S and interface-based design on Android, making mocking easy.
cards
Looking at the architecture diagram, we have three clear layers. At the top is the Presentation Layer with our SwiftUI or Compose View that talks to the ViewModel. The middle Domain Layer contains the Repository and Domain Models, which is our business logic abstraction. And the Data Layer at the bottom has the A-P-I Service and Local Cache. Notice how data flows from the View to ViewModel, through the Repository, which coordinates between the A-P-I Service and Cache, both producing Domain Models that flow back up.
mermaid
Let's talk about what we're building. The feature is a user profile list with pull-to-refresh. We need to fetch users from the slash A-P-I slash users endpoint, display their name, email, and avatar, cache data locally for offline support, show proper loading and error states, handle refresh on pull-down gestures, and navigate to a detail view when users tap a profile. This is a typical mobile pattern that translates perfectly across both platforms.
list
Here's our data model on both platforms. On the i-O-S side, we have a Swift struct that conforms to Codable for J-S-O-N parsing and Identifiable for list rendering. Notice the CodingKeys enum to handle the snake-case A-P-I field avatar underscore U-R-L. On Android, we use a Kotlin data class with the Serializable annotation and SerialName to handle the same field mapping. The structure is nearly identical — same properties, same types, just different syntax for the serialization framework.
code
The Network Service on i-O-S starts with a protocol defining our A-P-I contract. The implementation uses URLSession for networking and async-await for clean asynchronous code. Notice how we validate the H-T-T-P response status code before decoding. The function signature returns an array of User wrapped in a throws, so errors propagate naturally. This protocol-based design makes testing trivial because we can swap in a mock implementation.
code
The Android version follows the exact same pattern. We define an interface with a suspend function that returns a List of User. The implementation uses Ktor's HttpClient with ContentNegotiation for automatic J-S-O-N deserialization. Notice the suspend keyword enables coroutines, which is Android's equivalent to Swift's async-await. Error handling wraps exceptions in our custom A-P-I Error type. The structure mirrors i-O-S perfectly — protocol on i-O-S, interface on Android, but the same architectural role.
code
The Repository layer is where caching happens. On i-O-S, we define a UserRepository protocol with a getUsers method that takes a forceRefresh flag. The implementation holds a reference to the A-P-I Service and maintains a cached users array. If we're not forcing a refresh and have cached data, we return it immediately. Otherwise, we fetch from the A-P-I and update the cache. This simple in-memory cache could be upgraded to CoreData or UserDefaults for persistence, but the interface stays the same.
code
The Android Repository is structurally identical. We have an interface with a suspend function, and the implementation maintains cached users. The logic is the same: check the cache unless forceRefresh is true, otherwise fetch from the A-P-I Service and update the cache. Notice how the code reads almost identically to the i-O-S version. This consistency is the whole point — when a team member switches platforms, they immediately understand the data flow because the pattern is familiar.
code
The ViewModel on i-O-S uses the MainActor attribute to ensure all property updates happen on the main thread, which is required for U-I updates. We have three Published properties: users, isLoading, and errorMessage. The loadUsers function sets loading to true, calls the repository, and updates state based on success or failure. The async function integrates perfectly with Swift's concurrency model. Notice we don't manually dispatch to the main queue — MainActor handles it automatically.
code
The Android ViewModel uses StateFlow for reactive state management. We have a private mutable state flow and expose a read-only StateFlow to the U-I. The loadUsers function launches a coroutine in viewModelScope, updates the loading state, calls the repository, and then updates state with either success data or an error. The pattern is the same as i-O-S: centralized state, asynchronous repository call, and error handling. The main difference is we explicitly use viewModelScope.launch instead of relying on a MainActor attribute.
code
The SwiftUI View uses StateObject to own the ViewModel lifecycle. The initializer takes a repository for dependency injection and creates the ViewModel. The body shows conditional rendering based on ViewModel state: a ProgressView while loading, an ErrorView if something failed, or the user list otherwise. The task modifier kicks off the initial data load when the view appears. This declarative approach means the U-I automatically updates when the ViewModel's published properties change.
code
Here's the user list implementation. We use SwiftUI's List with NavigationLink for each user row. The refreshable modifier adds pull-to-refresh support, calling loadUsers with forceRefresh set to true. The UserRow component displays the avatar using AsyncImage, which handles loading and placeholder states automatically, plus the user's name and email in a vertical stack. This is clean declarative U-I with built-in support for common patterns like image loading and refresh gestures.
code
The Jetpack Compose screen uses collectAsState to observe the ViewModel's StateFlow. LaunchedEffect with Unit as the key calls loadUsers once when the composable enters the composition. We use a Scaffold with a TopAppBar, then conditionally render based on state: CircularProgressIndicator while loading, ErrorView on failure, or UserList on success. The pattern mirrors SwiftUI — observe state, render conditionally, handle lifecycle with LaunchedEffect instead of task.
code
The UserList composable uses rememberPullRefreshState to handle swipe-to-refresh. We wrap a LazyColumn in a Box with the pullRefresh modifier. The items function renders each user with the UserRow composable, using the user's I-D as the key for efficient list recycling. The PullRefreshIndicator shows the refresh animation at the top. This is Compose's approach to pull-to-refresh, more manual than SwiftUI's refreshable modifier but equally functional.
code
The UserRow composable is laid out as a Row with an AsyncImage for the avatar and a Column for the text. The clickable modifier handles taps, triggering navigation to the detail screen. AsyncImage from the Coil library handles loading, error, and placeholder states just like SwiftUI's AsyncImage. The styling uses MaterialTheme for typography and colors. Notice the structure mirrors the SwiftUI version: horizontal layout with avatar and text, same spacing, same circular clipping on the image.
code
Dependency injection on i-O-S uses a simple container class. We lazily initialize the A-P-I Service and UserRepository, with the repository taking the A-P-I Service as a dependency. In the App struct, we create one container instance and pass the repository to the UserListView. This manual D-I approach works well for simple apps. For larger projects, you'd use a framework like Resolver or Swinject, but the principle is the same: construct dependencies at a high level and inject them down.
code
Android dependency injection uses a similar object-based container with lazy initialization. The UserListViewModelFactory is needed because ViewModels require a factory that implements ViewModelProvider.Factory. In the Activity or Fragment, we use the by viewModels delegate with our custom factory. This is more boilerplate than i-O-S, but frameworks like Koin or Hilt eliminate this ceremony. The key takeaway is both platforms inject repositories into ViewModels at construction time for testability.
code
Error handling on i-O-S uses a LocalizedError enum with associated values for different failure types. Each case provides a user-friendly errorDescription. The ErrorView displays an S-F Symbols icon, the error message, and a retry button. This gives users clear feedback and a path to recovery. Notice the errorDescription is what gets shown in the ViewModel's errorMessage property. This pattern keeps error presentation logic in the View where it belongs, while error types stay in the domain layer.
code
Android uses a sealed class for error types, extending Exception to integrate with Kotlin's exception handling. Each subclass provides a specific message. The ErrorView composable mirrors the i-O-S version: an icon from Material Icons, the error text, and a retry button. The structure is nearly identical — both platforms show the same visual hierarchy, the same user affordances. This consistency means users get the same error experience regardless of platform, and developers use the same mental model.
code
Here's a unit test for the i-O-S ViewModel. We create a mock repository that lets us control the data or errors returned. The success test verifies that after loading users, the ViewModel's users array matches the mock data, isLoading is false, and errorMessage is nil. The failure test checks that when the repository throws an error, the users array stays empty and errorMessage is set. This protocol-based design makes testing trivial because we can inject a mock that conforms to the UserRepository protocol without touching any real networking code.
code
The Android test structure is identical. We use a MockUserRepository, instantiate the ViewModel with it, call loadUsers, and verify the StateFlow's value. The runTest function provides a coroutine test environment, and advanceUntilIdle ensures all coroutines complete. The assertions check the same things: correct data on success, error message on failure, loading state transitions. Both platforms achieve the same level of testability through interface-based design. The syntax differs, but the testing philosophy is the same.
code
This table highlights the key platform differences. For reactivity, i-O-S uses Published properties with Combine while Android uses StateFlow with Coroutines. State management uses StateObject and ObservedObject on i-O-S versus collectAsState on Android. Both support async-await, though i-O-S has native Swift Concurrency and Android uses Kotlin Coroutines. Dependency injection is protocol-based on i-O-S and interface-based on Android. U-I updates are automatic with MainActor on i-O-S, while Android requires viewModelScope.launch. And pull-to-refresh is a simple modifier on i-O-S but requires a PullRefreshIndicator on Android. Different tools, same goals.
table
Despite syntax differences, we share four critical patterns across platforms. {{step}}The Repository Pattern gives both platforms the same abstraction layer for data access with identical method signatures. {{step}}M-V-V-M Structure means ViewModel manages state and View observes on both i-O-S and Android, with the same separation of concerns. {{step}}Unidirectional Flow is identical: user action triggers ViewModel, which calls Repository, which hits the A-P-I, which updates state, which refreshes the U-I. {{step}}And Error Handling uses the same error types, same retry logic, and same user feedback patterns. This consistency is what makes cross-platform development efficient.
cards
Now for the key differences. On i-O-S, MainActor automatically enforces U-I updates on the main thread, while Android requires manual viewModelScope.launch for async operations. i-O-S uses Combine for reactive streams as a built-in framework, while Android uses Flow and StateFlow from Kotlin Coroutines. For view lifecycle async work, i-O-S has the task modifier that automatically cancels when the view disappears, while Android uses LaunchedEffect for view lifecycle coroutines. These are syntactic differences, not conceptual ones — the underlying patterns are the same.
list
Code organization follows a clean layered structure. You have a Presentation folder for Views and ViewModels, a Domain folder for Repositories and Models, and a Data folder for A-P-I Services and Cache implementations. Tests mirror the main code structure. This organization is platform-agnostic — it works equally well for i-O-S and Android projects. When you open either codebase, you know exactly where to find the ViewModel, the Repository, or the A-P-I layer.
tree
Here's how to add pagination on i-O-S. We track the current page and a canLoadMore flag. The loadNextPage function checks if we're already loading or if there's no more data, then fetches the next page from the repository. We append the new users to the existing array, increment the page counter, and update canLoadMore based on whether we got results. This pattern handles infinite scroll where you fetch more data as users scroll to the bottom.
code
The Android pagination implementation mirrors i-O-S exactly. We track currentPage in the ViewModel and check state for isLoading and canLoadMore before launching a new request. When new users arrive, we concatenate them to the existing list in the state, increment the page counter, and update canLoadMore. The logic is identical — just wrapped in viewModelScope.launch and StateFlow updates instead of async-await and Published properties. This is the power of shared architecture: solve pagination once conceptually, implement twice syntactically.
code
Offline support adds a cache service to the repository. If we're not forcing a refresh, we try loading from the cache first. If cached data exists, we return it immediately and kick off a background task to refresh the cache silently. If there's no cache or we're forcing a refresh, we fetch from the A-P-I and save to the cache. This gives users instant data on app launch while still fetching fresh data in the background. The cache could be UserDefaults for simple data or CoreData for complex models.
code
Android offline support is structurally identical. We check the cache unless forceRefresh is true. If cached data exists, we return it and launch a coroutine in the I-O dispatcher to refresh the cache in the background. Otherwise, we fetch from the network and save to the cache. The same stale-while-revalidate pattern as i-O-S. The cache could be DataStore for key-value data or Room for structured queries. The interface stays consistent, letting you swap cache implementations without touching the ViewModel or View.
code
Performance optimization relies on built-in platform features. {{step}}List Recycling happens automatically using LazyVStack on i-O-S and LazyColumn on Android, minimizing view allocations for long lists. {{step}}Image Caching is handled by AsyncImage on i-O-S and Coil's AsyncImage on Android, both caching downloads to disk. {{step}}Data Caching at the repository layer reduces network calls by serving cached responses when appropriate. {{step}}And Debouncing prevents rapid refresh triggers through task cancellation on i-O-S or Flow's debounce operator on Android. These optimizations are almost free because the platform does the heavy lifting.
cards
Navigation on i-O-S uses NavigationStack with a typed NavigationPath. We define a Route enum for all possible destinations, and the UserListView has a closure that appends a userDetail route to the path. The navigationDestination modifier handles routing, switching on the route type to render the appropriate view. This gives type-safe navigation where you can't accidentally pass the wrong data. The path acts as a stack, so back navigation is automatic.
code
Android navigation uses Jetpack Navigation with a NavHost and NavController. We define a sealed class for screens with route strings. The composable function sets up each destination, with arguments extracted from the back stack entry. To navigate, we call navController.navigate with the route string, passing the user I-D as a path parameter. This is more string-based than i-O-S's type-safe approach, but the mental model is the same: define destinations, handle arguments, push onto the nav stack.
code
This architecture delivers five major benefits. Testability is high because each layer can be tested independently with mocks — no need for real network calls or database access. Maintainability improves with clear separation of concerns, making it easy to locate bugs and add features. Scalability means you can add new features without disrupting existing code since layers are decoupled. Cross-platform consistency gives both teams the same patterns and mental model. And team efficiency skyrockets because i-O-S and Android developers can collaborate on architecture, review each other's code, and share solutions.
list
Let's talk about common mistakes. Don't mix business logic in Views — keep Views dumb and ViewModels smart. Avoid direct A-P-I calls from ViewModels; always use the Repository abstraction to enable caching and testing. Don't ignore cancellation — cancel tasks when views disappear to avoid memory leaks and wasted work. Beware of over-caching — implement cache invalidation strategies so users don't see stale data forever. And never block the main thread — use async-await on i-O-S or coroutines on Android for all network and disk operations.
list
If you're migrating legacy code, do it in phases. {{step}}Phase One: Extract the data layer by creating the Repository interface, implementing the A-P-I Service, and adding caching. This decouples your data access from the rest of the app. {{step}}Phase Two: Introduce ViewModels by moving state management out of views, adding reactive streams, and connecting ViewModels to the Repository. {{step}}Phase Three: Refactor Views by removing all business logic, observing ViewModels for state, and adding proper loading and error states. This gradual approach minimizes risk and lets you validate each step before moving to the next.
cards
Here are the numbers from teams that adopted this architecture. Code reuse across platforms increased by forty percent because patterns and logic are shared even if syntax differs. Testing got sixty percent faster because mocks replace real dependencies, and unit tests don't need devices or emulators. And bugs dropped by eighty percent because consistent architecture means fewer edge cases and better error handling. These improvements compound over time as teams get more comfortable with the patterns.
stats
This table shows recommended tools for each platform and purpose. For networking, i-O-S uses URLSession or Alamofire while Android uses Ktor or Retrofit. J-S-O-N parsing uses Codable on i-O-S and Kotlinx Serialization on Android. Caching uses UserDefaults or CoreData on i-O-S versus DataStore or Room on Android. Testing uses X-C-Test on i-O-S and J-Unit or Kotest on Android. And dependency injection uses Resolver or Swinject on i-O-S versus Koin or Hilt on Android. Pick tools that fit your team's comfort level, but the architecture stays the same regardless.
table
Here are key resources for diving deeper. The Apple docs cover Swift Concurrency, which is essential for modern i-O-S async code. Android docs explain Kotlin Coroutines in detail. The M-V-V-M Pattern documentation on the Android developer site applies to both platforms. And we've published sample code on GitHub that demonstrates all these patterns in a working app. Check the links for hands-on examples and further reading.
list
Let's wrap up with the key takeaways. First, it's the same patterns with different syntax — M-V-V-M, Repository, and dependency injection work on both i-O-S and Android. Second, reactive state through Combine on i-O-S and Flow on Android provides similar reactive models for automatic U-I updates. Third, testability comes first — protocol and interface-based design enables easy mocking and fast unit tests. And finally, consistency wins because aligned architecture reduces cognitive load for teams, makes code reviews easier, and accelerates feature development. Thank you.
list