Skip to main content

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

  1. Presentation depends on Domain, not Data: ViewModels/Blocs interact with use cases, not repositories directly
  2. Domain is platform-agnostic: No framework imports (no SwiftUI, Compose, or Flutter widgets in domain)
  3. Data implements Domain interfaces: Repository implementations live in Data layer
  4. 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 ObservableObject classes
  • Use @MainActor for ViewModels
  • Leverage Swift's async/await for asynchronous operations

Android (Kotlin/Compose)

  • ViewModels extend androidx.lifecycle.ViewModel
  • Use StateFlow or LiveData for 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