MVVM with Clean Architecture
Overview
This document defines the recommended architecture pattern for mobile applications: MVVM (Model-View-ViewModel) combined with Clean Architecture principles.
Goal
Keep UI, business rules, and infrastructure separated to improve:
- Testability: Easy to write unit and integration tests
- Maintainability: Changes in one layer don't ripple through others
- Team Collaboration: Multiple developers can work on different layers simultaneously
- Platform Independence: Business logic can be shared or ported easily
Clean Architecture Layers
1. Presentation Layer
- Responsibility: UI and state management
- Components: Views, ViewModels, Blocs, UI State
- Dependencies: Only depends on Domain layer
- Examples: SwiftUI Views, Compose Screens, Flutter Pages
2. Domain Layer
- Responsibility: Pure business logic and rules
- Components: Entities, Use Cases, Repository Interfaces
- Dependencies: None (platform-agnostic)
- Examples: User entity, LoginUseCase, AuthRepository protocol
3. Data Layer
- Responsibility: Data access and transformation
- Components: Repository implementations, Data sources, DTOs, Mappers
- Dependencies: Implements Domain interfaces
- Examples: AuthRepositoryImpl, Remote/Local data sources
4. Infrastructure Layer
- Responsibility: Platform-specific implementations
- Components: HTTP clients, Database implementations, Storage, Analytics, Logging
- Dependencies: Provides services to Data layer
- Examples: URLSession wrapper, Retrofit setup, Dio configuration
Core Architecture Rules
Dependency Rule
Dependencies point inward: Presentation → Domain ← Data ← Infrastructure
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (Views, ViewModels, Blocs) │
└──────────────┬──────────────────────────┘
│ depends on
↓
┌─────────────────────────────────────────┐
│ Domain Layer │
│ (Entities, Use Cases, Interfaces) │
└─────────────────────────────────────────┘
↑ implements
│
┌──────────────┴──────────────────────────┐
│ Data Layer │
│ (Repositories, DTOs, Mappers) │
└──────────────┬──────────────────────────┘
│ uses
↓
┌─────────────────────────────────────────┐
│ Infrastructure Layer │
│ (HTTP, DB, Storage, Analytics) │
└─────────────────────────────────────────┘
Key Principles
- Presentation depends on Domain, not Data: ViewModels/Blocs interact with use cases, not repositories directly
- Domain is platform-agnostic: No framework imports (no SwiftUI, Compose, or Flutter widgets in domain)
- Data implements Domain interfaces: Repository implementations live in Data layer
- Infrastructure is pluggable: Easy to swap HTTP clients or databases
Example Flow: User Login
1. User Action (Presentation)
// iOS - LoginView.swift
Button("Login") {
viewModel.login(email: email, password: password)
}
2. ViewModel Calls Use Case (Presentation → Domain)
// iOS - LoginViewModel.swift
@MainActor
final class LoginViewModel: ObservableObject {
private let loginUseCase: LoginUseCase
func login(email: String, password: String) async {
do {
let user = try await loginUseCase.execute(email: email, password: password)
// Navigate to home
} catch {
// Show error
}
}
}
3. Use Case Orchestrates Business Logic (Domain)
// LoginUseCase.swift (Domain layer)
final class LoginUseCase {
private let authRepository: AuthRepository
init(authRepository: AuthRepository) {
self.authRepository = authRepository
}
func execute(email: String, password: String) async throws -> User {
// Validate email format
guard email.contains("@") else {
throw AppError.invalidEmail
}
// Call repository
let tokenPair = try await authRepository.login(email: email, password: password)
// Get user profile
let user = try await authRepository.getCurrentUser()
return user
}
}
4. Repository Coordinates Data Sources (Data)
// AuthRepositoryImpl.swift (Data layer)
final class AuthRepositoryImpl: AuthRepository {
private let remoteDataSource: AuthRemoteDataSource
private let tokenStore: TokenStore
func login(email: String, password: String) async throws -> TokenPair {
let tokenDTO = try await remoteDataSource.login(email: email, password: password)
let tokenPair = tokenDTO.toDomain()
tokenStore.save(tokenPair)
return tokenPair
}
}
5. Remote Data Source Makes API Call (Infrastructure)
// AuthRemoteDataSource.swift (Data layer)
final class AuthRemoteDataSource {
private let apiClient: APIClient
func login(email: String, password: String) async throws -> TokenDTO {
let endpoint = AuthEndpoint.login(email: email, password: password)
return try await apiClient.request(endpoint)
}
}
Benefits of This Architecture
Testability
- Mock repositories in use case tests
- Mock use cases in ViewModel tests
- No UI framework dependencies in business logic
Maintainability
- Clear separation of concerns
- Easy to locate and modify code
- Changes in one layer rarely affect others
Scalability
- New features follow the same pattern
- Team members can work on different layers
- Easy to add new platforms (e.g., watchOS, web)
Flexibility
- Swap data sources (e.g., mock for testing)
- Change UI frameworks without touching business logic
- A/B test different implementations
Platform-Specific Considerations
iOS (Swift/SwiftUI)
- ViewModels are
ObservableObjectclasses - Use
@MainActorfor ViewModels - Leverage Swift's
async/awaitfor asynchronous operations
Android (Kotlin/Compose)
- ViewModels extend
androidx.lifecycle.ViewModel - Use
StateFloworLiveDatafor state - Leverage coroutines for asynchronous operations
Flutter (Dart)
- Use Bloc or Cubit for state management
- Blocs emit immutable state objects
- Leverage async/await or Streams
Common Mistakes
Don't let ViewModels access data sources directly:
// Wrong - ViewModel shouldn't depend on Data layer
class LoginViewModel {
private let apiClient: APIClient
}
Instead, use a Use Case:
// ViewModels should only depend on Domain layer
class LoginViewModel {
private let loginUseCase: LoginUseCase
}
Keep Domain layer free of framework dependencies:
// Wrong - no framework imports in Domain
import SwiftUI
struct User {
var name: String
}
// Domain entities should be platform-agnostic
struct User {
let id: String
let name: String
let email: String
}
Business logic belongs in Use Cases, not Repositories:
// Wrong - validation is business logic
func login(email: String, password: String) async throws -> User {
guard email.contains("@") else { ... }
}
// Business rules go in Use Cases
class LoginUseCase {
func execute(email: String, password: String) async throws -> User {
guard email.contains("@") else { ... }
}
}
Summary
MVVM with Clean Architecture provides:
- Clear separation of concerns across layers
- High testability with mockable dependencies
- Platform independence for business logic
- Maintainable and scalable codebase
- Consistent patterns across iOS, Android, and Flutter
Follow the dependency rule strictly: Presentation → Domain ← Data ← Infrastructure