Clean Architecture for Mobile Apps
Every senior mobile candidate I've interviewed can draw the concentric circles. Domain in the middle, data layer around it, presentation on the outside. They label the arrows, mention dependency inversion, and nod confidently.
Then I ask: "Show me what a use case looks like in code." And the room gets quiet.
The gap between knowing Clean Architecture as a diagram and actually building with it is massive. I've seen this gap trip up candidates at Google, Airbnb, and Meta. The theory is everywhere. The practical knowledge -- what goes where, why, and how the layers actually connect in Swift or Kotlin -- is not.
This article closes that gap. We'll build a real feature (order history) across all three layers, in both Swift and Kotlin, so you can walk into an interview and talk about Clean Architecture like someone who's shipped it.
The Core Idea: Dependency Inversion#
Forget the concentric circles for a minute. Clean Architecture boils down to one rule:
Inner layers don't know about outer layers.
Your User model shouldn't know whether it came from a REST API or a local database. Your GetOrderHistoryUseCase shouldn't know whether the result gets displayed in a SwiftUI view or a Jetpack Compose screen. The business logic is isolated from the delivery mechanism.
Why does this matter on mobile? Because things change constantly. You swap Alamofire for URLSession. You migrate from Core Data to SwiftData. You replace Retrofit with Ktor. If your domain layer depends on any of those, every infrastructure change becomes a rewrite.
Dependency inversion means the domain layer defines what it needs through interfaces (protocols in Swift, interfaces in Kotlin), and the outer layers provide the implementations. The domain layer is the stable center. Everything else is a plugin.
Domain says: "I need something that can fetch orders."
Data says: "I can do that. Here's my implementation."
Domain says: "I don't care how. Just conform to my interface."
This is the entire philosophy. Everything else is implementation detail.
The Layers in Practice#
Let's make this concrete. We're building an order history feature -- the user opens a screen and sees their past orders, loaded from a remote API with a local cache fallback. This is a feature you'd find in any e-commerce app interview question.
Domain Layer#
The innermost layer. It contains:
- Entities: Plain data models with no framework dependencies
- Use Cases (Interactors): Single-purpose business operations
- Repository Interfaces: Contracts the data layer must fulfill
The domain layer has zero imports from UIKit, SwiftUI, Jetpack Compose, Retrofit, or any framework. It's pure business logic.
Data Layer#
Implements the repository interfaces defined by the domain. It contains:
- Repository Implementations: The actual data-fetching logic
- Data Sources: Remote (API) and local (database/cache)
- Data Transfer Objects (DTOs): Network response models, database entities
- Mappers: Convert DTOs to domain entities
Presentation Layer#
Consumes use cases to drive the UI. It contains:
- ViewModels / Presenters: Orchestrate use cases and manage UI state
- Views: SwiftUI views, Compose screens, UIViewControllers, Fragments
- UI Models: View-specific representations of domain data
Here's what this looks like for our order history feature. These entities live in the domain layer:
// Entity -- no Android imports
data class Order(
val id: String,
val items: List<OrderItem>,
val totalAmount: BigDecimal,
val status: OrderStatus,
val placedAt: Instant
)
enum class OrderStatus {
PLACED, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
data class OrderItem(
val productName: String,
val quantity: Int,
val price: BigDecimal
)
// Entity -- no framework imports
struct Order {
let id: String
let items: [OrderItem]
let totalAmount: Decimal
let status: OrderStatus
let placedAt: Date
}
enum OrderStatus {
case placed, confirmed, shipped, delivered, cancelled
}
struct OrderItem {
let productName: String
let quantity: Int
let price: Decimal
}
Notice: no Parcelable, no Codable, no Room annotations, no @SerializedName. These are plain objects. The data layer handles serialization. The domain doesn't care.
Interview Tip: When whiteboarding, start with the domain entities. It shows the interviewer you think about the problem domain first, not the framework. Most candidates jump straight to the ViewModel or the API call. Starting from the center out signals maturity.
Use Cases: Where Candidates Stumble#
A use case (also called an interactor) represents a single action the user can perform. Not "manage orders." Just "get the user's order history." One class, one job.
Here's the part that confuses people: a use case often looks trivially simple. Candidates see it and think, "Why not just call the repository directly from the ViewModel?"
Sometimes that's a valid shortcut. But use cases earn their place when:
- The operation combines data from multiple repositories
- There's business logic that doesn't belong in the ViewModel or the repository
- You want to test business rules independently from UI logic
Let's build GetOrderHistoryUseCase. It fetches orders from the API, falls back to the local cache if the network fails, and filters out cancelled orders older than 30 days.
protocol GetOrderHistoryUseCaseProtocol {
func execute() async throws -> [Order]
}
final class GetOrderHistoryUseCase: GetOrderHistoryUseCaseProtocol {
private let orderRepository: OrderRepositoryProtocol
private let calendar: Calendar
init(orderRepository: OrderRepositoryProtocol,
calendar: Calendar = .current) {
self.orderRepository = orderRepository
self.calendar = calendar
}
func execute() async throws -> [Order] {
let orders = try await orderRepository.getOrders()
return filterStaleOrders(orders)
}
private func filterStaleOrders(_ orders: [Order]) -> [Order] {
let thirtyDaysAgo = calendar.date(
byAdding: .day, value: -30, to: Date()
)!
return orders.filter { order in
if order.status == .cancelled {
return order.placedAt > thirtyDaysAgo
}
return true
}
}
}
class GetOrderHistoryUseCase(
private val orderRepository: OrderRepository,
private val clock: Clock = Clock.System
) {
suspend operator fun invoke(): List<Order> {
val orders = orderRepository.getOrders()
return filterStaleOrders(orders)
}
private fun filterStaleOrders(orders: List<Order>): List<Order> {
val thirtyDaysAgo = clock.now().minus(30.days)
return orders.filter { order ->
if (order.status == OrderStatus.CANCELLED) {
order.placedAt > thirtyDaysAgo
} else {
true
}
}
}
}
A few things to notice:
- The Kotlin version uses
operator fun invoke()so you can call the use case like a function:val orders = getOrderHistory(). This is a common Kotlin convention for use cases. - Both versions inject a clock/calendar for testability. No hardcoded
Date()orClock.Systemin the logic. - The filtering logic -- removing stale cancelled orders -- is a business rule. It belongs here, not in the ViewModel (which shouldn't know business rules) and not in the repository (which just fetches data).
When Use Cases Are Overkill#
If your use case is literally just:
class GetUserUseCase(private val repo: UserRepository) {
suspend operator fun invoke(id: String) = repo.getUser(id)
}
struct GetUserUseCase {
let repo: UserRepositoryProtocol
func execute(id: String) async throws -> User {
try await repo.getUser(id: id)
}
}
That's a pass-through. It adds a file and an abstraction for zero benefit. In a small app or a prototype, call the repository directly from the ViewModel. In an interview, be upfront about this: "For simple CRUD operations, I'd skip the use case and let the ViewModel call the repository directly. I'd introduce use cases when there's business logic to encapsulate."
Interview Tip: Interviewers love it when you show judgment about when to apply a pattern, not just how. Saying "I'd use a use case here because we need to combine data from two sources and apply business rules" is much stronger than "I always use use cases because that's what Clean Architecture says."
The Repository Pattern#
The repository is the bridge between domain and data. The domain layer defines the interface. The data layer implements it. This is dependency inversion in action.
The interface lives in the domain layer:
interface OrderRepository {
suspend fun getOrders(): List<Order>
suspend fun getOrder(byId: String): Order
}
protocol OrderRepositoryProtocol {
func getOrders() async throws -> [Order]
func getOrder(byId id: String) async throws -> Order
}
The implementation lives in the data layer:
class OrderRepositoryImpl(
private val remoteDataSource: OrderRemoteDataSource,
private val localDataSource: OrderLocalDataSource
) : OrderRepository {
override suspend fun getOrders(): List<Order> {
return try {
val dtos = remoteDataSource.fetchOrders()
val orders = dtos.map { it.toDomain() }
localDataSource.saveOrders(orders)
orders
} catch (e: IOException) {
// Network failed -- fall back to cache
localDataSource.getOrders()
}
}
override suspend fun getOrder(byId: String): Order {
return try {
val dto = remoteDataSource.fetchOrder(byId)
val order = dto.toDomain()
localDataSource.saveOrder(order)
order
} catch (e: IOException) {
localDataSource.getOrder(byId)
}
}
}
final class OrderRepository: OrderRepositoryProtocol {
private let remoteDataSource: OrderRemoteDataSource
private let localDataSource: OrderLocalDataSource
init(remoteDataSource: OrderRemoteDataSource,
localDataSource: OrderLocalDataSource) {
self.remoteDataSource = remoteDataSource
self.localDataSource = localDataSource
}
func getOrders() async throws -> [Order] {
do {
let dtos = try await remoteDataSource.fetchOrders()
let orders = dtos.map { $0.toDomain() }
try await localDataSource.saveOrders(orders)
return orders
} catch {
// Network failed -- fall back to cache
return try await localDataSource.getOrders()
}
}
func getOrder(byId id: String) async throws -> Order {
do {
let dto = try await remoteDataSource.fetchOrder(id: id)
let order = dto.toDomain()
try await localDataSource.saveOrder(order)
return order
} catch {
return try await localDataSource.getOrder(byId: id)
}
}
}
The repository decides the caching strategy (network-first with local fallback here), but the domain doesn't know or care. If you wanted to switch to a cache-first strategy, you'd change the repository implementation without touching the use case.
Interview Tip: When an interviewer asks about offline support, this is the pattern to reach for. The repository decides when to hit the network vs. the cache. The use case doesn't change. The ViewModel doesn't change. That's the whole point.
Dependency Injection: Wiring the Layers#
Clean Architecture requires dependency injection. The domain layer defines interfaces, and the outer layers provide implementations -- but something needs to wire them together.
Android: Hilt#
Hilt is the standard for Android DI. It generates the boilerplate Dagger requires and integrates with Android's lifecycle.
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideOrderRemoteDataSource(
api: OrderApi
): OrderRemoteDataSource {
return OrderRemoteDataSourceImpl(api)
}
@Provides
@Singleton
fun provideOrderLocalDataSource(
db: AppDatabase
): OrderLocalDataSource {
return OrderLocalDataSourceImpl(db.orderDao())
}
@Provides
@Singleton
fun provideOrderRepository(
remote: OrderRemoteDataSource,
local: OrderLocalDataSource
): OrderRepository {
return OrderRepositoryImpl(remote, local)
}
}
@Module
@InstallIn(ViewModelComponent::class)
object UseCaseModule {
@Provides
fun provideGetOrderHistoryUseCase(
repository: OrderRepository
): GetOrderHistoryUseCase {
return GetOrderHistoryUseCase(repository)
}
}
Then the ViewModel just declares what it needs:
@HiltViewModel
class OrderHistoryViewModel @Inject constructor(
private val getOrderHistory: GetOrderHistoryUseCase
) : ViewModel() {
// Hilt provides the use case, which got its repository,
// which got its data sources -- all automatically
}
iOS: Manual DI and Factory Pattern#
iOS doesn't have a dominant DI framework. Many teams use manual dependency injection with a factory or container pattern. Swinject exists, but manual DI is more common in practice and easier to explain in interviews.
final class DependencyContainer {
// Data Sources
lazy var orderRemoteDataSource: OrderRemoteDataSource = {
OrderRemoteDataSourceImpl(session: URLSession.shared)
}()
lazy var orderLocalDataSource: OrderLocalDataSource = {
OrderLocalDataSourceImpl(store: UserDefaults.standard)
}()
// Repositories
lazy var orderRepository: OrderRepositoryProtocol = {
OrderRepository(
remoteDataSource: orderRemoteDataSource,
localDataSource: orderLocalDataSource
)
}()
// Use Cases
func makeGetOrderHistoryUseCase() -> GetOrderHistoryUseCaseProtocol {
GetOrderHistoryUseCase(orderRepository: orderRepository)
}
// ViewModels
func makeOrderHistoryViewModel() -> OrderHistoryViewModel {
OrderHistoryViewModel(
getOrderHistory: makeGetOrderHistoryUseCase()
)
}
}
You create the container at app launch and pass it (or specific dependencies) where needed. No magic, no annotations, easy to follow.
Interview Tip: If an interviewer asks about DI on iOS, don't just name-drop Swinject. Show that you understand the principle: outer layers create the concrete types and inject them into inner layers. Whether you use a framework or a hand-rolled container is a tooling choice, not an architectural one.
Clean Architecture on iOS#
Let's put it all together for an iOS project. Here's a realistic folder structure:
OrdersFeature/
├── Domain/
│ ├── Entities/
│ │ └── Order.swift
│ ├── UseCases/
│ │ └── GetOrderHistoryUseCase.swift
│ └── Repositories/
│ └── OrderRepositoryProtocol.swift
├── Data/
│ ├── Repositories/
│ │ └── OrderRepository.swift
│ ├── DataSources/
│ │ ├── Remote/
│ │ │ ├── OrderRemoteDataSource.swift
│ │ │ └── OrderDTO.swift
│ │ └── Local/
│ │ └── OrderLocalDataSource.swift
│ └── Mappers/
│ └── OrderMapper.swift
└── Presentation/
├── OrderHistoryViewModel.swift
└── OrderHistoryView.swift
Here's the ViewModel and View completing the picture:
@MainActor
final class OrderHistoryViewModel: ObservableObject {
@Published private(set) var orders: [Order] = []
@Published private(set) var isLoading = false
@Published private(set) var error: String?
private let getOrderHistory: GetOrderHistoryUseCaseProtocol
init(getOrderHistory: GetOrderHistoryUseCaseProtocol) {
self.getOrderHistory = getOrderHistory
}
func loadOrders() {
isLoading = true
error = nil
Task {
do {
orders = try await getOrderHistory.execute()
} catch {
self.error = "Failed to load orders. Pull to refresh."
}
isLoading = false
}
}
}
struct OrderHistoryView: View {
@StateObject var viewModel: OrderHistoryViewModel
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
Text(error)
.foregroundStyle(.secondary)
} else {
List(viewModel.orders, id: \.id) { order in
OrderRow(order: order)
}
.refreshable {
viewModel.loadOrders()
}
}
}
.onAppear {
viewModel.loadOrders()
}
.navigationTitle("Order History")
}
}
The View knows about the ViewModel. The ViewModel knows about the use case (through its protocol). The use case knows about the repository (through its protocol). Nobody knows about the layers above them. Data flows down through protocols, events flow up through async/await.
Clean Architecture on Android#
Android's ecosystem makes Clean Architecture slightly more structured, especially with multi-module builds. Here's a typical setup:
:feature:orders/
├── domain/
│ ├── model/
│ │ └── Order.kt
│ ├── usecase/
│ │ └── GetOrderHistoryUseCase.kt
│ └── repository/
│ └── OrderRepository.kt // Interface
├── data/
│ ├── repository/
│ │ └── OrderRepositoryImpl.kt
│ ├── remote/
│ │ ├── OrderApi.kt
│ │ └── OrderDto.kt
│ ├── local/
│ │ ├── OrderDao.kt
│ │ └── OrderEntity.kt
│ ├── mapper/
│ │ └── OrderMapper.kt
│ └── di/
│ └── DataModule.kt
└── presentation/
├── OrderHistoryViewModel.kt
└── OrderHistoryScreen.kt
In a multi-module setup, domain would be a separate Gradle module with no Android dependencies. data depends on domain. presentation depends on domain. Neither data nor presentation depends on each other -- Hilt wires them together at the app level.
Here's the ViewModel and Compose screen:
@HiltViewModel
class OrderHistoryViewModel @Inject constructor(
private val getOrderHistory: GetOrderHistoryUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<OrderHistoryUiState>(
OrderHistoryUiState.Loading
)
val uiState: StateFlow<OrderHistoryUiState> = _uiState.asStateFlow()
init {
loadOrders()
}
fun loadOrders() {
viewModelScope.launch {
_uiState.value = OrderHistoryUiState.Loading
try {
val orders = getOrderHistory()
_uiState.value = OrderHistoryUiState.Success(orders)
} catch (e: Exception) {
_uiState.value = OrderHistoryUiState.Error(
"Failed to load orders. Pull to refresh."
)
}
}
}
}
sealed interface OrderHistoryUiState {
data object Loading : OrderHistoryUiState
data class Success(val orders: List<Order>) : OrderHistoryUiState
data class Error(val message: String) : OrderHistoryUiState
}
@Composable
fun OrderHistoryScreen(
viewModel: OrderHistoryViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(topBar = {
TopAppBar(title = { Text("Order History") })
}) { padding ->
when (val state = uiState) {
is OrderHistoryUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is OrderHistoryUiState.Success -> {
LazyColumn(
modifier = Modifier.padding(padding)
) {
items(state.orders) { order ->
OrderRow(order = order)
}
}
}
is OrderHistoryUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(state.message)
}
}
}
}
}
Notice the Kotlin ViewModel uses sealed interface for UI state. This is idiomatic and gives you exhaustive when blocks -- the compiler forces you to handle every state.
Interview Tip: If the interviewer asks you to design for Android specifically, mention multi-module builds. Separating domain, data, and presentation into Gradle modules enforces the dependency rule at compile time. If your domain module has no Android dependency, it literally cannot import Room or Retrofit. The build system becomes your architecture enforcer.
When Clean Architecture Is Overkill#
I want to be honest here because I've seen too many articles treat Clean Architecture as gospel. It has real costs:
- More files: Even a simple feature ends up with an entity, a use case, a repository interface, a repository implementation, data sources, DTOs, mappers, a ViewModel, and a view. That's 9+ files for one feature.
- More indirection: Tracing a bug through three layers of abstraction takes longer than reading a flat ViewModel that calls an API.
- Slower iteration: In the early stages of a project, you're still figuring out what your domain looks like. Locking it into rigid layers slows you down.
Clean Architecture is overkill for:
- Prototypes and MVPs: You're validating an idea, not building for the next 5 years. Ship fast, refactor later.
- Solo projects or tiny teams: The ceremony of interfaces and use cases pays off when multiple developers need to work in parallel. For one person, it's overhead.
- Simple apps: A flashlight app doesn't need a
ToggleLightUseCase.
It earns its keep for:
- Apps with complex business logic: When there are real rules to encapsulate (pricing, eligibility, data transformations), use cases are worth it.
- Large teams: Clear boundaries between layers let different developers work on data and presentation without stepping on each other.
- Long-lived apps: If your app will be maintained for years, the investment in separation pays dividends during inevitable infrastructure migrations.
Interview Tip: If you're asked to design a small feature in an interview, you don't need to go full Clean Architecture. But mention it: "For this scope, I'd keep it simple with a ViewModel calling a repository directly. If the feature grew in complexity -- say we needed offline sync or multi-source data merging -- I'd introduce use cases to keep the business logic testable and separate." This shows you can scale your architecture to the problem.
How to Discuss Clean Architecture in Interviews#
The biggest mistake candidates make is treating Clean Architecture as a checkbox. They say "I'd use Clean Architecture" and draw the circles. That tells the interviewer nothing about their judgment.
Instead, here's how to talk about it:
1. Start with the problem, not the pattern.
"This feature pulls data from two APIs and a local cache, applies business rules for filtering, and needs to work offline. I'd structure this with Clean Architecture so the business rules are isolated and testable, and the data sources are swappable."
2. Be specific about which layers matter for this problem.
"For this design, the domain layer is thin -- there's not much business logic beyond data fetching. The heavy lifting is in the data layer, where we need to coordinate between the REST API, the WebSocket connection, and the local cache. I'd invest in a solid repository with clear caching strategies rather than building elaborate use cases."
3. Show that you know the tradeoffs.
"Clean Architecture adds indirection. For a feature this simple, I could get away with a ViewModel calling a repository directly. But since the interviewer asked about testability and offline support, the extra layer of use cases gives us clear boundaries for unit testing without mocking the entire data layer."
4. Discuss testing at each layer.
"The use case is the easiest layer to test -- I inject a mock repository and verify the business rules. The repository gets integration tests with a fake data source. The ViewModel gets UI tests that verify state transitions. Each layer has a natural testing strategy."
Here's a cheat sheet for interview discussions:
| The interviewer asks about... | Emphasize this layer... |
|---|---|
| Offline support | Data layer (repository caching strategy) |
| Complex business rules | Domain layer (use cases) |
| Testability | Domain layer (pure logic, easy to mock) |
| Team scalability | All layers (clear boundaries for parallel work) |
| Performance | Data layer (caching, pagination, lazy loading) |
| UI complexity | Presentation layer (state management, ViewModel design) |
Wrapping Up#
Clean Architecture isn't about following a template. It's about one idea: the business logic is the most stable part of your app, so protect it from everything that changes -- frameworks, APIs, databases, UI toolkits.
In an interview, the candidate who explains why they'd put the filtering logic in a use case instead of the ViewModel, who shows the repository interface in the domain layer and the implementation in the data layer, who can articulate when the pattern is worth the overhead and when it isn't -- that candidate stands out.
Don't memorize the circles. Understand the dependency rule, know what goes in each layer, and practice explaining your choices out loud. That's what gets you the offer.